mirror of
https://github.com/github/awesome-copilot.git
synced 2026-06-13 03:23:30 +00:00
chore: publish from staged
This commit is contained in:
@@ -194,6 +194,7 @@ const INSTRUCTIONS_DIR = path.join(ROOT_FOLDER, "instructions");
|
||||
const AGENTS_DIR = path.join(ROOT_FOLDER, "agents");
|
||||
const SKILLS_DIR = path.join(ROOT_FOLDER, "skills");
|
||||
const HOOKS_DIR = path.join(ROOT_FOLDER, "hooks");
|
||||
const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions");
|
||||
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
|
||||
const WORKFLOWS_DIR = path.join(ROOT_FOLDER, "workflows");
|
||||
const COOKBOOK_DIR = path.join(ROOT_FOLDER, "cookbook");
|
||||
@@ -212,6 +213,7 @@ export {
|
||||
AKA_INSTALL_URLS,
|
||||
COOKBOOK_DIR,
|
||||
DOCS_DIR,
|
||||
EXTENSIONS_DIR,
|
||||
HOOKS_DIR,
|
||||
INSTRUCTIONS_DIR,
|
||||
MAX_PLUGIN_ITEMS,
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { execSync } from "child_process";
|
||||
import {
|
||||
AGENTS_DIR,
|
||||
COOKBOOK_DIR,
|
||||
EXTENSIONS_DIR,
|
||||
HOOKS_DIR,
|
||||
INSTRUCTIONS_DIR,
|
||||
PLUGINS_DIR,
|
||||
@@ -64,6 +66,68 @@ function extractTitle(filePath, frontmatter) {
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert kebab/snake names into readable titles.
|
||||
*/
|
||||
function formatDisplayName(value) {
|
||||
const acronymMap = new Map([
|
||||
["ai", "AI"],
|
||||
["api", "API"],
|
||||
["cli", "CLI"],
|
||||
["css", "CSS"],
|
||||
["html", "HTML"],
|
||||
["json", "JSON"],
|
||||
["llm", "LLM"],
|
||||
["mcp", "MCP"],
|
||||
["ui", "UI"],
|
||||
["ux", "UX"],
|
||||
["vscode", "VS Code"],
|
||||
]);
|
||||
|
||||
return value
|
||||
.split(/[-_]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
const lower = part.toLowerCase();
|
||||
if (acronymMap.has(lower)) {
|
||||
return acronymMap.get(lower);
|
||||
}
|
||||
return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the latest git-modified date for any file under a directory.
|
||||
*/
|
||||
function getDirectoryLastUpdated(gitDates, relativeDirPath) {
|
||||
const prefix = `${relativeDirPath}/`;
|
||||
let latestDate = null;
|
||||
let latestTime = 0;
|
||||
|
||||
for (const [filePath, date] of gitDates.entries()) {
|
||||
if (!filePath.startsWith(prefix)) continue;
|
||||
const timestamp = Date.parse(date);
|
||||
if (!Number.isNaN(timestamp) && timestamp > latestTime) {
|
||||
latestTime = timestamp;
|
||||
latestDate = date;
|
||||
}
|
||||
}
|
||||
|
||||
return latestDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current commit SHA for the checked-out repository.
|
||||
*/
|
||||
function getCurrentCommitSha() {
|
||||
return execSync("git --no-pager rev-parse HEAD", {
|
||||
cwd: ROOT_FOLDER,
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate agents metadata
|
||||
*/
|
||||
@@ -603,6 +667,38 @@ function generatePluginsData(gitDates) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate canvas extensions metadata
|
||||
*/
|
||||
function generateExtensionsData(gitDates, commitSha) {
|
||||
const extensions = [];
|
||||
|
||||
if (!fs.existsSync(EXTENSIONS_DIR)) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
const extensionDirs = fs
|
||||
.readdirSync(EXTENSIONS_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory());
|
||||
|
||||
for (const dir of extensionDirs) {
|
||||
const relPath = `extensions/${dir.name}`;
|
||||
extensions.push({
|
||||
id: dir.name,
|
||||
name: formatDisplayName(dir.name),
|
||||
path: relPath,
|
||||
ref: commitSha,
|
||||
lastUpdated: getDirectoryLastUpdated(gitDates, relPath),
|
||||
});
|
||||
}
|
||||
|
||||
const sortedExtensions = extensions.sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
|
||||
return { items: sortedExtensions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate tools metadata from website/data/tools.yml
|
||||
*/
|
||||
@@ -893,12 +989,22 @@ async function main() {
|
||||
// Load git dates for all resource files (single efficient git command)
|
||||
console.log("Loading git history for last updated dates...");
|
||||
const gitDates = getGitFileDates(
|
||||
["agents/", "instructions/", "hooks/", "workflows/", "skills/", "plugins/"],
|
||||
[
|
||||
"agents/",
|
||||
"instructions/",
|
||||
"hooks/",
|
||||
"workflows/",
|
||||
"skills/",
|
||||
"extensions/",
|
||||
"plugins/",
|
||||
],
|
||||
ROOT_FOLDER
|
||||
);
|
||||
console.log(`✓ Loaded dates for ${gitDates.size} files\n`);
|
||||
|
||||
// Generate all data
|
||||
const commitSha = getCurrentCommitSha();
|
||||
|
||||
const agentsData = generateAgentsData(gitDates);
|
||||
const agents = agentsData.items;
|
||||
console.log(
|
||||
@@ -933,6 +1039,10 @@ async function main() {
|
||||
`✓ Generated ${plugins.length} plugins (${pluginsData.filters.tags.length} tags)`
|
||||
);
|
||||
|
||||
const extensionsData = generateExtensionsData(gitDates, commitSha);
|
||||
const extensions = extensionsData.items;
|
||||
console.log(`✓ Generated ${extensions.length} extensions`);
|
||||
|
||||
const toolsData = generateToolsData();
|
||||
const tools = toolsData.items;
|
||||
console.log(
|
||||
@@ -991,6 +1101,11 @@ async function main() {
|
||||
JSON.stringify(pluginsData, null, 2)
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(WEBSITE_DATA_DIR, "extensions.json"),
|
||||
JSON.stringify(extensionsData, null, 2)
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(WEBSITE_DATA_DIR, "tools.json"),
|
||||
JSON.stringify(toolsData, null, 2)
|
||||
@@ -1016,6 +1131,7 @@ async function main() {
|
||||
hooks: hooks.length,
|
||||
workflows: workflows.length,
|
||||
plugins: plugins.length,
|
||||
extensions: extensions.length,
|
||||
tools: tools.length,
|
||||
contributors: contributorCount,
|
||||
samples: samplesData.totalRecipes,
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension";
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const EXTENSION_NAME = "accessibility-kanban";
|
||||
const STATE_FILE = "signalbox-accessibility-kanban-state.json";
|
||||
const COLUMNS = ["backlog", "plan", "ready", "implement", "done"];
|
||||
const VALID_COLUMNS = new Set(COLUMNS);
|
||||
|
||||
const defaultIssues = [
|
||||
{
|
||||
number: 39,
|
||||
title: "Add keyboard trap prevention for modal-like interactions",
|
||||
url: "https://github.com/sethjuarez/SignalBox/issues/39",
|
||||
labels: ["signalbox-mvp", "frontend", "accessibility"],
|
||||
column: "backlog",
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
number: 38,
|
||||
title: "Ensure color contrast meets WCAG AA for all text",
|
||||
url: "https://github.com/sethjuarez/SignalBox/issues/38",
|
||||
labels: ["signalbox-mvp", "product-polish", "accessibility"],
|
||||
column: "backlog",
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
number: 37,
|
||||
title: "Add aria-live region for form submission feedback",
|
||||
url: "https://github.com/sethjuarez/SignalBox/issues/37",
|
||||
labels: ["signalbox-mvp", "frontend", "accessibility"],
|
||||
column: "backlog",
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
number: 36,
|
||||
title: "Add focus-visible outline to all interactive elements",
|
||||
url: "https://github.com/sethjuarez/SignalBox/issues/36",
|
||||
labels: ["signalbox-mvp", "frontend", "accessibility"],
|
||||
column: "backlog",
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
number: 35,
|
||||
title: "Add aria-hidden to decorative SVG icons in AuthPage",
|
||||
url: "https://github.com/sethjuarez/SignalBox/issues/35",
|
||||
labels: ["signalbox-mvp", "frontend", "accessibility"],
|
||||
column: "backlog",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
number: 20,
|
||||
title: "Audit and fix form field label association and aria-describedby",
|
||||
url: "https://github.com/sethjuarez/SignalBox/issues/20",
|
||||
labels: ["signalbox-mvp", "frontend", "product-polish", "accessibility"],
|
||||
column: "backlog",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
number: 19,
|
||||
title: "Ensure consistent keyboard focus styles across the intake form",
|
||||
url: "https://github.com/sethjuarez/SignalBox/issues/19",
|
||||
labels: ["enhancement", "good first issue", "ready-for-implementation", "frontend", "accessibility"],
|
||||
column: "backlog",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
number: 17,
|
||||
title: "Add accessible client-side validation errors to the intake form",
|
||||
url: "https://github.com/sethjuarez/SignalBox/issues/17",
|
||||
labels: ["enhancement", "good first issue", "ready-for-implementation", "frontend", "accessibility"],
|
||||
column: "backlog",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
number: 16,
|
||||
title: "Improve page landmark and heading structure for screen reader navigation",
|
||||
url: "https://github.com/sethjuarez/SignalBox/issues/16",
|
||||
labels: ["good first issue", "signalbox-mvp", "frontend", "product-polish", "accessibility"],
|
||||
column: "backlog",
|
||||
priority: "medium",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── State persistence ───
|
||||
|
||||
function copilotHome() {
|
||||
return process.env.COPILOT_HOME || path.join(os.homedir(), ".copilot");
|
||||
}
|
||||
|
||||
function getStatePath() {
|
||||
return path.join(copilotHome(), "extensions", EXTENSION_NAME, "artifacts", STATE_FILE);
|
||||
}
|
||||
|
||||
function defaultState() {
|
||||
return {
|
||||
repo: "sethjuarez/SignalBox",
|
||||
updatedAt: new Date().toISOString(),
|
||||
generation: Date.now(),
|
||||
columns: COLUMNS,
|
||||
issues: defaultIssues.map((issue, index) => ({ ...issue, order: index })),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureStateDirectory() {
|
||||
fs.mkdirSync(path.dirname(getStatePath()), { recursive: true });
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(getStatePath(), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveState(state) {
|
||||
ensureStateDirectory();
|
||||
fs.writeFileSync(getStatePath(), JSON.stringify({ ...state, updatedAt: new Date().toISOString() }, null, 2));
|
||||
}
|
||||
|
||||
function currentState() {
|
||||
const state = loadState();
|
||||
if (state) return state;
|
||||
const initial = defaultState();
|
||||
saveState(initial);
|
||||
return initial;
|
||||
}
|
||||
|
||||
// ─── Issue operations ───
|
||||
|
||||
function moveIssue(issueNumber, column) {
|
||||
if (!VALID_COLUMNS.has(column)) {
|
||||
throw new CanvasError("invalid_column", `Column must be one of: ${COLUMNS.join(", ")}`);
|
||||
}
|
||||
const state = currentState();
|
||||
const issue = state.issues.find((i) => i.number === issueNumber);
|
||||
if (!issue) {
|
||||
throw new CanvasError("not_found", `Issue #${issueNumber} not found on the board`);
|
||||
}
|
||||
const prevColumn = issue.column;
|
||||
issue.column = column;
|
||||
issue.order = state.issues.filter((i) => i.column === column).length;
|
||||
// Clear agent status when moved to done or backlog
|
||||
if (column === "done" || column === "backlog") {
|
||||
issue.agentActive = false;
|
||||
issue.agentStatus = column === "done" ? "Complete" : "";
|
||||
}
|
||||
saveState(state);
|
||||
broadcast("state", currentState());
|
||||
return { issue, prevColumn };
|
||||
}
|
||||
|
||||
function updateIssueStatus(issueNumber, status, logEntry) {
|
||||
const state = currentState();
|
||||
const issue = state.issues.find((i) => i.number === issueNumber);
|
||||
if (!issue) {
|
||||
throw new CanvasError("not_found", `Issue #${issueNumber} not found on the board`);
|
||||
}
|
||||
// Don't update agent status on issues that have been reset to backlog
|
||||
if (issue.column === "backlog") {
|
||||
return issue;
|
||||
}
|
||||
if (status !== undefined) issue.agentStatus = status;
|
||||
if (logEntry) {
|
||||
if (!issue.logs) issue.logs = [];
|
||||
issue.logs.push({ timestamp: new Date().toISOString(), message: logEntry });
|
||||
}
|
||||
issue.agentActive = true;
|
||||
saveState(state);
|
||||
broadcast("state", currentState());
|
||||
return issue;
|
||||
}
|
||||
|
||||
function clearAgentStatus(issueNumber) {
|
||||
const state = currentState();
|
||||
const issue = state.issues.find((i) => i.number === issueNumber);
|
||||
if (!issue) return;
|
||||
issue.agentActive = false;
|
||||
saveState(state);
|
||||
broadcast("state", currentState());
|
||||
}
|
||||
|
||||
function replaceIssues(issues) {
|
||||
const existing = currentState();
|
||||
const existingByNumber = new Map(existing.issues.map((i) => [i.number, i]));
|
||||
const next = {
|
||||
...existing,
|
||||
issues: issues
|
||||
.filter((i) => i && Number.isInteger(i.number) && i.title)
|
||||
.map((issue, idx) => {
|
||||
const prev = existingByNumber.get(issue.number);
|
||||
const labels = Array.isArray(issue.labels)
|
||||
? issue.labels.map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.url || `https://github.com/sethjuarez/SignalBox/issues/${issue.number}`,
|
||||
labels,
|
||||
column: VALID_COLUMNS.has(issue.column) ? issue.column : prev?.column || "backlog",
|
||||
priority: issue.priority || prev?.priority || "medium",
|
||||
order: Number.isInteger(issue.order) ? issue.order : prev?.order ?? idx,
|
||||
};
|
||||
}),
|
||||
};
|
||||
saveState(next);
|
||||
broadcast("state", currentState());
|
||||
return currentState();
|
||||
}
|
||||
|
||||
// ─── SSE ───
|
||||
|
||||
const sseClients = new Set();
|
||||
|
||||
function broadcast(event, data) {
|
||||
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
for (const res of sseClients) res.write(msg);
|
||||
}
|
||||
|
||||
// ─── HTTP helpers ───
|
||||
|
||||
function readJson(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => resolve(body ? JSON.parse(body) : {}));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function json(res, code, data) {
|
||||
res.writeHead(code, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
// ─── HTTP server ───
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
|
||||
if (url.pathname === "/events") {
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
||||
sseClients.add(res);
|
||||
req.on("close", () => sseClients.delete(res));
|
||||
res.write(`event: state\ndata: ${JSON.stringify(currentState())}\n\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/state") {
|
||||
json(res, 200, currentState());
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/move") {
|
||||
const input = await readJson(req);
|
||||
const { issue, prevColumn } = moveIssue(input.issue_number, input.column);
|
||||
|
||||
// When an issue moves INTO "plan", send a prompt to the agent
|
||||
if (input.column === "plan" && prevColumn !== "plan") {
|
||||
if (issue.number === 35) {
|
||||
// Fast path for demo — issue 35 is trivial, skip full analysis
|
||||
session.send({
|
||||
prompt: `The accessibility kanban board just moved issue #35 ("Add aria-hidden to decorative SVG icons in AuthPage") into the Plan column. This is a simple fix — just add aria-hidden="true" to the two decorative blur divs and the Microsoft logo SVG in src/components/AuthPage.tsx. Use the kanban_update_status tool to post a brief status update ("Analyzing..."), then after a moment post the plan summary, then move the issue to "ready" using kanban_move_issue. Keep it quick — no need to read the GitHub issue or deeply analyze the codebase. The plan is: add aria-hidden="true" to lines ~47-48 (decorative background circles) and the SVG element at lines ~6-17.`,
|
||||
});
|
||||
} else {
|
||||
session.send({
|
||||
prompt: `The accessibility kanban board just moved issue #${issue.number} ("${issue.title}") into the Plan column. Please start planning the implementation for this issue in a background agent. Read the issue details from GitHub, analyze the codebase to understand what needs to change, and produce a concrete implementation plan. When planning is complete, move the issue to "ready" on the canvas using the move_issue canvas action.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
json(res, 200, { issue, state: currentState() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/update-status") {
|
||||
const input = await readJson(req);
|
||||
const issue = updateIssueStatus(input.issue_number, input.status, input.log);
|
||||
if (input.done) clearAgentStatus(input.issue_number);
|
||||
json(res, 200, { issue, state: currentState() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname.startsWith("/api/logs/")) {
|
||||
const num = parseInt(url.pathname.split("/").pop(), 10);
|
||||
const state = currentState();
|
||||
const issue = state.issues.find((i) => i.number === num);
|
||||
if (!issue) { json(res, 404, { error: "not found" }); return; }
|
||||
json(res, 200, { issue_number: num, title: issue.title, logs: issue.logs || [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/reset") {
|
||||
const s = defaultState();
|
||||
saveState(s);
|
||||
broadcast("state", currentState());
|
||||
json(res, 200, currentState());
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/") {
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(fs.readFileSync(path.join(__dirname, "public", "index.html"), "utf8"));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
function getPort() { return server.address().port; }
|
||||
|
||||
// ─── Canvas declaration ───
|
||||
|
||||
const canvas = createCanvas({
|
||||
id: "accessibility-kanban",
|
||||
displayName: "Accessibility Kanban",
|
||||
description: "Kanban board for triaging open SignalBox accessibility issues into backlog, plan, ready, implement, and done lanes. Moving an issue to plan triggers a background planning agent.",
|
||||
actions: [
|
||||
{
|
||||
name: "get_state",
|
||||
description: "Get the current Kanban board state including all issues and their columns.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
handler() {
|
||||
return currentState();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "move_issue",
|
||||
description: "Move an issue to a different column on the Kanban board.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
issue_number: { type: "number", description: "GitHub issue number" },
|
||||
column: { type: "string", enum: COLUMNS, description: "Target column" },
|
||||
},
|
||||
required: ["issue_number", "column"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler({ input }) {
|
||||
const { issue } = moveIssue(input.issue_number, input.column);
|
||||
return { issue, state: currentState() };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "refresh_issues",
|
||||
description: "Replace the board with fresh issue data supplied by the agent.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
issues: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
number: { type: "number" },
|
||||
title: { type: "string" },
|
||||
url: { type: "string" },
|
||||
labels: { type: "array", items: { oneOf: [{ type: "string" }, { type: "object", properties: { name: { type: "string" } }, required: ["name"] }] } },
|
||||
column: { type: "string", enum: COLUMNS },
|
||||
priority: { type: "string" },
|
||||
order: { type: "number" },
|
||||
},
|
||||
required: ["number", "title"],
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["issues"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler({ input }) {
|
||||
return replaceIssues(input.issues);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reset_state",
|
||||
description: "Reset the board to the default issue list with everything in backlog.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
handler() {
|
||||
const s = defaultState();
|
||||
saveState(s);
|
||||
broadcast("state", currentState());
|
||||
return currentState();
|
||||
},
|
||||
},
|
||||
],
|
||||
open() {
|
||||
const state = currentState();
|
||||
broadcast("state", state);
|
||||
return {
|
||||
url: `http://127.0.0.1:${getPort()}`,
|
||||
title: "Accessibility Kanban",
|
||||
status: `${state.issues.length} issues across ${COLUMNS.length} columns`,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Join session (tools + canvas) ───
|
||||
|
||||
const session = await joinSession({
|
||||
canvases: [canvas],
|
||||
tools: [
|
||||
{
|
||||
name: "kanban_move_issue",
|
||||
description: "Move an issue on the accessibility Kanban board to a new column (backlog, plan, ready, implement, done). Use after completing a planning or implementation step to advance the issue.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
issue_number: { type: "number", description: "GitHub issue number" },
|
||||
column: { type: "string", enum: COLUMNS, description: "Target column to move the issue to" },
|
||||
},
|
||||
required: ["issue_number", "column"],
|
||||
},
|
||||
handler: async (args) => {
|
||||
const { issue } = moveIssue(args.issue_number, args.column);
|
||||
return JSON.stringify({ moved: true, issue, state: currentState() });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "kanban_update_status",
|
||||
description: "Update the agent status line and log on a Kanban card. Use this to report progress while planning or implementing an issue. The status appears under the card title and a glow indicates active work.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
issue_number: { type: "number", description: "GitHub issue number" },
|
||||
status: { type: "string", description: "Short status text shown on the card (e.g. 'Reading issue...', 'Analyzing codebase...', 'Plan complete')" },
|
||||
log: { type: "string", description: "Detailed log entry appended to the issue's agent log (viewable in modal)" },
|
||||
done: { type: "boolean", description: "Set true to stop the active glow (agent finished working)" },
|
||||
},
|
||||
required: ["issue_number", "status"],
|
||||
},
|
||||
handler: async (args) => {
|
||||
const issue = updateIssueStatus(args.issue_number, args.status, args.log);
|
||||
if (args.done) clearAgentStatus(args.issue_number);
|
||||
return JSON.stringify({ updated: true, issue });
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "accessibility-kanban",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "extension.mjs",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Accessibility Kanban</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--outer: #f1f5f9;
|
||||
--text: #111827;
|
||||
--muted: #6b7280;
|
||||
--meta: #94a3b8;
|
||||
--border: #e5e7eb;
|
||||
--coral: #ff7f50;
|
||||
--azure: #0ea5e9;
|
||||
--sage: #84cc16;
|
||||
--violet: #a78bfa;
|
||||
--radius: 6px;
|
||||
--font: 'DM Sans', sans-serif;
|
||||
--mono: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--outer);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.board-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
header .breadcrumb {
|
||||
font-size: 12px;
|
||||
color: var(--meta);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
header .breadcrumb .sep {
|
||||
margin: 0 5px;
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
header .breadcrumb .current {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
header .spacer { flex: 1; }
|
||||
|
||||
header .reset-btn {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
color: var(--meta);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
header .reset-btn:hover {
|
||||
border-color: var(--coral);
|
||||
color: var(--coral);
|
||||
}
|
||||
|
||||
.board {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.column {
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--border);
|
||||
border-top: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.column:last-child { border-right: none; }
|
||||
|
||||
.col-backlog { border-top-color: var(--meta); }
|
||||
.col-plan { border-top-color: var(--azure); }
|
||||
.col-ready { border-top-color: var(--sage); }
|
||||
.col-implement { border-top-color: var(--coral); }
|
||||
.col-done { border-top-color: var(--violet); }
|
||||
|
||||
.column-header {
|
||||
padding: 10px 12px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.column-header h2 {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.column-header .count {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
color: var(--meta);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 10px;
|
||||
cursor: grab;
|
||||
transition: border-color 0.12s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--meta);
|
||||
}
|
||||
|
||||
.card.dragging {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.card .issue-title {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
color: var(--text);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card .issue-title .num {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
color: var(--meta);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* Agent status line */
|
||||
.card .agent-status {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
color: var(--azure);
|
||||
margin-top: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.card .agent-status .pulse {
|
||||
width: 4px; height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--azure);
|
||||
animation: pulse-dot 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Glowing border for active agent work */
|
||||
.card.agent-active {
|
||||
border-color: rgba(14, 165, 233, 0.4);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(14, 165, 233, 0.15),
|
||||
0 0 12px rgba(14, 165, 233, 0.1);
|
||||
animation: glow-pulse 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(14, 165, 233, 0.15),
|
||||
0 0 12px rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(14, 165, 233, 0.3),
|
||||
0 0 18px rgba(14, 165, 233, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* Log button */
|
||||
.card .log-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--meta);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.card .log-btn:hover {
|
||||
background: rgba(14,165,233,0.08);
|
||||
color: var(--azure);
|
||||
}
|
||||
|
||||
.card.agent-active .log-btn { display: flex; }
|
||||
.card.has-logs:hover .log-btn { display: flex; }
|
||||
|
||||
/* Drop target highlight */
|
||||
.column.drop-target {
|
||||
background: rgba(14,165,233,0.02);
|
||||
}
|
||||
|
||||
/* Ghost (drag preview) */
|
||||
.ghost {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0.85;
|
||||
transform: rotate(1.5deg) scale(1.01);
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
/* Log Modal */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.3);
|
||||
z-index: 10000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal-overlay.open { display: flex; }
|
||||
|
||||
.modal {
|
||||
background: var(--bg);
|
||||
border-radius: 10px;
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
max-height: 65vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-header button {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.modal-header button:hover { background: var(--border); }
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.log-entry:last-child { border-bottom: none; }
|
||||
|
||||
.log-entry .ts {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
color: var(--meta);
|
||||
white-space: nowrap;
|
||||
padding-top: 2px;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.log-entry .msg {
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.empty-log {
|
||||
font-size: 11px;
|
||||
color: var(--meta);
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="board-wrap">
|
||||
<header>
|
||||
<div class="breadcrumb">
|
||||
SignalBox<span class="sep">/</span><span class="current">Accessibility Sprint</span>
|
||||
</div>
|
||||
<span class="spacer"></span>
|
||||
<button class="reset-btn" id="reset-btn">Reset</button>
|
||||
</header>
|
||||
<div class="board" id="board"></div>
|
||||
</div>
|
||||
|
||||
<!-- Log Modal -->
|
||||
<div class="modal-overlay" id="modal-overlay">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-title">Agent Log</h3>
|
||||
<button id="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const COLUMNS = ["backlog", "plan", "ready", "implement", "done"];
|
||||
const COL_LABELS = { backlog: "Backlog", plan: "Plan", ready: "Ready", implement: "Implement", done: "Done" };
|
||||
|
||||
let state = { issues: [] };
|
||||
let dragState = null;
|
||||
|
||||
// ─── Rendering ───
|
||||
|
||||
function render() {
|
||||
const board = document.getElementById("board");
|
||||
board.innerHTML = "";
|
||||
|
||||
COLUMNS.forEach((col) => {
|
||||
const issues = state.issues
|
||||
.filter((i) => i.column === col)
|
||||
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
|
||||
const colEl = document.createElement("div");
|
||||
colEl.className = `column col-${col}`;
|
||||
colEl.dataset.column = col;
|
||||
|
||||
colEl.innerHTML = `
|
||||
<div class="column-header">
|
||||
<h2>${COL_LABELS[col]}</h2>
|
||||
<span class="count">${issues.length}</span>
|
||||
</div>
|
||||
<div class="card-list" data-column="${col}"></div>
|
||||
`;
|
||||
|
||||
const list = colEl.querySelector(".card-list");
|
||||
issues.forEach((issue) => {
|
||||
const card = document.createElement("div");
|
||||
const hasLogs = issue.logs && issue.logs.length > 0;
|
||||
let cls = "card";
|
||||
if (issue.agentActive) cls += " agent-active";
|
||||
if (hasLogs) cls += " has-logs";
|
||||
card.className = cls;
|
||||
card.dataset.issueNumber = issue.number;
|
||||
|
||||
let statusHtml = "";
|
||||
if (issue.agentStatus) {
|
||||
statusHtml = `<div class="agent-status">${issue.agentActive ? '<span class="pulse"></span>' : ''}${escHtml(issue.agentStatus)}</div>`;
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<button class="log-btn" title="View agent log">▣</button>
|
||||
<div class="issue-title"><span class="num">#${issue.number}</span>${escHtml(issue.title)}</div>
|
||||
${statusHtml}
|
||||
`;
|
||||
|
||||
// Log button click
|
||||
const logBtn = card.querySelector(".log-btn");
|
||||
if (logBtn) {
|
||||
logBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
openLogModal(issue);
|
||||
});
|
||||
}
|
||||
|
||||
// Pointer drag
|
||||
card.addEventListener("pointerdown", (e) => startDrag(e, card, issue));
|
||||
list.appendChild(card);
|
||||
});
|
||||
|
||||
board.appendChild(colEl);
|
||||
});
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// ─── Log Modal ───
|
||||
|
||||
function openLogModal(issue) {
|
||||
const overlay = document.getElementById("modal-overlay");
|
||||
const title = document.getElementById("modal-title");
|
||||
const body = document.getElementById("modal-body");
|
||||
|
||||
title.textContent = `#${issue.number} — Agent Log`;
|
||||
|
||||
if (!issue.logs || issue.logs.length === 0) {
|
||||
body.innerHTML = '<div class="empty-log">No agent activity logged yet.</div>';
|
||||
} else {
|
||||
body.innerHTML = issue.logs.map((l) => `
|
||||
<div class="log-entry">
|
||||
<span class="ts">${formatTime(l.timestamp)}</span>
|
||||
<span class="msg">${escHtml(l.message)}</span>
|
||||
</div>
|
||||
`).join("");
|
||||
body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
|
||||
overlay.classList.add("open");
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
document.getElementById("modal-close").addEventListener("click", () => {
|
||||
document.getElementById("modal-overlay").classList.remove("open");
|
||||
});
|
||||
|
||||
document.getElementById("modal-overlay").addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
e.currentTarget.classList.remove("open");
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Pointer Drag & Drop ───
|
||||
|
||||
function startDrag(e, cardEl, issue) {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
cardEl.setPointerCapture(e.pointerId);
|
||||
|
||||
const rect = cardEl.getBoundingClientRect();
|
||||
const ghost = cardEl.cloneNode(true);
|
||||
ghost.className = "card ghost";
|
||||
ghost.style.width = rect.width + "px";
|
||||
ghost.style.left = rect.left + "px";
|
||||
ghost.style.top = rect.top + "px";
|
||||
document.body.appendChild(ghost);
|
||||
cardEl.classList.add("dragging");
|
||||
|
||||
dragState = {
|
||||
issue,
|
||||
ghost,
|
||||
cardEl,
|
||||
offsetX: e.clientX - rect.left,
|
||||
offsetY: e.clientY - rect.top,
|
||||
pointerId: e.pointerId,
|
||||
};
|
||||
|
||||
cardEl.addEventListener("pointermove", onDragMove);
|
||||
cardEl.addEventListener("pointerup", onDragEnd);
|
||||
}
|
||||
|
||||
function onDragMove(e) {
|
||||
if (!dragState) return;
|
||||
dragState.ghost.style.left = (e.clientX - dragState.offsetX) + "px";
|
||||
dragState.ghost.style.top = (e.clientY - dragState.offsetY) + "px";
|
||||
|
||||
// Highlight drop target
|
||||
document.querySelectorAll(".column").forEach((col) => {
|
||||
const r = col.getBoundingClientRect();
|
||||
if (e.clientX >= r.left && e.clientX <= r.right) {
|
||||
col.classList.add("drop-target");
|
||||
} else {
|
||||
col.classList.remove("drop-target");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onDragEnd(e) {
|
||||
if (!dragState) return;
|
||||
const { issue, ghost, cardEl } = dragState;
|
||||
|
||||
cardEl.classList.remove("dragging");
|
||||
cardEl.releasePointerCapture(dragState.pointerId);
|
||||
cardEl.removeEventListener("pointermove", onDragMove);
|
||||
cardEl.removeEventListener("pointerup", onDragEnd);
|
||||
ghost.remove();
|
||||
|
||||
document.querySelectorAll(".column.drop-target").forEach((col) => {
|
||||
col.classList.remove("drop-target");
|
||||
});
|
||||
|
||||
// Find target column
|
||||
const targetCol = document.elementFromPoint(e.clientX, e.clientY)?.closest(".column");
|
||||
if (targetCol && targetCol.dataset.column !== issue.column) {
|
||||
moveIssue(issue.number, targetCol.dataset.column);
|
||||
}
|
||||
|
||||
dragState = null;
|
||||
}
|
||||
|
||||
// ─── API ───
|
||||
|
||||
async function moveIssue(issueNumber, column) {
|
||||
try {
|
||||
const resp = await fetch("/api/move", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ issue_number: issueNumber, column }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
state = data.state;
|
||||
render();
|
||||
} catch (err) {
|
||||
console.error("Move failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SSE ───
|
||||
|
||||
function connectSSE() {
|
||||
const es = new EventSource("/events");
|
||||
es.addEventListener("state", (e) => {
|
||||
try {
|
||||
state = JSON.parse(e.data);
|
||||
render();
|
||||
} catch {}
|
||||
});
|
||||
es.addEventListener("connected", () => {});
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
setTimeout(connectSSE, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Init ───
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const resp = await fetch("/api/state");
|
||||
state = await resp.json();
|
||||
} catch {}
|
||||
render();
|
||||
connectSSE();
|
||||
|
||||
document.getElementById("reset-btn").addEventListener("click", async () => {
|
||||
if (!confirm("Reset all issues to backlog?")) return;
|
||||
try {
|
||||
const resp = await fetch("/api/reset", { method: "POST" });
|
||||
state = await resp.json();
|
||||
render();
|
||||
} catch (err) {
|
||||
console.error("Reset failed:", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,289 @@
|
||||
import http from "node:http";
|
||||
import { createCanvas, joinSession } from "@github/copilot-sdk/extension";
|
||||
|
||||
// In-memory state (ephemeral per provider process)
|
||||
let currentColor = "#6c63ff";
|
||||
let logEntries = [];
|
||||
const sseClients = new Set();
|
||||
|
||||
function broadcast(event, data) {
|
||||
for (const res of sseClients) {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Loopback HTTP server for the iframe ---
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === "GET" && req.url === "/") {
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(getHTML());
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/events") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
// Send current state immediately
|
||||
res.write(`event: color\ndata: ${JSON.stringify({ color: currentColor })}\n\n`);
|
||||
res.write(`event: log\ndata: ${JSON.stringify({ entries: logEntries })}\n\n`);
|
||||
sseClients.add(res);
|
||||
req.on("close", () => sseClients.delete(res));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/request-change") {
|
||||
const entry = { time: new Date().toLocaleTimeString(), message: "🖱️ User clicked — requesting a color change..." };
|
||||
logEntries.push(entry);
|
||||
broadcast("log", { entries: logEntries });
|
||||
if (session) {
|
||||
session.send({
|
||||
prompt: "The user clicked the 'Ask Agent to Change Color' button on the Color Orb canvas. Pick a random, fun color and use the set_color canvas action to change the orb, then use log_message to tell them what color you chose and why.",
|
||||
});
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/clear-log") {
|
||||
logEntries = [];
|
||||
broadcast("log", { entries: logEntries });
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
const port = await new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||
});
|
||||
|
||||
let session;
|
||||
|
||||
const canvas = createCanvas({
|
||||
id: "color-orb",
|
||||
displayName: "Color Orb",
|
||||
description: "An interactive orb whose color can be changed by the agent. The user clicks a button to request a color change, then the agent sets the new color.",
|
||||
actions: [
|
||||
{
|
||||
name: "set_color",
|
||||
description: "Set the orb color. Accepts any valid CSS color (hex, named, rgb, hsl).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
color: { type: "string", description: "CSS color value, e.g. '#ff6347' or 'tomato'" },
|
||||
},
|
||||
required: ["color"],
|
||||
},
|
||||
handler({ input }) {
|
||||
currentColor = input.color;
|
||||
broadcast("color", { color: currentColor });
|
||||
return { color: currentColor };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "log_message",
|
||||
description: "Append a message to the canvas log area visible to the user.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string", description: "The message to display in the log" },
|
||||
},
|
||||
required: ["message"],
|
||||
},
|
||||
handler({ input }) {
|
||||
const entry = { time: new Date().toLocaleTimeString(), message: input.message };
|
||||
logEntries.push(entry);
|
||||
broadcast("log", { entries: logEntries });
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "clear_log",
|
||||
description: "Clear all messages from the canvas log.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
handler() {
|
||||
logEntries = [];
|
||||
broadcast("log", { entries: logEntries });
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
],
|
||||
open({ instanceId }) {
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
title: "Color Orb",
|
||||
status: "ready",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
session = await joinSession({ canvases: [canvas] });
|
||||
|
||||
function getHTML() {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
background: #0b0f14;
|
||||
color: #e2e8f0;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
padding: 3.5rem 1.5rem; min-height: 100vh;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
|
||||
/* Ambient glow */
|
||||
body::before, body::after {
|
||||
content: ''; position: absolute; width: 500px; height: 500px;
|
||||
border-radius: 50%; pointer-events: none; z-index: 0;
|
||||
}
|
||||
body::before {
|
||||
top: -180px; right: -120px;
|
||||
background: radial-gradient(circle, rgba(255,127,80,0.15) 0%, transparent 70%);
|
||||
filter: blur(80px);
|
||||
}
|
||||
body::after {
|
||||
bottom: -200px; left: -140px;
|
||||
background: radial-gradient(circle, rgba(14,165,233,0.12) 0%, transparent 70%);
|
||||
filter: blur(80px);
|
||||
}
|
||||
|
||||
/* Grain overlay */
|
||||
body > .grain {
|
||||
position: fixed; inset: 0; pointer-events: none; z-index: 1000;
|
||||
opacity: 0.025;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.content { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; width: 100%; max-width: 420px; }
|
||||
|
||||
/* Label */
|
||||
.label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px; font-weight: 500;
|
||||
text-transform: uppercase; letter-spacing: 0.7px;
|
||||
color: #64748b; margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Orb */
|
||||
.orb-wrap { position: relative; margin-bottom: 2.5rem; }
|
||||
.orb {
|
||||
width: 140px; height: 140px; border-radius: 50%;
|
||||
background: var(--orb-color, #ff7f50);
|
||||
box-shadow: 0 0 60px var(--orb-color, #ff7f50), 0 0 120px color-mix(in srgb, var(--orb-color, #ff7f50) 40%, transparent);
|
||||
transition: background 0.5s ease, box-shadow 0.5s ease;
|
||||
}
|
||||
|
||||
/* CTA */
|
||||
.actions { display: flex; gap: 0.75rem; margin-bottom: 2rem; }
|
||||
.btn {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
background: linear-gradient(135deg, #ff7f50, #0ea5e9);
|
||||
color: #fff; border: none;
|
||||
padding: 10px 20px; border-radius: 9999px;
|
||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(14,165,233,0.3);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.btn:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(14,165,233,0.4); }
|
||||
.btn:active { transform: translateY(0); }
|
||||
|
||||
.btn-ghost {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background: transparent;
|
||||
color: #64748b; border: 1px solid rgba(255,255,255,0.06);
|
||||
padding: 8px 16px; border-radius: 9999px;
|
||||
font-size: 11px; font-weight: 500; cursor: pointer;
|
||||
transition: color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.btn-ghost:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.15); }
|
||||
|
||||
/* Log — terminal block */
|
||||
.log {
|
||||
width: 100%;
|
||||
background: #111820;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 12px; padding: 16px 20px;
|
||||
max-height: 180px; overflow-y: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px; line-height: 1.7;
|
||||
box-shadow: 0 24px 60px -12px rgba(0,0,0,0.45), 0 0 0 1px rgba(255,255,255,0.02);
|
||||
}
|
||||
.log::-webkit-scrollbar { width: 4px; }
|
||||
.log::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
|
||||
.log-dots { display: flex; gap: 6px; margin-bottom: 12px; }
|
||||
.log-dots span { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.log-dots .r { background: #ef4444; }
|
||||
.log-dots .y { background: #f59e0b; }
|
||||
.log-dots .g { background: #22c55e; }
|
||||
.log-entry { color: #94a3b8; margin-bottom: 4px; }
|
||||
.log-entry .time { color: #334155; margin-right: 8px; }
|
||||
.log-empty { color: #334155; font-style: italic; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grain"></div>
|
||||
<div class="content">
|
||||
<div class="label">color-orb</div>
|
||||
<div class="orb-wrap">
|
||||
<div class="orb" id="orb"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" id="btn">Change Color</button>
|
||||
<button class="btn-ghost" id="clear-btn">clear</button>
|
||||
</div>
|
||||
<div class="log">
|
||||
<div class="log-dots"><span class="r"></span><span class="y"></span><span class="g"></span></div>
|
||||
<div id="log-content"><div class="log-empty">waiting for input…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const orb = document.getElementById('orb');
|
||||
const logContent = document.getElementById('log-content');
|
||||
const btn = document.getElementById('btn');
|
||||
const clearBtn = document.getElementById('clear-btn');
|
||||
|
||||
const es = new EventSource('/events');
|
||||
es.addEventListener('color', (e) => {
|
||||
const { color } = JSON.parse(e.data);
|
||||
orb.style.setProperty('--orb-color', color);
|
||||
orb.style.background = color;
|
||||
orb.style.boxShadow = '0 0 60px ' + color + ', 0 0 120px ' + color + '66';
|
||||
});
|
||||
es.addEventListener('log', (e) => {
|
||||
const { entries } = JSON.parse(e.data);
|
||||
if (entries.length === 0) {
|
||||
logContent.innerHTML = '<div class="log-empty">waiting for input\\u2026</div>';
|
||||
} else {
|
||||
logContent.innerHTML = entries.map(x =>
|
||||
'<div class="log-entry"><span class="time">' + x.time + '</span>' + x.message + '</div>'
|
||||
).join('');
|
||||
logContent.parentElement.scrollTop = logContent.parentElement.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
btn.addEventListener('click', async () => {
|
||||
await fetch('/request-change', { method: 'POST' });
|
||||
});
|
||||
|
||||
clearBtn.addEventListener('click', async () => {
|
||||
await fetch('/clear-log', { method: 'POST' });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
Generated
+218
@@ -0,0 +1,218 @@
|
||||
{
|
||||
"name": "color-orb",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "color-orb",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "latest"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot": {
|
||||
"version": "1.0.55-7",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.55-7.tgz",
|
||||
"integrity": "sha512-TczFrIaHH2sel6FM007H4FzT+Ipkj++I5u8Vx2ECWz9u24H7WOx/RpWcp6ExnSY1KSK1MtXaGcniAuqVi8Khaw==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.1.2"
|
||||
},
|
||||
"bin": {
|
||||
"copilot": "npm-loader.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/copilot-darwin-arm64": "1.0.55-7",
|
||||
"@github/copilot-darwin-x64": "1.0.55-7",
|
||||
"@github/copilot-linux-arm64": "1.0.55-7",
|
||||
"@github/copilot-linux-x64": "1.0.55-7",
|
||||
"@github/copilot-linuxmusl-arm64": "1.0.55-7",
|
||||
"@github/copilot-linuxmusl-x64": "1.0.55-7",
|
||||
"@github/copilot-win32-arm64": "1.0.55-7",
|
||||
"@github/copilot-win32-x64": "1.0.55-7"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-arm64": {
|
||||
"version": "1.0.55-7",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.55-7.tgz",
|
||||
"integrity": "sha512-QReU4F5+W0x/Nuc6qO+xYPeNnRjuHIIAeMBc1S+RFQ0T+YWynxRzNHGs9ZkUiIcLJ1F/y8GDq6sq7760Cn+onQ==",
|
||||
"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.55-7",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.55-7.tgz",
|
||||
"integrity": "sha512-qQ0d+XyvIPbNiaIydHBSCTQfWK5s0x1XnlrUKSzadgOnsFobGeldLSKtB159zJEiz0F/in5ythiUGJjWoAQVrA==",
|
||||
"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.55-7",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.55-7.tgz",
|
||||
"integrity": "sha512-+2zlHahK3fUfkrnlHqbdQsZMPZwRfchoTxDZd9UHbEhQF7eNLzYN+7frWs6AZujU+h/1i92+mcLT18AQXI3KxQ==",
|
||||
"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.55-7",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.55-7.tgz",
|
||||
"integrity": "sha512-SGmvWcJHIKDIsjYZdFQloGw3Re6r2N1Zv1VuB1yV1ClVqfG5i5pTvai6vzX8d3WgGgRzrkLksDrzZKR27zJZ7A==",
|
||||
"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.55-7",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.55-7.tgz",
|
||||
"integrity": "sha512-rJkZLvz4KeGoLgyX6gcONgTNfFxeoQvN4jaAXlbD1nFP3hJbLTuY0CB4fBHmZWktrPkRL/j5aDGxrcIcl+Xg3A==",
|
||||
"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.55-7",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.55-7.tgz",
|
||||
"integrity": "sha512-uPb08qgJHY1QW2YhA1OBJ9PB0CDwCvtuttWbeZ+AW+qfFVsvBpARU1cdEl/xT4IXMhBFoJiePv3BnLGjVZtoWA==",
|
||||
"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.0-beta.9",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.0-beta.9.tgz",
|
||||
"integrity": "sha512-D4yiGL4/faFCjL7bozhX7bgxt/x1wp2LZ2p9Tw+xrA5hbcLh5Be5kPen+bFA8NbVfgt1G2djDYFZlrZjXXmcBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/copilot": "^1.0.55-5",
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-arm64": {
|
||||
"version": "1.0.55-7",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.55-7.tgz",
|
||||
"integrity": "sha512-mb4Sg2sJjmK9Rq8XCRuhoIOjUScB5p2Ct9ZtTbC3ipvONWMOMjYPbLvC8K9GAHcYcHLdv98hvzv3+qjBhb5tZQ==",
|
||||
"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.55-7",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.55-7.tgz",
|
||||
"integrity": "sha512-GL9jAtkn2Kx4IO9ZfTiMC3LFd539KuuOx3uOIKciWKMuCvcfct0rdVkXlDr+EnrmPzu1A4PavcJ0RScpI39jUQ==",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "color-orb",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "extension.mjs",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createCanvas, joinSession } from "@github/copilot-sdk/extension";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Per-instance state (ephemeral, lives in memory for session lifetime)
|
||||
const instances = new Map();
|
||||
|
||||
function getInstance(instanceId) {
|
||||
if (!instances.has(instanceId)) {
|
||||
instances.set(instanceId, {
|
||||
currentView: null,
|
||||
history: [],
|
||||
selectedNodeId: null,
|
||||
token: crypto.randomBytes(16).toString("hex"),
|
||||
});
|
||||
}
|
||||
return instances.get(instanceId);
|
||||
}
|
||||
|
||||
function getCurrentView(inst) {
|
||||
return inst.currentView;
|
||||
}
|
||||
|
||||
function pushView(inst, view) {
|
||||
if (inst.currentView) {
|
||||
inst.history.push(inst.currentView);
|
||||
}
|
||||
inst.currentView = view;
|
||||
inst.selectedNodeId = null;
|
||||
}
|
||||
|
||||
function replaceView(inst, view) {
|
||||
inst.currentView = view;
|
||||
inst.selectedNodeId = null;
|
||||
}
|
||||
|
||||
function popView(inst) {
|
||||
if (inst.history.length === 0) return null;
|
||||
inst.currentView = inst.history.pop();
|
||||
inst.selectedNodeId = null;
|
||||
return inst.currentView;
|
||||
}
|
||||
|
||||
// SSE clients per instance
|
||||
const sseClients = new Map();
|
||||
|
||||
function broadcast(instanceId, event, data) {
|
||||
const clients = sseClients.get(instanceId);
|
||||
if (!clients) return;
|
||||
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
for (const res of clients) {
|
||||
res.write(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast the full view state to the iframe
|
||||
function broadcastView(instanceId, inst) {
|
||||
const view = getCurrentView(inst);
|
||||
broadcast(instanceId, "view", {
|
||||
...view,
|
||||
historyDepth: inst.history.length,
|
||||
breadcrumbs: inst.history.map((v) => v.title).concat(view ? [view.title] : []),
|
||||
});
|
||||
}
|
||||
|
||||
// HTTP helpers
|
||||
function readJson(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => resolve(body ? JSON.parse(body) : {}));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function json(res, code, data) {
|
||||
res.writeHead(code, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
// HTTP server
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const token = url.searchParams.get("token");
|
||||
const instanceId = url.searchParams.get("instance");
|
||||
|
||||
// Serve the HTML page
|
||||
if (req.method === "GET" && url.pathname === "/") {
|
||||
if (!instanceId || !validateToken(instanceId, token)) {
|
||||
res.writeHead(403);
|
||||
res.end("Forbidden");
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(fs.readFileSync(path.join(__dirname, "public", "index.html"), "utf8"));
|
||||
return;
|
||||
}
|
||||
|
||||
// SSE endpoint
|
||||
if (req.method === "GET" && url.pathname === "/events") {
|
||||
if (!instanceId || !validateToken(instanceId, token)) {
|
||||
res.writeHead(403);
|
||||
res.end("Forbidden");
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
if (!sseClients.has(instanceId)) sseClients.set(instanceId, new Set());
|
||||
sseClients.get(instanceId).add(res);
|
||||
req.on("close", () => {
|
||||
const clients = sseClients.get(instanceId);
|
||||
if (clients) clients.delete(res);
|
||||
});
|
||||
// Send current view state immediately
|
||||
const inst = getInstance(instanceId);
|
||||
if (inst.currentView) {
|
||||
const view = getCurrentView(inst);
|
||||
res.write(`event: view\ndata: ${JSON.stringify({
|
||||
...view,
|
||||
historyDepth: inst.history.length,
|
||||
breadcrumbs: inst.history.map((v) => v.title).concat([view.title]),
|
||||
})}\n\n`);
|
||||
if (inst.selectedNodeId) {
|
||||
res.write(`event: select\ndata: ${JSON.stringify({ nodeId: inst.selectedNodeId })}\n\n`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// API: get full state
|
||||
if (req.method === "GET" && url.pathname === "/api/state") {
|
||||
if (!instanceId || !validateToken(instanceId, token)) {
|
||||
res.writeHead(403);
|
||||
res.end("Forbidden");
|
||||
return;
|
||||
}
|
||||
const inst = getInstance(instanceId);
|
||||
const view = getCurrentView(inst);
|
||||
json(res, 200, {
|
||||
view,
|
||||
historyDepth: inst.history.length,
|
||||
breadcrumbs: inst.history.map((v) => v.title).concat(view ? [view.title] : []),
|
||||
selectedNodeId: inst.selectedNodeId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// API: node clicked — triggers drill-down
|
||||
if (req.method === "POST" && url.pathname === "/api/click") {
|
||||
if (!instanceId || !validateToken(instanceId, token)) {
|
||||
res.writeHead(403);
|
||||
res.end("Forbidden");
|
||||
return;
|
||||
}
|
||||
const { nodeId } = await readJson(req);
|
||||
const inst = getInstance(instanceId);
|
||||
inst.selectedNodeId = nodeId;
|
||||
broadcast(instanceId, "select", { nodeId });
|
||||
|
||||
// Send prompt to agent to drill into the clicked node
|
||||
const view = getCurrentView(inst);
|
||||
const node = view?.diagram?.nodes?.find((n) => n.id === nodeId);
|
||||
if (node && session) {
|
||||
const diagramContext = view.diagram.nodes.map((n) => n.label).join(", ");
|
||||
session.send({
|
||||
prompt: `The user clicked on the "${node.label}" node in the Diagram Explorer canvas (id: "${node.id}", type: "${node.type || "default"}", description: "${node.description || "none"}"). The current diagram is "${view.title}" which contains: ${diagramContext}.
|
||||
|
||||
Do NOT explain in chat. Instead, use the canvas actions to respond visually:
|
||||
1. Use the render_diagram action with mode "push" to show a detailed sub-diagram of "${node.label}" — break it into its internal components, sub-systems, or key parts with their relationships.
|
||||
2. Use the show_explanation action to display a brief explanation panel on the canvas.
|
||||
|
||||
If you cannot create a meaningful sub-diagram (e.g. the node is already a leaf concept), use show_explanation to provide a detailed description on the canvas instead, without rendering a new diagram.`,
|
||||
});
|
||||
}
|
||||
|
||||
json(res, 200, { ok: true, selectedNodeId: nodeId });
|
||||
return;
|
||||
}
|
||||
|
||||
// API: navigate back
|
||||
if (req.method === "POST" && url.pathname === "/api/back") {
|
||||
if (!instanceId || !validateToken(instanceId, token)) {
|
||||
res.writeHead(403);
|
||||
res.end("Forbidden");
|
||||
return;
|
||||
}
|
||||
const inst = getInstance(instanceId);
|
||||
const prev = popView(inst);
|
||||
if (prev) {
|
||||
broadcastView(instanceId, inst);
|
||||
}
|
||||
json(res, 200, { ok: true, view: prev });
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
function validateToken(instanceId, token) {
|
||||
const inst = instances.get(instanceId);
|
||||
return inst && inst.token === token;
|
||||
}
|
||||
|
||||
const port = await new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||
});
|
||||
|
||||
// Canvas declaration
|
||||
const canvas = createCanvas({
|
||||
id: "diagram",
|
||||
displayName: "Diagram Explorer",
|
||||
description:
|
||||
"Interactive diagram for exploring architecture, data flow, and relationships. Render nodes and edges, then click any node to get a detailed explanation from the agent.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Optional title for the initial diagram" },
|
||||
},
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
name: "render_diagram",
|
||||
description:
|
||||
"Render an interactive diagram with nodes and edges. Use mode 'push' to drill into a node (adds to history so user can navigate back), or 'replace' (default) to update the current view in place.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Diagram title" },
|
||||
nodes: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Unique node identifier" },
|
||||
label: { type: "string", description: "Display label" },
|
||||
description: {
|
||||
type: "string",
|
||||
description: "Brief description shown on hover and used when drilling in",
|
||||
},
|
||||
type: {
|
||||
type: "string",
|
||||
description: "Node type for color coding (e.g. 'service', 'database', 'ui', 'api', 'config', 'external')",
|
||||
},
|
||||
},
|
||||
required: ["id", "label"],
|
||||
},
|
||||
},
|
||||
edges: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
from: { type: "string", description: "Source node id" },
|
||||
to: { type: "string", description: "Target node id" },
|
||||
label: { type: "string", description: "Optional edge label" },
|
||||
},
|
||||
required: ["from", "to"],
|
||||
},
|
||||
},
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["push", "replace"],
|
||||
description: "Navigation mode. 'push' saves current view to history (for drill-down). 'replace' updates in place (default).",
|
||||
},
|
||||
explanation: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Explanation panel title" },
|
||||
text: { type: "string", description: "Explanation text (plain text)" },
|
||||
},
|
||||
description: "Optional explanation to show alongside the diagram",
|
||||
},
|
||||
},
|
||||
required: ["nodes", "edges"],
|
||||
},
|
||||
handler({ instanceId, input }) {
|
||||
const inst = getInstance(instanceId);
|
||||
const view = {
|
||||
title: input.title || "Diagram",
|
||||
diagram: { title: input.title || "Diagram", nodes: input.nodes, edges: input.edges },
|
||||
explanation: input.explanation || null,
|
||||
selectedNodeId: null,
|
||||
};
|
||||
|
||||
if (input.mode === "push") {
|
||||
pushView(inst, view);
|
||||
} else {
|
||||
replaceView(inst, view);
|
||||
}
|
||||
|
||||
broadcastView(instanceId, inst);
|
||||
return { ok: true, nodeCount: input.nodes.length, edgeCount: input.edges.length, historyDepth: inst.history.length };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "show_explanation",
|
||||
description:
|
||||
"Display an explanation panel on the canvas alongside the current diagram. Use this to provide context about the current view or a clicked node without changing the diagram.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Explanation panel title" },
|
||||
text: { type: "string", description: "Explanation content (plain text, can include line breaks)" },
|
||||
},
|
||||
required: ["title", "text"],
|
||||
},
|
||||
handler({ instanceId, input }) {
|
||||
const inst = getInstance(instanceId);
|
||||
const view = getCurrentView(inst);
|
||||
if (view) {
|
||||
view.explanation = { title: input.title, text: input.text };
|
||||
broadcast(instanceId, "explanation", view.explanation);
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_state",
|
||||
description:
|
||||
"Get the current diagram state including which node the user last clicked and the history depth.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
handler({ instanceId }) {
|
||||
const inst = getInstance(instanceId);
|
||||
const view = getCurrentView(inst);
|
||||
const selectedNode = inst.selectedNodeId
|
||||
? view?.diagram?.nodes?.find((n) => n.id === inst.selectedNodeId)
|
||||
: null;
|
||||
return {
|
||||
currentView: view,
|
||||
selectedNodeId: inst.selectedNodeId,
|
||||
selectedNode: selectedNode || null,
|
||||
historyDepth: inst.history.length,
|
||||
breadcrumbs: inst.history.map((v) => v.title).concat(view ? [view.title] : []),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "highlight_node",
|
||||
description: "Highlight a specific node in the diagram (e.g. while explaining it).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
nodeId: { type: "string", description: "The node id to highlight" },
|
||||
},
|
||||
required: ["nodeId"],
|
||||
},
|
||||
handler({ instanceId, input }) {
|
||||
const inst = getInstance(instanceId);
|
||||
inst.selectedNodeId = input.nodeId;
|
||||
broadcast(instanceId, "select", { nodeId: input.nodeId });
|
||||
return { ok: true, highlightedNodeId: input.nodeId };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "clear",
|
||||
description: "Clear the diagram canvas and all history.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
handler({ instanceId }) {
|
||||
const inst = getInstance(instanceId);
|
||||
inst.currentView = null;
|
||||
inst.history = [];
|
||||
inst.selectedNodeId = null;
|
||||
broadcast(instanceId, "clear", {});
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
],
|
||||
open({ instanceId, input }) {
|
||||
const inst = getInstance(instanceId);
|
||||
const view = getCurrentView(inst);
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}?instance=${instanceId}&token=${inst.token}`,
|
||||
title: input?.title || "Diagram Explorer",
|
||||
status: view
|
||||
? `${view.diagram.nodes.length} nodes`
|
||||
: "Ready",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
let session = await joinSession({ canvases: [canvas] });
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
{
|
||||
"name": "diagram-viewer",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "diagram-viewer",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "latest"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot": {
|
||||
"version": "1.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.55.tgz",
|
||||
"integrity": "sha512-wqzI0L7krORW6jDAQPx7VnInka5BYN5yVgu+dpUK4w8xP5RgnOBa6kRoXpydj/9O1ufs0k6RKRtQjsVLp52TRw==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.1.2"
|
||||
},
|
||||
"bin": {
|
||||
"copilot": "npm-loader.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/copilot-darwin-arm64": "1.0.55",
|
||||
"@github/copilot-darwin-x64": "1.0.55",
|
||||
"@github/copilot-linux-arm64": "1.0.55",
|
||||
"@github/copilot-linux-x64": "1.0.55",
|
||||
"@github/copilot-linuxmusl-arm64": "1.0.55",
|
||||
"@github/copilot-linuxmusl-x64": "1.0.55",
|
||||
"@github/copilot-win32-arm64": "1.0.55",
|
||||
"@github/copilot-win32-x64": "1.0.55"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-arm64": {
|
||||
"version": "1.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.55.tgz",
|
||||
"integrity": "sha512-v59pOpA7YO8j/lpDU/1E8l1Ag0hd26hIiEzTNbzqKd7tJpvhN0XTDWDCink50wXL656XIXt8lD8i8sGeD6yPfA==",
|
||||
"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.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.55.tgz",
|
||||
"integrity": "sha512-XrJ9ent/9ogLk8yNp3TMsNVW0qTRDlkw/b34VnTgbAkJCaI3UVqaqpFn60Laa6J5mOPW0/JeKIkkva+7IJdqpQ==",
|
||||
"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.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.55.tgz",
|
||||
"integrity": "sha512-5Q46Q72/l/U8KQRcBwYjzFPNXBCPG177FTmjEVOAH0qk7w58fMUDBEpnf9n1IpxYJDWQJ5BFGtLdfYgVVtkevw==",
|
||||
"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.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.55.tgz",
|
||||
"integrity": "sha512-KWmMCDmKJivvOyDAAe5K8r7uSlVq8aZCh20VfrVXsc4bckO6KjXY/TOagrdBNqkk5rh8v63ghBbxFdWIOvEJRA==",
|
||||
"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.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.55.tgz",
|
||||
"integrity": "sha512-Jb5ug9Ic1pzxB2ZT1xoR8b3Ea1xnvCa4h8cBque51+TevXe6QF98vAfSUIwLe4xu+K6JKhiKEA0SD3w29Z74eA==",
|
||||
"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.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.55.tgz",
|
||||
"integrity": "sha512-qMGIjHxKmW9q26EpoaNKWpmEVGyL/IM8ThVkh7yolDzv9lECFudPzT5yLX7f+VIiF6qWQlrQyzmamp7/fNQ2Zg==",
|
||||
"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.0-beta.9",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.0-beta.9.tgz",
|
||||
"integrity": "sha512-D4yiGL4/faFCjL7bozhX7bgxt/x1wp2LZ2p9Tw+xrA5hbcLh5Be5kPen+bFA8NbVfgt1G2djDYFZlrZjXXmcBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/copilot": "^1.0.55-5",
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-arm64": {
|
||||
"version": "1.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.55.tgz",
|
||||
"integrity": "sha512-TO4EJ8it6Qki7wMKYHqGUEDYmB0EAToy+pE5++OpydB6FijyQ31+/XwjvdnEFkuB4ZgPqu/6Y8hxMKucl2+FYg==",
|
||||
"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.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.55.tgz",
|
||||
"integrity": "sha512-TBMiSZMz8Dhx79JeSEM+7ONGxR5NmxfiDUdySo6thVbRmjS9D8msyAP8ucTsbLBJcTFeb7vsaeObD/ujYQgDtA==",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "diagram-viewer",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "extension.mjs",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Diagram Explorer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1419;
|
||||
--surface: #1a2028;
|
||||
--border: #2a3540;
|
||||
--text: #e2e8f0;
|
||||
--muted: #94a3b8;
|
||||
--accent: #0ea5e9;
|
||||
--coral: #ff7f50;
|
||||
--sage: #84cc16;
|
||||
--violet: #a78bfa;
|
||||
--amber: #f59e0b;
|
||||
--rose: #f43f5e;
|
||||
--font: 'DM Sans', sans-serif;
|
||||
--mono: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Header with breadcrumbs */
|
||||
.header {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.back-btn.visible { display: flex; }
|
||||
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.breadcrumbs .crumb {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.breadcrumbs .crumb.current {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.breadcrumbs .sep {
|
||||
color: var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header .badge {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
background: rgba(14, 165, 233, 0.15);
|
||||
color: var(--accent);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Main layout: diagram + optional explanation sidebar */
|
||||
.main-layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Diagram area */
|
||||
.canvas-area {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty-state .icon { font-size: 48px; opacity: 0.3; }
|
||||
.empty-state p { font-size: 13px; text-align: center; max-width: 280px; line-height: 1.6; }
|
||||
|
||||
/* Explanation sidebar */
|
||||
.explanation-panel {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
transition: width 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.explanation-panel.visible {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.explanation-header {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.explanation-header h3 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.explanation-close {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.explanation-close:hover { background: rgba(255,255,255,0.05); color: var(--text); }
|
||||
|
||||
.explanation-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--muted);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Node styles */
|
||||
.node-group { cursor: pointer; }
|
||||
.node-group:hover .node-rect { filter: brightness(1.2); }
|
||||
|
||||
.node-rect {
|
||||
rx: 8;
|
||||
ry: 8;
|
||||
stroke-width: 1.5;
|
||||
transition: filter 0.15s ease, stroke-width 0.15s ease;
|
||||
}
|
||||
|
||||
.node-group.selected .node-rect {
|
||||
stroke-width: 3;
|
||||
filter: brightness(1.3) drop-shadow(0 0 12px currentColor);
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
fill: var(--text);
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.node-type-badge {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 9px;
|
||||
fill: var(--muted);
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Edge styles */
|
||||
.edge-line {
|
||||
fill: none;
|
||||
stroke: var(--border);
|
||||
stroke-width: 1.5;
|
||||
marker-end: url(#arrowhead);
|
||||
}
|
||||
|
||||
.edge-label {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 9px;
|
||||
fill: var(--muted);
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
max-width: 240px;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.tooltip.visible { opacity: 1; }
|
||||
.tooltip .tt-label { font-weight: 600; margin-bottom: 4px; }
|
||||
.tooltip .tt-desc { color: var(--muted); line-height: 1.4; }
|
||||
.tooltip .tt-hint { margin-top: 6px; font-family: var(--mono); font-size: 9px; color: var(--accent); }
|
||||
|
||||
/* Loading indicator */
|
||||
.loading-indicator {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.loading-indicator.visible { display: flex; }
|
||||
|
||||
.loading-indicator .dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<button class="back-btn" id="back-btn" title="Go back">←</button>
|
||||
<div class="breadcrumbs" id="breadcrumbs">
|
||||
<span class="crumb current">Diagram Explorer</span>
|
||||
</div>
|
||||
<span class="badge" id="badge" style="display:none"></span>
|
||||
</div>
|
||||
|
||||
<div class="main-layout">
|
||||
<div class="canvas-area" id="canvas-area">
|
||||
<div class="empty-state" id="empty-state">
|
||||
<div class="icon">◇</div>
|
||||
<p>Ask Copilot about architecture or any topic, and an interactive diagram will appear here. Click nodes to drill in.</p>
|
||||
</div>
|
||||
<svg id="svg" xmlns="http://www.w3.org/2000/svg" style="display:none">
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#2a3540" />
|
||||
</marker>
|
||||
</defs>
|
||||
<g id="edges-layer"></g>
|
||||
<g id="nodes-layer"></g>
|
||||
</svg>
|
||||
<div class="tooltip" id="tooltip">
|
||||
<div class="tt-label"></div>
|
||||
<div class="tt-desc"></div>
|
||||
<div class="tt-hint">Click to drill in</div>
|
||||
</div>
|
||||
<div class="loading-indicator" id="loading">
|
||||
<span class="dot"></span>
|
||||
<span>Agent thinking…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="explanation-panel" id="explanation-panel">
|
||||
<div class="explanation-header">
|
||||
<h3 id="explanation-title">Explanation</h3>
|
||||
<button class="explanation-close" id="explanation-close">×</button>
|
||||
</div>
|
||||
<div class="explanation-body" id="explanation-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const instanceId = params.get("instance");
|
||||
const token = params.get("token");
|
||||
|
||||
const NODE_COLORS = {
|
||||
service: { fill: "#1e3a5f", stroke: "#0ea5e9" },
|
||||
database: { fill: "#2d1f3d", stroke: "#a78bfa" },
|
||||
ui: { fill: "#1f2d1f", stroke: "#84cc16" },
|
||||
api: { fill: "#3d2a1a", stroke: "#ff7f50" },
|
||||
config: { fill: "#3d3a1a", stroke: "#f59e0b" },
|
||||
external: { fill: "#3d1a2a", stroke: "#f43f5e" },
|
||||
default: { fill: "#1a2028", stroke: "#475569" },
|
||||
};
|
||||
|
||||
let currentView = null;
|
||||
let selectedNodeId = null;
|
||||
let historyDepth = 0;
|
||||
|
||||
// Layout algorithm: hierarchical/layered
|
||||
function computeLayout(nodes, edges) {
|
||||
if (!nodes || nodes.length === 0) return new Map();
|
||||
|
||||
const inDegree = new Map(nodes.map((n) => [n.id, 0]));
|
||||
const outEdges = new Map(nodes.map((n) => [n.id, []]));
|
||||
|
||||
for (const edge of edges) {
|
||||
if (inDegree.has(edge.to)) inDegree.set(edge.to, inDegree.get(edge.to) + 1);
|
||||
if (outEdges.has(edge.from)) outEdges.get(edge.from).push(edge.to);
|
||||
}
|
||||
|
||||
// Assign layers via BFS from roots
|
||||
const layers = new Map();
|
||||
const queue = [];
|
||||
const visited = new Set();
|
||||
|
||||
for (const [id, deg] of inDegree) {
|
||||
if (deg === 0) { queue.push(id); layers.set(id, 0); visited.add(id); }
|
||||
}
|
||||
|
||||
if (queue.length === 0 && nodes.length > 0) {
|
||||
queue.push(nodes[0].id); layers.set(nodes[0].id, 0); visited.add(nodes[0].id);
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
const currentLayer = layers.get(current);
|
||||
for (const next of (outEdges.get(current) || [])) {
|
||||
const newLayer = currentLayer + 1;
|
||||
if (!visited.has(next) || layers.get(next) < newLayer) {
|
||||
layers.set(next, newLayer);
|
||||
if (!visited.has(next)) { visited.add(next); queue.push(next); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!layers.has(node.id)) layers.set(node.id, 0);
|
||||
}
|
||||
|
||||
// Group by layer
|
||||
const layerGroups = new Map();
|
||||
for (const [id, layer] of layers) {
|
||||
if (!layerGroups.has(layer)) layerGroups.set(layer, []);
|
||||
layerGroups.get(layer).push(id);
|
||||
}
|
||||
|
||||
const NODE_WIDTH = 160;
|
||||
const NODE_HEIGHT = 56;
|
||||
const H_GAP = 40;
|
||||
const V_GAP = 80;
|
||||
const positions = new Map();
|
||||
|
||||
const maxLayer = Math.max(...layerGroups.keys());
|
||||
const svg = document.getElementById("svg");
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
const availWidth = svgRect.width || 500;
|
||||
const availHeight = svgRect.height || 400;
|
||||
|
||||
const totalHeight = (maxLayer + 1) * (NODE_HEIGHT + V_GAP) - V_GAP;
|
||||
const startY = Math.max(40, (availHeight - totalHeight) / 2);
|
||||
|
||||
for (const [layer, nodeIds] of layerGroups) {
|
||||
const totalWidth = nodeIds.length * (NODE_WIDTH + H_GAP) - H_GAP;
|
||||
const startX = Math.max(20, (availWidth - totalWidth) / 2);
|
||||
|
||||
nodeIds.forEach((id, i) => {
|
||||
positions.set(id, {
|
||||
x: startX + i * (NODE_WIDTH + H_GAP) + NODE_WIDTH / 2,
|
||||
y: startY + layer * (NODE_HEIGHT + V_GAP) + NODE_HEIGHT / 2,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
function getNodeColor(type) {
|
||||
return NODE_COLORS[type] || NODE_COLORS.default;
|
||||
}
|
||||
|
||||
function renderDiagram() {
|
||||
const diagram = currentView?.diagram;
|
||||
|
||||
if (!diagram || !diagram.nodes || diagram.nodes.length === 0) {
|
||||
document.getElementById("svg").style.display = "none";
|
||||
document.getElementById("empty-state").style.display = "flex";
|
||||
document.getElementById("badge").style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById("svg").style.display = "block";
|
||||
document.getElementById("empty-state").style.display = "none";
|
||||
document.getElementById("badge").textContent = `${diagram.nodes.length} nodes`;
|
||||
document.getElementById("badge").style.display = "inline";
|
||||
|
||||
const positions = computeLayout(diagram.nodes, diagram.edges);
|
||||
|
||||
// Render edges
|
||||
const edgesLayer = document.getElementById("edges-layer");
|
||||
edgesLayer.innerHTML = "";
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
const fromPos = positions.get(edge.from);
|
||||
const toPos = positions.get(edge.to);
|
||||
if (!fromPos || !toPos) continue;
|
||||
|
||||
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
||||
const x1 = fromPos.x, y1 = fromPos.y + fromPos.height / 2;
|
||||
const x2 = toPos.x, y2 = toPos.y - toPos.height / 2;
|
||||
const midY = (y1 + y2) / 2;
|
||||
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("class", "edge-line");
|
||||
path.setAttribute("d", `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`);
|
||||
g.appendChild(path);
|
||||
|
||||
if (edge.label) {
|
||||
const labelEl = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
labelEl.setAttribute("class", "edge-label");
|
||||
labelEl.setAttribute("x", (x1 + x2) / 2);
|
||||
labelEl.setAttribute("y", midY - 8);
|
||||
labelEl.textContent = edge.label;
|
||||
g.appendChild(labelEl);
|
||||
}
|
||||
edgesLayer.appendChild(g);
|
||||
}
|
||||
|
||||
// Render nodes
|
||||
const nodesLayer = document.getElementById("nodes-layer");
|
||||
nodesLayer.innerHTML = "";
|
||||
|
||||
for (const node of diagram.nodes) {
|
||||
const pos = positions.get(node.id);
|
||||
if (!pos) continue;
|
||||
|
||||
const colors = getNodeColor(node.type);
|
||||
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
||||
g.setAttribute("class", `node-group${selectedNodeId === node.id ? " selected" : ""}`);
|
||||
g.dataset.nodeId = node.id;
|
||||
|
||||
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||
rect.setAttribute("class", "node-rect");
|
||||
rect.setAttribute("x", pos.x - pos.width / 2);
|
||||
rect.setAttribute("y", pos.y - pos.height / 2);
|
||||
rect.setAttribute("width", pos.width);
|
||||
rect.setAttribute("height", pos.height);
|
||||
rect.setAttribute("fill", colors.fill);
|
||||
rect.setAttribute("stroke", colors.stroke);
|
||||
rect.style.color = colors.stroke;
|
||||
g.appendChild(rect);
|
||||
|
||||
const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
label.setAttribute("class", "node-label");
|
||||
label.setAttribute("x", pos.x);
|
||||
label.setAttribute("y", pos.y - 4);
|
||||
label.textContent = node.label;
|
||||
g.appendChild(label);
|
||||
|
||||
if (node.type) {
|
||||
const badge = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
badge.setAttribute("class", "node-type-badge");
|
||||
badge.setAttribute("x", pos.x);
|
||||
badge.setAttribute("y", pos.y + 14);
|
||||
badge.textContent = node.type;
|
||||
g.appendChild(badge);
|
||||
}
|
||||
|
||||
g.addEventListener("mouseenter", (e) => showTooltip(e, node));
|
||||
g.addEventListener("mouseleave", hideTooltip);
|
||||
g.addEventListener("click", () => onNodeClick(node));
|
||||
|
||||
nodesLayer.appendChild(g);
|
||||
}
|
||||
}
|
||||
|
||||
function updateBreadcrumbs(breadcrumbs) {
|
||||
const container = document.getElementById("breadcrumbs");
|
||||
container.innerHTML = "";
|
||||
|
||||
if (!breadcrumbs || breadcrumbs.length === 0) {
|
||||
container.innerHTML = '<span class="crumb current">Diagram Explorer</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
breadcrumbs.forEach((crumb, i) => {
|
||||
if (i > 0) {
|
||||
const sep = document.createElement("span");
|
||||
sep.className = "sep";
|
||||
sep.textContent = "›";
|
||||
container.appendChild(sep);
|
||||
}
|
||||
const el = document.createElement("span");
|
||||
el.className = i === breadcrumbs.length - 1 ? "crumb current" : "crumb";
|
||||
el.textContent = crumb;
|
||||
container.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function updateBackButton() {
|
||||
const btn = document.getElementById("back-btn");
|
||||
if (historyDepth > 0) {
|
||||
btn.classList.add("visible");
|
||||
} else {
|
||||
btn.classList.remove("visible");
|
||||
}
|
||||
}
|
||||
|
||||
function showExplanation(explanation) {
|
||||
if (!explanation) return;
|
||||
const panel = document.getElementById("explanation-panel");
|
||||
document.getElementById("explanation-title").textContent = explanation.title || "Explanation";
|
||||
document.getElementById("explanation-body").textContent = explanation.text || "";
|
||||
panel.classList.add("visible");
|
||||
}
|
||||
|
||||
function hideExplanation() {
|
||||
document.getElementById("explanation-panel").classList.remove("visible");
|
||||
}
|
||||
|
||||
function showTooltip(e, node) {
|
||||
const tooltip = document.getElementById("tooltip");
|
||||
tooltip.querySelector(".tt-label").textContent = node.label;
|
||||
tooltip.querySelector(".tt-desc").textContent = node.description || node.type || "";
|
||||
tooltip.classList.add("visible");
|
||||
|
||||
const rect = document.getElementById("canvas-area").getBoundingClientRect();
|
||||
const x = e.clientX - rect.left + 12;
|
||||
const y = e.clientY - rect.top + 12;
|
||||
tooltip.style.left = x + "px";
|
||||
tooltip.style.top = y + "px";
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
document.getElementById("tooltip").classList.remove("visible");
|
||||
}
|
||||
|
||||
function onNodeClick(node) {
|
||||
selectedNodeId = node.id;
|
||||
renderDiagram();
|
||||
|
||||
// Show loading state
|
||||
document.getElementById("loading").classList.add("visible");
|
||||
|
||||
// Notify extension
|
||||
fetch(`/api/click?instance=${instanceId}&token=${token}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ nodeId: node.id }),
|
||||
});
|
||||
}
|
||||
|
||||
// Back button
|
||||
document.getElementById("back-btn").addEventListener("click", async () => {
|
||||
try {
|
||||
await fetch(`/api/back?instance=${instanceId}&token=${token}`, { method: "POST" });
|
||||
} catch (err) {
|
||||
console.error("Back navigation failed:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Close explanation
|
||||
document.getElementById("explanation-close").addEventListener("click", hideExplanation);
|
||||
|
||||
// Apply a full view state update
|
||||
function applyViewState(data) {
|
||||
currentView = data;
|
||||
selectedNodeId = null;
|
||||
historyDepth = data.historyDepth || 0;
|
||||
|
||||
// Hide loading
|
||||
document.getElementById("loading").classList.remove("visible");
|
||||
|
||||
updateBreadcrumbs(data.breadcrumbs);
|
||||
updateBackButton();
|
||||
renderDiagram();
|
||||
|
||||
if (data.explanation) {
|
||||
showExplanation(data.explanation);
|
||||
} else {
|
||||
hideExplanation();
|
||||
}
|
||||
}
|
||||
|
||||
// SSE connection
|
||||
function connectSSE() {
|
||||
const es = new EventSource(`/events?instance=${instanceId}&token=${token}`);
|
||||
|
||||
es.addEventListener("view", (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
applyViewState(data);
|
||||
});
|
||||
|
||||
es.addEventListener("explanation", (e) => {
|
||||
const explanation = JSON.parse(e.data);
|
||||
showExplanation(explanation);
|
||||
document.getElementById("loading").classList.remove("visible");
|
||||
});
|
||||
|
||||
es.addEventListener("select", (e) => {
|
||||
const { nodeId } = JSON.parse(e.data);
|
||||
selectedNodeId = nodeId;
|
||||
renderDiagram();
|
||||
});
|
||||
|
||||
es.addEventListener("clear", () => {
|
||||
currentView = null;
|
||||
selectedNodeId = null;
|
||||
historyDepth = 0;
|
||||
hideExplanation();
|
||||
updateBreadcrumbs([]);
|
||||
updateBackButton();
|
||||
renderDiagram();
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
setTimeout(connectSSE, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener("resize", () => {
|
||||
if (currentView) renderDiagram();
|
||||
});
|
||||
|
||||
// Init
|
||||
async function init() {
|
||||
try {
|
||||
const resp = await fetch(`/api/state?instance=${instanceId}&token=${token}`);
|
||||
const state = await resp.json();
|
||||
if (state.view) {
|
||||
currentView = state.view;
|
||||
historyDepth = state.historyDepth || 0;
|
||||
selectedNodeId = state.selectedNodeId;
|
||||
updateBreadcrumbs(state.breadcrumbs);
|
||||
updateBackButton();
|
||||
renderDiagram();
|
||||
if (state.view.explanation) showExplanation(state.view.explanation);
|
||||
}
|
||||
} catch {}
|
||||
connectSSE();
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,244 @@
|
||||
{
|
||||
"meta": {
|
||||
"description": "Synthetic feedback signals for SignalBox theme exploration. These are demo data derived from fictional customer research scenarios.",
|
||||
"generatedAt": "2026-05-28"
|
||||
},
|
||||
"themes": [
|
||||
{
|
||||
"id": "workflow-automation",
|
||||
"label": "Workflow Automation",
|
||||
"description": "Signals about automating repetitive tasks, scheduling recurring operations, and reducing manual overhead in day-to-day workflows.",
|
||||
"aliases": ["workflow automation", "reporting cadence", "admin efficiency", "scheduled tasks", "recurring operations"]
|
||||
},
|
||||
{
|
||||
"id": "mobile-usability",
|
||||
"label": "Mobile Usability",
|
||||
"description": "Feedback on mobile experience gaps — density of information on small screens, touch interactions, and on-the-go decision making.",
|
||||
"aliases": ["mobile usability", "alert prioritization", "frontline decision making", "responsive design", "touch interaction"]
|
||||
},
|
||||
{
|
||||
"id": "data-governance",
|
||||
"label": "Data Governance & Permissions",
|
||||
"description": "Concerns around sharing confidence, permission transparency, and ensuring sensitive data stays protected during collaboration.",
|
||||
"aliases": ["permissions transparency", "data governance", "sharing confidence", "access control", "data privacy"]
|
||||
},
|
||||
{
|
||||
"id": "onboarding-setup",
|
||||
"label": "Onboarding & Setup",
|
||||
"description": "Pain points in first-run experiences, initial configuration complexity, and time-to-value for new users and teams.",
|
||||
"aliases": ["onboarding", "first-run experience", "setup complexity", "time to value", "getting started"]
|
||||
},
|
||||
{
|
||||
"id": "performance-reliability",
|
||||
"label": "Performance & Reliability",
|
||||
"description": "Issues with load times, API timeouts, data sync delays, and system reliability under normal and peak usage.",
|
||||
"aliases": ["performance", "load times", "reliability", "api timeouts", "data sync", "latency"]
|
||||
},
|
||||
{
|
||||
"id": "integration-ecosystem",
|
||||
"label": "Integration Ecosystem",
|
||||
"description": "Requests for third-party connectors, API extensibility, webhook support, and interoperability with existing toolchains.",
|
||||
"aliases": ["integrations", "third-party connectors", "api extensibility", "webhook support", "ecosystem"]
|
||||
}
|
||||
],
|
||||
"signals": [
|
||||
{
|
||||
"id": "sig-001",
|
||||
"source": "user-interview",
|
||||
"customer": "Northstar Analytics Cooperative",
|
||||
"title": "Admins need scheduled exports for recurring reviews",
|
||||
"description": "A fictional operations admin described rebuilding the same export every week before leadership review. The core need is a recurring delivery flow with clear ownership and failure visibility.",
|
||||
"impact": "high",
|
||||
"themes": ["workflow-automation"],
|
||||
"submittedBy": "Sarah Chen",
|
||||
"createdAt": "2026-04-12"
|
||||
},
|
||||
{
|
||||
"id": "sig-002",
|
||||
"source": "customer-call",
|
||||
"customer": "Blue Harbor Retail Group",
|
||||
"title": "Field managers need faster mobile triage",
|
||||
"description": "A fictional district manager said alert detail pages are useful on desktop but too dense during store visits. They want a compact mobile summary that highlights severity, affected locations, and the next best action.",
|
||||
"impact": "medium",
|
||||
"themes": ["mobile-usability"],
|
||||
"submittedBy": "Marcus Rivera",
|
||||
"createdAt": "2026-04-15"
|
||||
},
|
||||
{
|
||||
"id": "sig-003",
|
||||
"source": "support-ticket",
|
||||
"customer": "Cedar Labs Education",
|
||||
"title": "Analysts need clearer permission boundaries",
|
||||
"description": "A fictional analytics lead hesitated to share dashboards because the UI did not clearly explain which sensitive fields were excluded for external reviewers. The theme is confidence-building around governed collaboration.",
|
||||
"impact": "high",
|
||||
"themes": ["data-governance"],
|
||||
"submittedBy": "Priya Patel",
|
||||
"createdAt": "2026-04-18"
|
||||
},
|
||||
{
|
||||
"id": "sig-004",
|
||||
"source": "sales-note",
|
||||
"customer": "Verdant Supply Co",
|
||||
"title": "Procurement team blocked by slow initial setup",
|
||||
"description": "Prospect's IT team estimated 3 weeks to configure SSO and role mappings. They need a guided wizard that reduces setup from weeks to hours, with clear progress indicators and rollback options.",
|
||||
"impact": "high",
|
||||
"themes": ["onboarding-setup"],
|
||||
"submittedBy": "James O'Brien",
|
||||
"createdAt": "2026-04-20"
|
||||
},
|
||||
{
|
||||
"id": "sig-005",
|
||||
"source": "support-ticket",
|
||||
"customer": "Apex Manufacturing",
|
||||
"title": "Dashboard timeouts during month-end reporting",
|
||||
"description": "Multiple users reported 30-second load times and occasional gateway timeouts when running aggregate queries across all business units during month-end close. Affects executive visibility into financials.",
|
||||
"impact": "high",
|
||||
"themes": ["performance-reliability"],
|
||||
"submittedBy": "Lisa Chang",
|
||||
"createdAt": "2026-04-22"
|
||||
},
|
||||
{
|
||||
"id": "sig-006",
|
||||
"source": "customer-call",
|
||||
"customer": "Meridian Health Systems",
|
||||
"title": "Need Salesforce integration for patient outreach tracking",
|
||||
"description": "Clinical ops team manually exports engagement data to upload into Salesforce campaigns. They need a native connector or webhook that syncs patient touchpoints in near real-time.",
|
||||
"impact": "medium",
|
||||
"themes": ["integration-ecosystem"],
|
||||
"submittedBy": "David Park",
|
||||
"createdAt": "2026-04-25"
|
||||
},
|
||||
{
|
||||
"id": "sig-007",
|
||||
"source": "user-interview",
|
||||
"customer": "Northstar Analytics Cooperative",
|
||||
"title": "Approval chains block time-sensitive reports",
|
||||
"description": "Reports that require manager sign-off before distribution often miss their deadline. The team wants conditional auto-approval for recurring reports that haven't changed scope.",
|
||||
"impact": "medium",
|
||||
"themes": ["workflow-automation"],
|
||||
"submittedBy": "Sarah Chen",
|
||||
"createdAt": "2026-05-01"
|
||||
},
|
||||
{
|
||||
"id": "sig-008",
|
||||
"source": "teams-conversation",
|
||||
"customer": "Blue Harbor Retail Group",
|
||||
"title": "Push notifications dismissed too easily on mobile",
|
||||
"description": "Store managers reported that critical alerts are visually identical to informational ones. They swipe-dismiss high-priority alerts because there's no visual urgency differentiation on the lock screen.",
|
||||
"impact": "high",
|
||||
"themes": ["mobile-usability"],
|
||||
"submittedBy": "Marcus Rivera",
|
||||
"createdAt": "2026-05-03"
|
||||
},
|
||||
{
|
||||
"id": "sig-009",
|
||||
"source": "user-interview",
|
||||
"customer": "Cedar Labs Education",
|
||||
"title": "External partners confused by permission error messages",
|
||||
"description": "Partner reviewers see generic 'Access Denied' screens with no explanation of what they lack access to or who to contact. They need contextual guidance that preserves security while reducing friction.",
|
||||
"impact": "medium",
|
||||
"themes": ["data-governance"],
|
||||
"submittedBy": "Priya Patel",
|
||||
"createdAt": "2026-05-05"
|
||||
},
|
||||
{
|
||||
"id": "sig-010",
|
||||
"source": "customer-call",
|
||||
"customer": "Solaris Energy",
|
||||
"title": "New team members take too long to become productive",
|
||||
"description": "Engineering managers say it takes 2-3 weeks for new hires to navigate the system confidently. They want role-based onboarding paths with interactive tutorials rather than static documentation.",
|
||||
"impact": "medium",
|
||||
"themes": ["onboarding-setup"],
|
||||
"submittedBy": "Amanda Foster",
|
||||
"createdAt": "2026-05-07"
|
||||
},
|
||||
{
|
||||
"id": "sig-011",
|
||||
"source": "support-ticket",
|
||||
"customer": "Pinnacle Financial",
|
||||
"title": "Real-time data sync drops events under high load",
|
||||
"description": "During market open hours, the event stream occasionally drops updates, causing stale portfolio values. They need guaranteed delivery or at minimum a visible staleness indicator.",
|
||||
"impact": "high",
|
||||
"themes": ["performance-reliability"],
|
||||
"submittedBy": "Robert Kim",
|
||||
"createdAt": "2026-05-09"
|
||||
},
|
||||
{
|
||||
"id": "sig-012",
|
||||
"source": "sales-note",
|
||||
"customer": "Atlas Logistics",
|
||||
"title": "Must integrate with ServiceNow for IT ticket routing",
|
||||
"description": "Prospect requires alerts to automatically create ServiceNow incidents with proper categorization. Without this integration, their compliance team won't approve the vendor.",
|
||||
"impact": "high",
|
||||
"themes": ["integration-ecosystem"],
|
||||
"submittedBy": "Jennifer Walsh",
|
||||
"createdAt": "2026-05-11"
|
||||
},
|
||||
{
|
||||
"id": "sig-013",
|
||||
"source": "teams-conversation",
|
||||
"customer": "Verdant Supply Co",
|
||||
"title": "Bulk user provisioning needs CSV import",
|
||||
"description": "IT admin has 200+ users to onboard and the current one-by-one flow is untenable. They need batch import with validation preview and error handling.",
|
||||
"impact": "medium",
|
||||
"themes": ["onboarding-setup", "workflow-automation"],
|
||||
"submittedBy": "Thomas Wright",
|
||||
"createdAt": "2026-05-13"
|
||||
},
|
||||
{
|
||||
"id": "sig-014",
|
||||
"source": "customer-call",
|
||||
"customer": "Apex Manufacturing",
|
||||
"title": "API rate limits too restrictive for ETL pipelines",
|
||||
"description": "Their data engineering team hits rate limits during nightly batch syncs. Current limits of 100 req/min are insufficient for their 50K-record nightly ETL job.",
|
||||
"impact": "medium",
|
||||
"themes": ["performance-reliability", "integration-ecosystem"],
|
||||
"submittedBy": "Lisa Chang",
|
||||
"createdAt": "2026-05-15"
|
||||
},
|
||||
{
|
||||
"id": "sig-015",
|
||||
"source": "user-interview",
|
||||
"customer": "Meridian Health Systems",
|
||||
"title": "Mobile app crashes when offline then reconnecting",
|
||||
"description": "Clinicians in areas with spotty WiFi lose unsaved form data when the app crashes on network transition. They need offline-capable data entry with background sync.",
|
||||
"impact": "high",
|
||||
"themes": ["mobile-usability", "performance-reliability"],
|
||||
"submittedBy": "David Park",
|
||||
"createdAt": "2026-05-17"
|
||||
},
|
||||
{
|
||||
"id": "sig-016",
|
||||
"source": "support-ticket",
|
||||
"customer": "Solaris Energy",
|
||||
"title": "Sharing a dashboard should show a permission preview",
|
||||
"description": "Before sharing, users want to see exactly what the recipient will see — including which widgets will be hidden and which data will be masked. Current share dialog gives no preview.",
|
||||
"impact": "medium",
|
||||
"themes": ["data-governance"],
|
||||
"submittedBy": "Amanda Foster",
|
||||
"createdAt": "2026-05-19"
|
||||
},
|
||||
{
|
||||
"id": "sig-017",
|
||||
"source": "sales-note",
|
||||
"customer": "Pinnacle Financial",
|
||||
"title": "Need webhook notifications for compliance audit trail",
|
||||
"description": "Compliance team requires real-time webhook callbacks whenever sensitive data is accessed or exported. This is a hard requirement for their SOC 2 audit.",
|
||||
"impact": "high",
|
||||
"themes": ["integration-ecosystem", "data-governance"],
|
||||
"submittedBy": "Robert Kim",
|
||||
"createdAt": "2026-05-21"
|
||||
},
|
||||
{
|
||||
"id": "sig-018",
|
||||
"source": "other",
|
||||
"customer": "Atlas Logistics",
|
||||
"title": "Automated alert escalation when no action taken",
|
||||
"description": "If a critical alert isn't acknowledged within 15 minutes, it should auto-escalate to the next person in the chain. Current system only sends one notification with no follow-up.",
|
||||
"impact": "high",
|
||||
"themes": ["workflow-automation"],
|
||||
"submittedBy": "Jennifer Walsh",
|
||||
"createdAt": "2026-05-23"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension";
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// ─── Load fixture data ───
|
||||
|
||||
const fixtureRaw = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "data", "signals.json"), "utf8")
|
||||
);
|
||||
const THEMES = fixtureRaw.themes;
|
||||
const SIGNALS = fixtureRaw.signals;
|
||||
|
||||
// ─── Theme computation ───
|
||||
|
||||
function computeThemeGroups() {
|
||||
return THEMES.map((theme) => {
|
||||
const signals = SIGNALS.filter((s) => s.themes.includes(theme.id));
|
||||
const impactOrder = { high: 3, medium: 2, low: 1 };
|
||||
const maxImpact = signals.reduce(
|
||||
(max, s) => (impactOrder[s.impact] > impactOrder[max] ? s.impact : max),
|
||||
"low"
|
||||
);
|
||||
const sources = [...new Set(signals.map((s) => s.source))];
|
||||
const customers = [...new Set(signals.map((s) => s.customer))];
|
||||
return {
|
||||
...theme,
|
||||
signalCount: signals.length,
|
||||
maxImpact,
|
||||
sources,
|
||||
customers,
|
||||
signals,
|
||||
};
|
||||
}).sort((a, b) => {
|
||||
const impactOrder = { high: 3, medium: 2, low: 1 };
|
||||
if (impactOrder[b.maxImpact] !== impactOrder[a.maxImpact]) {
|
||||
return impactOrder[b.maxImpact] - impactOrder[a.maxImpact];
|
||||
}
|
||||
return b.signalCount - a.signalCount;
|
||||
});
|
||||
}
|
||||
|
||||
function getState() {
|
||||
const groups = computeThemeGroups();
|
||||
return {
|
||||
totalSignals: SIGNALS.length,
|
||||
totalThemes: THEMES.length,
|
||||
themes: groups,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── SSE ───
|
||||
|
||||
const sseClients = new Set();
|
||||
|
||||
function broadcast(event, data) {
|
||||
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
for (const res of sseClients) res.write(msg);
|
||||
}
|
||||
|
||||
// ─── HTTP helpers ───
|
||||
|
||||
function readJson(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => resolve(body ? JSON.parse(body) : {}));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function json(res, code, data) {
|
||||
res.writeHead(code, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
// ─── HTTP server ───
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
|
||||
if (url.pathname === "/events") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
sseClients.add(res);
|
||||
req.on("close", () => sseClients.delete(res));
|
||||
res.write(`event: state\ndata: ${JSON.stringify(getState())}\n\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/state") {
|
||||
json(res, 200, getState());
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/explore-theme") {
|
||||
const { themeId } = await readJson(req);
|
||||
const theme = computeThemeGroups().find((t) => t.id === themeId);
|
||||
if (!theme) {
|
||||
json(res, 404, { error: "Theme not found" });
|
||||
return;
|
||||
}
|
||||
// Trigger the agent to start a session exploring this theme
|
||||
session.send({
|
||||
prompt: `The user wants to explore the "${theme.label}" feedback theme in depth. This theme has ${theme.signalCount} signals across customers: ${theme.customers.join(", ")}. Maximum impact: ${theme.maxImpact}.
|
||||
|
||||
Theme description: ${theme.description}
|
||||
|
||||
Signals in this theme:
|
||||
${theme.signals.map((s) => `- [${s.impact.toUpperCase()}] "${s.title}" (${s.customer}): ${s.description}`).join("\n")}
|
||||
|
||||
Please help the user explore this theme. Summarize the key patterns, identify what product changes would address these signals, and suggest next steps. Ask the user what aspect they'd like to dig into.`,
|
||||
});
|
||||
json(res, 200, { ok: true, theme: theme.label });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/") {
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
fs.readFileSync(path.join(__dirname, "public", "index.html"), "utf8")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
function getPort() {
|
||||
return server.address().port;
|
||||
}
|
||||
|
||||
// ─── Canvas declaration ───
|
||||
|
||||
const canvas = createCanvas({
|
||||
id: "feedback-themes",
|
||||
displayName: "Feedback Themes",
|
||||
description:
|
||||
"Explore SignalBox feedback grouped into themes. Shows signal counts, impact levels, and sources for each theme. Use to identify patterns and start deep-dive sessions on specific themes.",
|
||||
actions: [
|
||||
{
|
||||
name: "get_state",
|
||||
description:
|
||||
"Get all feedback themes with their grouped signals, impact levels, and source breakdown.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
handler() {
|
||||
return getState();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explore_theme",
|
||||
description:
|
||||
"Get detailed information about a specific feedback theme including all associated signals.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
theme_id: {
|
||||
type: "string",
|
||||
description:
|
||||
"Theme identifier (workflow-automation, mobile-usability, data-governance, onboarding-setup, performance-reliability, integration-ecosystem)",
|
||||
},
|
||||
},
|
||||
required: ["theme_id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler({ input }) {
|
||||
const theme = computeThemeGroups().find((t) => t.id === input.theme_id);
|
||||
if (!theme) {
|
||||
throw new CanvasError("not_found", `Theme "${input.theme_id}" not found`);
|
||||
}
|
||||
return theme;
|
||||
},
|
||||
},
|
||||
],
|
||||
open() {
|
||||
const state = getState();
|
||||
broadcast("state", state);
|
||||
return {
|
||||
url: `http://127.0.0.1:${getPort()}`,
|
||||
title: "Feedback Themes",
|
||||
status: `${state.totalSignals} signals across ${state.totalThemes} themes`,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Join session ───
|
||||
|
||||
const session = await joinSession({ canvases: [canvas] });
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
{
|
||||
"name": "feedback-themes",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feedback-themes",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "latest"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot": {
|
||||
"version": "1.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.55.tgz",
|
||||
"integrity": "sha512-wqzI0L7krORW6jDAQPx7VnInka5BYN5yVgu+dpUK4w8xP5RgnOBa6kRoXpydj/9O1ufs0k6RKRtQjsVLp52TRw==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.1.2"
|
||||
},
|
||||
"bin": {
|
||||
"copilot": "npm-loader.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/copilot-darwin-arm64": "1.0.55",
|
||||
"@github/copilot-darwin-x64": "1.0.55",
|
||||
"@github/copilot-linux-arm64": "1.0.55",
|
||||
"@github/copilot-linux-x64": "1.0.55",
|
||||
"@github/copilot-linuxmusl-arm64": "1.0.55",
|
||||
"@github/copilot-linuxmusl-x64": "1.0.55",
|
||||
"@github/copilot-win32-arm64": "1.0.55",
|
||||
"@github/copilot-win32-x64": "1.0.55"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-arm64": {
|
||||
"version": "1.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.55.tgz",
|
||||
"integrity": "sha512-v59pOpA7YO8j/lpDU/1E8l1Ag0hd26hIiEzTNbzqKd7tJpvhN0XTDWDCink50wXL656XIXt8lD8i8sGeD6yPfA==",
|
||||
"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.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.55.tgz",
|
||||
"integrity": "sha512-XrJ9ent/9ogLk8yNp3TMsNVW0qTRDlkw/b34VnTgbAkJCaI3UVqaqpFn60Laa6J5mOPW0/JeKIkkva+7IJdqpQ==",
|
||||
"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.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.55.tgz",
|
||||
"integrity": "sha512-5Q46Q72/l/U8KQRcBwYjzFPNXBCPG177FTmjEVOAH0qk7w58fMUDBEpnf9n1IpxYJDWQJ5BFGtLdfYgVVtkevw==",
|
||||
"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.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.55.tgz",
|
||||
"integrity": "sha512-KWmMCDmKJivvOyDAAe5K8r7uSlVq8aZCh20VfrVXsc4bckO6KjXY/TOagrdBNqkk5rh8v63ghBbxFdWIOvEJRA==",
|
||||
"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.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.55.tgz",
|
||||
"integrity": "sha512-Jb5ug9Ic1pzxB2ZT1xoR8b3Ea1xnvCa4h8cBque51+TevXe6QF98vAfSUIwLe4xu+K6JKhiKEA0SD3w29Z74eA==",
|
||||
"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.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.55.tgz",
|
||||
"integrity": "sha512-qMGIjHxKmW9q26EpoaNKWpmEVGyL/IM8ThVkh7yolDzv9lECFudPzT5yLX7f+VIiF6qWQlrQyzmamp7/fNQ2Zg==",
|
||||
"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.0-beta.9",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.0-beta.9.tgz",
|
||||
"integrity": "sha512-D4yiGL4/faFCjL7bozhX7bgxt/x1wp2LZ2p9Tw+xrA5hbcLh5Be5kPen+bFA8NbVfgt1G2djDYFZlrZjXXmcBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/copilot": "^1.0.55-5",
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-arm64": {
|
||||
"version": "1.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.55.tgz",
|
||||
"integrity": "sha512-TO4EJ8it6Qki7wMKYHqGUEDYmB0EAToy+pE5++OpydB6FijyQ31+/XwjvdnEFkuB4ZgPqu/6Y8hxMKucl2+FYg==",
|
||||
"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.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.55.tgz",
|
||||
"integrity": "sha512-TBMiSZMz8Dhx79JeSEM+7ONGxR5NmxfiDUdySo6thVbRmjS9D8msyAP8ucTsbLBJcTFeb7vsaeObD/ujYQgDtA==",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "feedback-themes",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "extension.mjs",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Feedback Themes</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--outer: #f8fafc;
|
||||
--text: #111827;
|
||||
--muted: #6b7280;
|
||||
--meta: #94a3b8;
|
||||
--border: #e5e7eb;
|
||||
--radius: 8px;
|
||||
--font: 'DM Sans', sans-serif;
|
||||
--mono: 'IBM Plex Mono', monospace;
|
||||
--high: #ef4444;
|
||||
--high-bg: #fef2f2;
|
||||
--medium: #f59e0b;
|
||||
--medium-bg: #fffbeb;
|
||||
--low: #22c55e;
|
||||
--low-bg: #f0fdf4;
|
||||
--accent: #3b82f6;
|
||||
--accent-bg: #eff6ff;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--outer);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
header .subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stat .value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat .label {
|
||||
font-size: 10px;
|
||||
font-family: var(--mono);
|
||||
color: var(--meta);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.theme-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.theme-header {
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.theme-header:hover {
|
||||
background: var(--outer);
|
||||
}
|
||||
|
||||
.impact-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-top: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.impact-dot.high { background: var(--high); }
|
||||
.impact-dot.medium { background: var(--medium); }
|
||||
.impact-dot.low { background: var(--low); }
|
||||
|
||||
.theme-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.theme-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.theme-desc {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.theme-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-badge {
|
||||
font-size: 10px;
|
||||
font-family: var(--mono);
|
||||
color: var(--meta);
|
||||
background: var(--outer);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.signal-count {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: transform 0.2s ease;
|
||||
color: var(--meta);
|
||||
}
|
||||
|
||||
.theme-card.expanded .chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.theme-body {
|
||||
display: none;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.theme-card.expanded .theme-body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.signal-item {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
background: var(--outer);
|
||||
}
|
||||
|
||||
.signal-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.signal-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.signal-desc {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.signal-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.impact-badge {
|
||||
font-size: 9px;
|
||||
font-family: var(--mono);
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.impact-badge.high { background: var(--high-bg); color: var(--high); }
|
||||
.impact-badge.medium { background: var(--medium-bg); color: var(--medium); }
|
||||
.impact-badge.low { background: var(--low-bg); color: var(--low); }
|
||||
|
||||
.source-badge {
|
||||
font-size: 9px;
|
||||
font-family: var(--mono);
|
||||
color: var(--muted);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.customer-badge {
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.explore-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
padding: 8px 14px;
|
||||
font-family: var(--font);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.explore-btn:hover {
|
||||
background: #dbeafe;
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.explore-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.explore-btn.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.explore-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty-state .spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Feedback Themes</h1>
|
||||
<p class="subtitle">Synthetic signals grouped by theme · click to explore</p>
|
||||
</header>
|
||||
<div class="stats-bar" id="stats-bar">
|
||||
<div class="stat"><span class="value" id="stat-signals">—</span><span class="label">Signals</span></div>
|
||||
<div class="stat"><span class="value" id="stat-themes">—</span><span class="label">Themes</span></div>
|
||||
<div class="stat"><span class="value" id="stat-high">—</span><span class="label">High Impact</span></div>
|
||||
</div>
|
||||
<div id="themes-container">
|
||||
<div class="empty-state"><div class="spinner"></div><p>Loading themes…</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const container = document.getElementById('themes-container');
|
||||
|
||||
function sourceLabel(s) {
|
||||
return s.replace(/-/g, ' ');
|
||||
}
|
||||
|
||||
function renderState(state) {
|
||||
document.getElementById('stat-signals').textContent = state.totalSignals;
|
||||
document.getElementById('stat-themes').textContent = state.totalThemes;
|
||||
const highCount = state.themes.filter(t => t.maxImpact === 'high').length;
|
||||
document.getElementById('stat-high').textContent = highCount;
|
||||
|
||||
container.innerHTML = '';
|
||||
for (const theme of state.themes) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'theme-card';
|
||||
card.innerHTML = `
|
||||
<div class="theme-header" data-theme-id="${theme.id}">
|
||||
<div class="impact-dot ${theme.maxImpact}"></div>
|
||||
<div class="theme-info">
|
||||
<div class="theme-label">${theme.label}</div>
|
||||
<div class="theme-desc">${theme.description}</div>
|
||||
<div class="theme-meta">
|
||||
${theme.sources.map(s => `<span class="meta-badge">${sourceLabel(s)}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-count">
|
||||
<span>${theme.signalCount}</span>
|
||||
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-body">
|
||||
${theme.signals.map(s => `
|
||||
<div class="signal-item">
|
||||
<div class="signal-title">${s.title}</div>
|
||||
<div class="signal-desc">${s.description}</div>
|
||||
<div class="signal-meta">
|
||||
<span class="impact-badge ${s.impact}">${s.impact}</span>
|
||||
<span class="source-badge">${sourceLabel(s.source)}</span>
|
||||
<span class="customer-badge">${s.customer}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
<button class="explore-btn" data-theme-id="${theme.id}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
Explore this theme
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(card);
|
||||
}
|
||||
|
||||
// Toggle expand
|
||||
container.querySelectorAll('.theme-header').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
header.parentElement.classList.toggle('expanded');
|
||||
});
|
||||
});
|
||||
|
||||
// Explore button
|
||||
container.querySelectorAll('.explore-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const themeId = btn.dataset.themeId;
|
||||
btn.classList.add('loading');
|
||||
btn.textContent = 'Starting session…';
|
||||
try {
|
||||
await fetch('/api/explore-theme', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ themeId }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to start exploration:', err);
|
||||
}
|
||||
setTimeout(() => {
|
||||
btn.classList.remove('loading');
|
||||
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Explore this theme`;
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// SSE connection
|
||||
const evtSource = new EventSource('/events');
|
||||
evtSource.addEventListener('state', (e) => {
|
||||
renderState(JSON.parse(e.data));
|
||||
});
|
||||
evtSource.onerror = () => {
|
||||
// Reconnect handled by browser
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
+218
@@ -0,0 +1,218 @@
|
||||
{
|
||||
"name": "gesture-review",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gesture-review",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "latest"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot": {
|
||||
"version": "1.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.55.tgz",
|
||||
"integrity": "sha512-wqzI0L7krORW6jDAQPx7VnInka5BYN5yVgu+dpUK4w8xP5RgnOBa6kRoXpydj/9O1ufs0k6RKRtQjsVLp52TRw==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.1.2"
|
||||
},
|
||||
"bin": {
|
||||
"copilot": "npm-loader.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/copilot-darwin-arm64": "1.0.55",
|
||||
"@github/copilot-darwin-x64": "1.0.55",
|
||||
"@github/copilot-linux-arm64": "1.0.55",
|
||||
"@github/copilot-linux-x64": "1.0.55",
|
||||
"@github/copilot-linuxmusl-arm64": "1.0.55",
|
||||
"@github/copilot-linuxmusl-x64": "1.0.55",
|
||||
"@github/copilot-win32-arm64": "1.0.55",
|
||||
"@github/copilot-win32-x64": "1.0.55"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-arm64": {
|
||||
"version": "1.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.55.tgz",
|
||||
"integrity": "sha512-v59pOpA7YO8j/lpDU/1E8l1Ag0hd26hIiEzTNbzqKd7tJpvhN0XTDWDCink50wXL656XIXt8lD8i8sGeD6yPfA==",
|
||||
"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.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.55.tgz",
|
||||
"integrity": "sha512-XrJ9ent/9ogLk8yNp3TMsNVW0qTRDlkw/b34VnTgbAkJCaI3UVqaqpFn60Laa6J5mOPW0/JeKIkkva+7IJdqpQ==",
|
||||
"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.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.55.tgz",
|
||||
"integrity": "sha512-5Q46Q72/l/U8KQRcBwYjzFPNXBCPG177FTmjEVOAH0qk7w58fMUDBEpnf9n1IpxYJDWQJ5BFGtLdfYgVVtkevw==",
|
||||
"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.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.55.tgz",
|
||||
"integrity": "sha512-KWmMCDmKJivvOyDAAe5K8r7uSlVq8aZCh20VfrVXsc4bckO6KjXY/TOagrdBNqkk5rh8v63ghBbxFdWIOvEJRA==",
|
||||
"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.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.55.tgz",
|
||||
"integrity": "sha512-Jb5ug9Ic1pzxB2ZT1xoR8b3Ea1xnvCa4h8cBque51+TevXe6QF98vAfSUIwLe4xu+K6JKhiKEA0SD3w29Z74eA==",
|
||||
"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.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.55.tgz",
|
||||
"integrity": "sha512-qMGIjHxKmW9q26EpoaNKWpmEVGyL/IM8ThVkh7yolDzv9lECFudPzT5yLX7f+VIiF6qWQlrQyzmamp7/fNQ2Zg==",
|
||||
"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.0-beta.9",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.0-beta.9.tgz",
|
||||
"integrity": "sha512-D4yiGL4/faFCjL7bozhX7bgxt/x1wp2LZ2p9Tw+xrA5hbcLh5Be5kPen+bFA8NbVfgt1G2djDYFZlrZjXXmcBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/copilot": "^1.0.55-5",
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-arm64": {
|
||||
"version": "1.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.55.tgz",
|
||||
"integrity": "sha512-TO4EJ8it6Qki7wMKYHqGUEDYmB0EAToy+pE5++OpydB6FijyQ31+/XwjvdnEFkuB4ZgPqu/6Y8hxMKucl2+FYg==",
|
||||
"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.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.55.tgz",
|
||||
"integrity": "sha512-TBMiSZMz8Dhx79JeSEM+7ONGxR5NmxfiDUdySo6thVbRmjS9D8msyAP8ucTsbLBJcTFeb7vsaeObD/ujYQgDtA==",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "gesture-review",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "extension.mjs",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
// Extension: where-was-i
|
||||
// Interrupt Recovery canvas — helps developers resume mental context after interruption.
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import { execFile } from "node:child_process";
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { joinSession, createCanvas } from "@github/copilot-sdk/extension";
|
||||
|
||||
const servers = new Map();
|
||||
const sseClients = new Map(); // instanceId → Set<res>
|
||||
const contextCache = new Map(); // instanceId → contextData
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
// Derive repo root from extension location (.github/extensions/where-was-i/)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const REPO_ROOT = join(__dirname, "..", "..", "..");
|
||||
|
||||
// --- Shell helpers ---
|
||||
|
||||
function run(cmd, cwd) {
|
||||
const shell = isWindows ? "powershell" : "bash";
|
||||
const args = isWindows
|
||||
? ["-NoProfile", "-NoLogo", "-Command", cmd]
|
||||
: ["-c", cmd];
|
||||
return new Promise((resolve) => {
|
||||
execFile(shell, args, { cwd, timeout: 15000, maxBuffer: 1024 * 256 }, (err, stdout) => {
|
||||
resolve(err ? "" : (stdout || "").trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function gatherContext(cwd) {
|
||||
cwd = cwd || REPO_ROOT;
|
||||
const authorCmd = isWindows
|
||||
? 'git log --oneline -5 --format="%h %s" --author="$(git config user.name)"'
|
||||
: 'git log --oneline -5 --format="%h %s" --author="$(git config user.name)"';
|
||||
const suppressErr = isWindows ? "2>$null" : "2>/dev/null";
|
||||
|
||||
const [branch, log, status, diff, prs, issues] = await Promise.all([
|
||||
run("git branch --show-current", cwd),
|
||||
run(authorCmd, cwd),
|
||||
run("git status --short", cwd),
|
||||
run("git diff --stat", cwd),
|
||||
run(`gh pr list --author=@me --state=open --limit=10 --json number,title,url,updatedAt,comments ${suppressErr}`, cwd),
|
||||
run(`gh issue list --assignee=@me --state=open --limit=10 --json number,title,url,updatedAt ${suppressErr}`, cwd),
|
||||
]);
|
||||
|
||||
let parsedPrs = [];
|
||||
let parsedIssues = [];
|
||||
try { parsedPrs = JSON.parse(prs || "[]"); } catch {}
|
||||
try { parsedIssues = JSON.parse(issues || "[]"); } catch {}
|
||||
|
||||
return {
|
||||
branch,
|
||||
recentCommits: log.split("\n").filter(Boolean),
|
||||
uncommitted: status.split("\n").filter(Boolean),
|
||||
diffStat: diff,
|
||||
openPrs: parsedPrs,
|
||||
assignedIssues: parsedIssues,
|
||||
gatheredAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Persistence ---
|
||||
|
||||
async function saveContext(workspacePath, data) {
|
||||
if (!workspacePath) return;
|
||||
const dir = join(workspacePath, "files");
|
||||
try { await mkdir(dir, { recursive: true }); } catch {}
|
||||
await writeFile(join(dir, "where-was-i-context.json"), JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
async function loadContext(workspacePath) {
|
||||
if (!workspacePath) return null;
|
||||
try {
|
||||
const raw = await readFile(join(workspacePath, "files", "where-was-i-context.json"), "utf-8");
|
||||
return JSON.parse(raw);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// --- SSE ---
|
||||
|
||||
function broadcast(instanceId, data) {
|
||||
const clients = sseClients.get(instanceId);
|
||||
if (!clients) return;
|
||||
const payload = `data: ${JSON.stringify(data)}\n\n`;
|
||||
for (const res of clients) {
|
||||
try { res.write(payload); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTML renderer ---
|
||||
|
||||
function renderHtml(instanceId) {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Where Was I?</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #f8fcff;
|
||||
--surface: #ffffff;
|
||||
--text: #111827;
|
||||
--muted: #6b7280;
|
||||
--meta: #94a3b8;
|
||||
--border: #e2e8f0;
|
||||
--coral: #ff7f50;
|
||||
--azure: #0ea5e9;
|
||||
--sage: #84cc16;
|
||||
--coral-tint: #fff0eb;
|
||||
--azure-tint: #e8f7fe;
|
||||
--sage-tint: #f2fde0;
|
||||
--sans: 'DM Sans', system-ui, sans-serif;
|
||||
--mono: 'IBM Plex Mono', 'SF Mono', monospace;
|
||||
--radius-soft: 16px;
|
||||
--radius-compact: 8px;
|
||||
--radius-pill: 9999px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: var(--sans);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body { padding: 2rem 1.5rem 3rem; max-width: 880px; margin: 0 auto; }
|
||||
|
||||
.header {
|
||||
margin-bottom: 2.5rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.time-away {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
background: var(--azure-tint);
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid rgba(14,165,233,0.12);
|
||||
}
|
||||
|
||||
.branch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 2rem;
|
||||
padding: 12px 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-compact);
|
||||
}
|
||||
|
||||
.branch-bar .icon { font-size: 1.1rem; }
|
||||
.branch-bar .branch-name {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--azure);
|
||||
}
|
||||
.branch-bar .label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.7px;
|
||||
color: var(--meta);
|
||||
}
|
||||
|
||||
.section { margin-bottom: 2rem; }
|
||||
.section-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--meta);
|
||||
margin-bottom: 0.75rem;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-soft);
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 0.75rem;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.card-clickable { cursor: pointer; }
|
||||
.card-clickable:active { transform: translateY(0); }
|
||||
|
||||
.commit-list { list-style: none; }
|
||||
.commit-list li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.03);
|
||||
}
|
||||
.commit-list li:last-child { border-bottom: none; }
|
||||
.commit-hash {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--azure);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.commit-msg {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.file-list { list-style: none; }
|
||||
.file-list li {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
padding: 4px 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.file-list .status-badge {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
margin-right: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.file-list .status-badge.M { color: #d97706; }
|
||||
.file-list .status-badge.A { color: var(--sage); }
|
||||
.file-list .status-badge.D { color: #ef4444; }
|
||||
.file-list .status-badge.U { color: var(--coral); }
|
||||
|
||||
.thread-cards { display: grid; grid-template-columns: 1fr; gap: 0.6rem; }
|
||||
.thread-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 14px 18px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-compact);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.thread-card:hover {
|
||||
border-color: var(--azure);
|
||||
background: color-mix(in srgb, var(--azure) 4%, var(--surface));
|
||||
}
|
||||
.thread-card .number {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--azure);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.thread-card .title {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.thread-card .badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.badge-pr { background: var(--azure-tint); color: var(--azure); }
|
||||
.badge-issue { background: var(--sage-tint); color: #4d7c0f; }
|
||||
|
||||
.resume-section {
|
||||
margin-top: 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.resume-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 36px;
|
||||
font-family: var(--sans);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--coral);
|
||||
border: none;
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(255,127,80,0.3);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.resume-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(255,127,80,0.4);
|
||||
}
|
||||
.resume-btn:active { transform: translateY(0); }
|
||||
|
||||
.resume-hint {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--meta);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
font-style: italic;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 14px;
|
||||
font-family: var(--sans);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.refresh-btn:hover { border-color: var(--azure); color: var(--azure); }
|
||||
.refresh-btn.spinning .icon { animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.diff-stat {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
white-space: pre-wrap;
|
||||
padding: 12px 16px;
|
||||
background: #f1f5f9;
|
||||
border-radius: var(--radius-compact);
|
||||
margin-top: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 0;
|
||||
color: var(--meta);
|
||||
font-size: 0.9rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.loading .dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--azure);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.loading .dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.loading .dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes pulse { 0%,100% { opacity: 0.3; } 50% { opacity: 1; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="loading">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span style="margin-left: 8px;">Reconstructing your context…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const instanceId = "${instanceId}";
|
||||
let contextData = null;
|
||||
|
||||
function timeAgo(isoString) {
|
||||
if (!isoString) return "";
|
||||
const diff = Date.now() - new Date(isoString).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return mins + "m ago";
|
||||
const hrs = Math.floor(mins / 60);
|
||||
const remainMins = mins % 60;
|
||||
if (hrs < 24) return hrs + "h " + remainMins + "m ago";
|
||||
const days = Math.floor(hrs / 24);
|
||||
return days + "d " + (hrs % 24) + "h ago";
|
||||
}
|
||||
|
||||
function timeAwayLabel(isoString) {
|
||||
if (!isoString) return "";
|
||||
const diff = Date.now() - new Date(isoString).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 2) return "You\\'re still in the zone";
|
||||
if (mins < 60) return "Away for " + mins + " minutes";
|
||||
const hrs = Math.floor(mins / 60);
|
||||
const remainMins = mins % 60;
|
||||
if (hrs < 24) return "Away for " + hrs + "h " + remainMins + "m";
|
||||
const days = Math.floor(hrs / 24);
|
||||
return "Away for " + days + " day" + (days > 1 ? "s" : "") + " " + (hrs % 24) + "h";
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || "").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
contextData = data;
|
||||
const app = document.getElementById("app");
|
||||
|
||||
const commits = (data.recentCommits || []).map(c => {
|
||||
const parts = c.split(" ");
|
||||
const hash = parts[0] || "";
|
||||
const msg = parts.slice(1).join(" ");
|
||||
return { hash, msg };
|
||||
});
|
||||
|
||||
const files = (data.uncommitted || []).map(f => {
|
||||
const status = f.substring(0, 2).trim();
|
||||
const path = f.substring(3);
|
||||
return { status, path };
|
||||
});
|
||||
|
||||
const prs = data.openPrs || [];
|
||||
const issues = data.assignedIssues || [];
|
||||
const hasThreads = prs.length > 0 || issues.length > 0;
|
||||
|
||||
app.innerHTML = \`
|
||||
<div class="header">
|
||||
<h1>Where was I?</h1>
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
\${data.gatheredAt ? \`<span class="time-away">\${timeAwayLabel(data.gatheredAt)}</span>\` : ""}
|
||||
<button class="refresh-btn" onclick="doRefresh(this)">
|
||||
<span class="icon">↻</span> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="branch-bar">
|
||||
<span class="icon">⎇</span>
|
||||
<span class="label">Branch</span>
|
||||
<span class="branch-name">\${escapeHtml(data.branch) || "detached HEAD"}</span>
|
||||
</div>
|
||||
|
||||
\${commits.length ? \`
|
||||
<div class="section">
|
||||
<div class="section-title">Recent Commits</div>
|
||||
<div class="card">
|
||||
<ul class="commit-list">
|
||||
\${commits.map(c => \`
|
||||
<li>
|
||||
<span class="commit-hash">\${escapeHtml(c.hash)}</span>
|
||||
<span class="commit-msg">\${escapeHtml(c.msg)}</span>
|
||||
</li>
|
||||
\`).join("")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
\` : ""}
|
||||
|
||||
\${files.length ? \`
|
||||
<div class="section">
|
||||
<div class="section-title">Uncommitted Changes</div>
|
||||
<div class="card">
|
||||
<ul class="file-list">
|
||||
\${files.map(f => \`
|
||||
<li>
|
||||
<span class="status-badge \${escapeHtml(f.status)}">\${escapeHtml(f.status)}</span>
|
||||
\${escapeHtml(f.path)}
|
||||
</li>
|
||||
\`).join("")}
|
||||
</ul>
|
||||
\${data.diffStat ? \`<div class="diff-stat">\${escapeHtml(data.diffStat)}</div>\` : ""}
|
||||
</div>
|
||||
</div>
|
||||
\` : ""}
|
||||
|
||||
\${hasThreads ? \`
|
||||
<div class="section">
|
||||
<div class="section-title">Open Threads</div>
|
||||
<div class="thread-cards">
|
||||
\${prs.map(pr => \`
|
||||
<div class="thread-card card-clickable" onclick="resumeThread('PR #\${pr.number}: \${escapeHtml(pr.title)}')">
|
||||
<span class="number">#\${pr.number}</span>
|
||||
<span class="title">\${escapeHtml(pr.title)}</span>
|
||||
<span class="badge badge-pr">PR</span>
|
||||
</div>
|
||||
\`).join("")}
|
||||
\${issues.map(iss => \`
|
||||
<div class="thread-card card-clickable" onclick="resumeThread('Issue #\${iss.number}: \${escapeHtml(iss.title)}')">
|
||||
<span class="number">#\${iss.number}</span>
|
||||
<span class="title">\${escapeHtml(iss.title)}</span>
|
||||
<span class="badge badge-issue">Issue</span>
|
||||
</div>
|
||||
\`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
\` : ""}
|
||||
|
||||
<div class="resume-section">
|
||||
<button class="resume-btn" onclick="doResume()">
|
||||
↩ Resume where I left off
|
||||
</button>
|
||||
<p class="resume-hint">Sends your full context to the agent so it can help you pick up</p>
|
||||
</div>
|
||||
\`;
|
||||
}
|
||||
|
||||
async function doRefresh(btn) {
|
||||
if (btn) btn.classList.add("spinning");
|
||||
try {
|
||||
const res = await fetch("/refresh", { method: "POST" });
|
||||
const data = await res.json();
|
||||
render(data);
|
||||
} catch (e) {}
|
||||
if (btn) setTimeout(() => btn.classList.remove("spinning"), 300);
|
||||
}
|
||||
|
||||
async function doResume() {
|
||||
await fetch("/resume", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ thread: null })
|
||||
});
|
||||
}
|
||||
|
||||
async function resumeThread(thread) {
|
||||
await fetch("/resume", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ thread })
|
||||
});
|
||||
}
|
||||
|
||||
// SSE for live updates
|
||||
const evtSource = new EventSource("/events");
|
||||
evtSource.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
render(data);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// Initial load
|
||||
fetch("/context").then(r => r.json()).then(render).catch(() => {});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// --- Server ---
|
||||
|
||||
async function startServer(instanceId, sessionRef, cwd, workspacePath) {
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
|
||||
if (url.pathname === "/events") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
});
|
||||
res.write(":\n\n");
|
||||
let clients = sseClients.get(instanceId);
|
||||
if (!clients) { clients = new Set(); sseClients.set(instanceId, clients); }
|
||||
clients.add(res);
|
||||
req.on("close", () => { clients.delete(res); });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/context" && req.method === "GET") {
|
||||
const data = contextCache.get(instanceId) || {};
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/refresh" && req.method === "POST") {
|
||||
const data = await gatherContext(cwd);
|
||||
contextCache.set(instanceId, data);
|
||||
await saveContext(workspacePath, data);
|
||||
broadcast(instanceId, data);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/resume" && req.method === "POST") {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
let thread = null;
|
||||
try { thread = JSON.parse(body).thread; } catch {}
|
||||
|
||||
const ctx = contextCache.get(instanceId) || {};
|
||||
let prompt;
|
||||
if (thread) {
|
||||
prompt = `I was working on ${thread} and got interrupted. Here's my current context:\n\n` +
|
||||
`**Branch:** ${ctx.branch || "unknown"}\n` +
|
||||
`**Recent commits:** ${(ctx.recentCommits || []).join(", ")}\n` +
|
||||
`**Uncommitted changes:** ${(ctx.uncommitted || []).join(", ")}\n` +
|
||||
`**Open PRs:** ${(ctx.openPrs || []).map(p => "#" + p.number + " " + p.title).join(", ")}\n\n` +
|
||||
`Help me pick up where I left off on this specific thread.`;
|
||||
} else {
|
||||
prompt = `I got interrupted and need to resume my work. Here's my full context:\n\n` +
|
||||
`**Branch:** ${ctx.branch || "unknown"}\n` +
|
||||
`**Recent commits:**\n${(ctx.recentCommits || []).map(c => "- " + c).join("\n")}\n\n` +
|
||||
`**Uncommitted changes:**\n${(ctx.uncommitted || []).map(f => "- " + f).join("\n")}\n\n` +
|
||||
`**Diff stat:**\n${ctx.diffStat || "none"}\n\n` +
|
||||
`**Open PRs:** ${(ctx.openPrs || []).map(p => "#" + p.number + " " + p.title).join(", ") || "none"}\n` +
|
||||
`**Assigned issues:** ${(ctx.assignedIssues || []).map(i => "#" + i.number + " " + i.title).join(", ") || "none"}\n\n` +
|
||||
`Help me pick up where I left off. What should I focus on first?`;
|
||||
}
|
||||
|
||||
try { await sessionRef.send(prompt); } catch {}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: serve HTML
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(renderHtml(instanceId));
|
||||
});
|
||||
|
||||
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}/` };
|
||||
}
|
||||
|
||||
// --- Extension ---
|
||||
|
||||
let sessionRef = null;
|
||||
|
||||
const session = await joinSession({
|
||||
canvases: [
|
||||
createCanvas({
|
||||
id: "where-was-i",
|
||||
displayName: "Where Was I?",
|
||||
description: "Interrupt Recovery — reconstructs your working context (branch, commits, changes, PRs) so you can resume after being pulled away.",
|
||||
actions: [
|
||||
{
|
||||
name: "refresh",
|
||||
description: "Re-gather all git/project context and push updates to the canvas",
|
||||
handler: async (ctx) => {
|
||||
const data = await gatherContext(REPO_ROOT);
|
||||
contextCache.set(ctx.instanceId, data);
|
||||
if (sessionRef) await saveContext(sessionRef.workspacePath, data);
|
||||
broadcast(ctx.instanceId, data);
|
||||
return data;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_context",
|
||||
description: "Return the currently assembled developer context as JSON",
|
||||
handler: async (ctx) => {
|
||||
return contextCache.get(ctx.instanceId) || {};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resume",
|
||||
description: "Send a contextual 'resume' message to the agent with the developer's assembled state",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
thread: {
|
||||
type: "string",
|
||||
description: "Optional specific thread/topic to focus on when resuming",
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (ctx) => {
|
||||
const thread = ctx.input?.thread || null;
|
||||
const data = contextCache.get(ctx.instanceId) || {};
|
||||
let prompt;
|
||||
if (thread) {
|
||||
prompt = `I was working on ${thread} and got interrupted. Context: branch=${data.branch}, recent commits: ${(data.recentCommits || []).join("; ")}. Help me resume.`;
|
||||
} else {
|
||||
prompt = `Help me resume. Branch: ${data.branch}. Commits: ${(data.recentCommits || []).join("; ")}. Uncommitted: ${(data.uncommitted || []).join("; ")}.`;
|
||||
}
|
||||
if (sessionRef) await sessionRef.send(prompt);
|
||||
return { sent: true };
|
||||
},
|
||||
},
|
||||
],
|
||||
open: async (ctx) => {
|
||||
let entry = servers.get(ctx.instanceId);
|
||||
if (!entry) {
|
||||
entry = await startServer(ctx.instanceId, sessionRef, REPO_ROOT, sessionRef?.workspacePath);
|
||||
servers.set(ctx.instanceId, entry);
|
||||
}
|
||||
|
||||
// Load persisted context or gather fresh
|
||||
let data = await loadContext(sessionRef?.workspacePath);
|
||||
if (!data) {
|
||||
data = await gatherContext(REPO_ROOT);
|
||||
await saveContext(sessionRef?.workspacePath, data);
|
||||
}
|
||||
contextCache.set(ctx.instanceId, data);
|
||||
// Push to any waiting SSE clients
|
||||
setTimeout(() => broadcast(ctx.instanceId, data), 100);
|
||||
|
||||
return { title: "Where Was I?", url: entry.url };
|
||||
},
|
||||
onClose: async (ctx) => {
|
||||
const entry = servers.get(ctx.instanceId);
|
||||
if (entry) {
|
||||
servers.delete(ctx.instanceId);
|
||||
await new Promise((r) => entry.server.close(() => r()));
|
||||
}
|
||||
sseClients.delete(ctx.instanceId);
|
||||
contextCache.delete(ctx.instanceId);
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
sessionRef = session;
|
||||
@@ -57,6 +57,7 @@ export default defineConfig({
|
||||
{ label: "Skills", link: "/skills/" },
|
||||
{ label: "Hooks", link: "/hooks/" },
|
||||
{ label: "Workflows", link: "/workflows/" },
|
||||
{ label: "Canvas Extensions", link: "/extensions/" },
|
||||
{ label: "Plugins", link: "/plugins/" },
|
||||
{ label: "Tools", link: "/tools/" },
|
||||
{ label: "Contributors", link: "/contributors/" },
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
|
||||
import extensionsData from '../../public/data/extensions.json';
|
||||
import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import BackToTop from '../components/BackToTop.astro';
|
||||
import { renderExtensionsHtml, sortExtensions } from '../scripts/pages/extensions-render';
|
||||
|
||||
const initialItems = sortExtensions(extensionsData.items, 'title');
|
||||
---
|
||||
|
||||
<StarlightPage frontmatter={{ title: 'Canvas Extensions', description: 'Canvas extensions for GitHub Copilot app experiences', template: 'splash', prev: false, next: false, editUrl: false }}>
|
||||
<div id="main-content" class="extensions-page">
|
||||
<PageHeader title="Canvas Extensions" description="Canvas extensions for GitHub Copilot app experiences" icon="plug" />
|
||||
|
||||
<div class="page-content">
|
||||
<div class="container">
|
||||
<div class="listing-toolbar">
|
||||
<div class="listing-toolbar-row">
|
||||
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} extensions</div>
|
||||
<details class="listing-controls">
|
||||
<summary class="listing-controls-trigger btn btn-secondary btn-small">Sort</summary>
|
||||
<div class="listing-controls-panel">
|
||||
<div class="filters-bar" id="filters-bar">
|
||||
<div class="filter-group">
|
||||
<label for="sort-select">Sort:</label>
|
||||
<select id="sort-select" aria-label="Sort extensions">
|
||||
<option value="title">Name (A-Z)</option>
|
||||
<option value="lastUpdated">Recently Updated</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource-list" id="resource-list" role="list" set:html={renderExtensionsHtml(initialItems)}></div>
|
||||
<ContributeCTA resourceType="extensions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
<EmbeddedPageData filename="extensions.json" data={extensionsData} />
|
||||
|
||||
<script>
|
||||
import '../scripts/listing-flyouts';
|
||||
import '../scripts/pages/extensions';
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.extensions-page .resource-preview {
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
</StarlightPage>
|
||||
@@ -170,11 +170,32 @@ const base = import.meta.env.BASE_URL;
|
||||
-
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href={`${base}extensions/`}
|
||||
class="card card-with-count card-category-extension"
|
||||
id="card-extensions"
|
||||
style="--animation-delay: 300ms;"
|
||||
>
|
||||
<div class="card-icon" aria-hidden="true">
|
||||
<Icon name="plug" size={40} />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h3>Canvas Extensions</h3>
|
||||
<p>Interactive canvas extensions for Copilot app experiences</p>
|
||||
</div>
|
||||
<div
|
||||
class="card-count"
|
||||
data-count="extensions"
|
||||
aria-label="Extension count"
|
||||
>
|
||||
-
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href={`${base}tools/`}
|
||||
class="card card-with-count card-category-dev"
|
||||
id="card-tools"
|
||||
style="--animation-delay: 300ms;"
|
||||
style="--animation-delay: 350ms;"
|
||||
>
|
||||
<div class="card-icon" aria-hidden="true">
|
||||
<Icon name="wrench" size={40} />
|
||||
@@ -191,7 +212,7 @@ const base = import.meta.env.BASE_URL;
|
||||
href={`${base}learning-hub/`}
|
||||
class="card card-with-count card-category-learn"
|
||||
id="card-learning-hub"
|
||||
style="--animation-delay: 350ms;"
|
||||
style="--animation-delay: 400ms;"
|
||||
>
|
||||
<div class="card-icon" aria-hidden="true">
|
||||
<Icon name="book" size={40} />
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { escapeHtml, getGitHubUrl, getLastUpdatedHtml } from "../utils";
|
||||
|
||||
export interface RenderableExtension {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
ref: string;
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export type ExtensionSortOption = "title" | "lastUpdated";
|
||||
|
||||
export function sortExtensions<T extends RenderableExtension>(
|
||||
items: T[],
|
||||
sort: ExtensionSortOption
|
||||
): T[] {
|
||||
return [...items].sort((a, b) => {
|
||||
if (sort === "lastUpdated") {
|
||||
const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0;
|
||||
const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
}
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderExtensionsHtml(items: RenderableExtension[]): string {
|
||||
if (items.length === 0) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<h3>No extensions found</h3>
|
||||
<p>No canvas extensions are available right now.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return items
|
||||
.map(
|
||||
(item) => `
|
||||
<article class="resource-item" role="listitem">
|
||||
<div class="resource-preview">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${escapeHtml(item.name)}</div>
|
||||
<div class="resource-description">Canvas extension</div>
|
||||
<div class="resource-meta">
|
||||
${getLastUpdatedHtml(item.lastUpdated)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource-actions">
|
||||
<button
|
||||
class="btn btn-primary btn-small copy-install-url-btn"
|
||||
data-install-url="${escapeHtml(
|
||||
`https://github.com/github/awesome-copilot/tree/${item.ref}/${item.path.replace(
|
||||
/\\/g,
|
||||
"/"
|
||||
)}`
|
||||
)}"
|
||||
title="Copy install URL"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
<a href="${getGitHubUrl(
|
||||
item.path
|
||||
)}" class="btn btn-secondary btn-small" target="_blank" rel="noopener noreferrer" title="View on GitHub">GitHub</a>
|
||||
</div>
|
||||
</article>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Canvas extensions page functionality
|
||||
*/
|
||||
import {
|
||||
copyToClipboard,
|
||||
fetchData,
|
||||
getQueryParam,
|
||||
showToast,
|
||||
updateQueryParams,
|
||||
} from "../utils";
|
||||
import {
|
||||
renderExtensionsHtml,
|
||||
sortExtensions,
|
||||
type ExtensionSortOption,
|
||||
type RenderableExtension,
|
||||
} from "./extensions-render";
|
||||
|
||||
interface Extension extends RenderableExtension {
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
interface ExtensionsData {
|
||||
items: Extension[];
|
||||
}
|
||||
|
||||
let allItems: Extension[] = [];
|
||||
let currentSort: ExtensionSortOption = "title";
|
||||
let actionHandlersReady = false;
|
||||
|
||||
function applySortAndRender(): void {
|
||||
const countEl = document.getElementById("results-count");
|
||||
const results = sortExtensions(allItems, currentSort);
|
||||
|
||||
renderItems(results);
|
||||
if (countEl) {
|
||||
countEl.textContent = `${results.length} extension${results.length === 1 ? "" : "s"}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderItems(items: Extension[]): void {
|
||||
const list = document.getElementById("resource-list");
|
||||
if (!list) return;
|
||||
|
||||
list.innerHTML = renderExtensionsHtml(items);
|
||||
}
|
||||
|
||||
function setupActionHandlers(list: HTMLElement | null): void {
|
||||
if (!list || actionHandlersReady) return;
|
||||
|
||||
list.addEventListener("click", async (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const installButton = target.closest(
|
||||
".copy-install-url-btn"
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
if (!installButton) return;
|
||||
|
||||
event.stopPropagation();
|
||||
const installUrl = installButton.dataset.installUrl || "";
|
||||
const success = await copyToClipboard(installUrl);
|
||||
showToast(
|
||||
success ? "Install URL copied!" : "Failed to copy install URL",
|
||||
success ? "success" : "error"
|
||||
);
|
||||
});
|
||||
|
||||
actionHandlersReady = true;
|
||||
}
|
||||
|
||||
function syncUrlState(): void {
|
||||
updateQueryParams({
|
||||
sort: currentSort === "title" ? "" : currentSort,
|
||||
});
|
||||
}
|
||||
|
||||
export async function initExtensionsPage(): Promise<void> {
|
||||
const list = document.getElementById("resource-list");
|
||||
const sortSelect = document.getElementById(
|
||||
"sort-select"
|
||||
) as HTMLSelectElement;
|
||||
|
||||
setupActionHandlers(list as HTMLElement | null);
|
||||
|
||||
const data = await fetchData<ExtensionsData>("extensions.json");
|
||||
if (!data || !data.items) {
|
||||
if (list)
|
||||
list.innerHTML =
|
||||
'<div class="empty-state"><h3>Failed to load data</h3></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
allItems = data.items;
|
||||
|
||||
const initialSort = getQueryParam("sort");
|
||||
if (initialSort === "lastUpdated") {
|
||||
currentSort = initialSort;
|
||||
if (sortSelect) sortSelect.value = initialSort;
|
||||
}
|
||||
|
||||
sortSelect?.addEventListener("change", () => {
|
||||
currentSort = sortSelect.value as ExtensionSortOption;
|
||||
applySortAndRender();
|
||||
syncUrlState();
|
||||
});
|
||||
|
||||
applySortAndRender();
|
||||
}
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
document.addEventListener("DOMContentLoaded", initExtensionsPage);
|
||||
@@ -11,6 +11,7 @@ interface Manifest {
|
||||
hooks: number;
|
||||
workflows: number;
|
||||
plugins: number;
|
||||
extensions: number;
|
||||
tools: number;
|
||||
};
|
||||
}
|
||||
@@ -27,6 +28,7 @@ export async function initHomepage(): Promise<void> {
|
||||
"hooks",
|
||||
"workflows",
|
||||
"plugins",
|
||||
"extensions",
|
||||
"tools",
|
||||
] as const;
|
||||
countKeys.forEach((key) => {
|
||||
|
||||
Reference in New Issue
Block a user