chore: publish from staged

This commit is contained in:
github-actions[bot]
2026-06-02 18:18:24 +00:00
parent 3d8e3d6e98
commit 1fd59ee7e1
27 changed files with 6617 additions and 3 deletions
+390
View File
@@ -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
View File
@@ -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"
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "diagram-viewer",
"version": "1.0.0",
"type": "module",
"main": "extension.mjs",
"dependencies": {
"@github/copilot-sdk": "latest"
}
}
+721
View File
@@ -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>