project-graph-mcp 2.3.0 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -3
- package/project-graph-mcp-2.3.0.tgz +0 -0
- package/src/network/web-server.js +1 -1
- package/vendor/symbiote-node/package.json +2 -2
- package/web/app.js +6 -3
- package/web/components/canvas-graph.js +50 -11
- package/web/components/code-block.js +1 -1
- package/web/components/event-feed/MiniGraphWidget.js +105 -15
- package/web/components/follow-ribbon.js +134 -0
- package/web/follow-controller.js +241 -0
- package/web/panels/code-viewer.js +1 -1
- package/web/panels/dep-graph.js +21 -42
- package/web/style.css +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "project-graph-mcp",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "MCP server for AI agents — project graph, code quality analysis, visual web explorer. JS, TS, Python, Go.",
|
|
6
6
|
"main": "src/network/server.js",
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
"publishConfig": {
|
|
11
11
|
"access": "public"
|
|
12
12
|
},
|
|
13
|
-
|
|
14
13
|
"scripts": {
|
|
15
14
|
"start": "node src/network/server.js",
|
|
16
15
|
"test": "node --test tests/*.test.js"
|
|
@@ -40,7 +39,6 @@
|
|
|
40
39
|
"license": "MIT",
|
|
41
40
|
"dependencies": {
|
|
42
41
|
"@symbiotejs/symbiote": "^3.2.1",
|
|
43
|
-
"symbiote-node": "file:vendor/symbiote-node",
|
|
44
42
|
"ws": "^8.20.0"
|
|
45
43
|
},
|
|
46
44
|
"engines": {
|
|
Binary file
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @ctx .context/src/network/web-server.ctx
|
|
2
2
|
import e from"node:http";import t from"node:fs";import o from"node:path";import n from"node:crypto";import{fileURLToPath as a}from"node:url";import{createRequire as _createRequire}from"node:module";import{WebSocketServer as s}from"ws";import{createServer as i}from"../mcp/mcp-server.js";import c from"../core/event-bus.js";import{registerService as r}from"./local-gateway.js";import{compressFile as _cf}from"../compact/compress.js";import{expandFile as _ef}from"../compact/expand.js";import{setRoots as _setRoots}from"../core/workspace.js";
|
|
3
|
-
const d=o.dirname(a(import.meta.url)),p=o.join(d,"..","..");const _rq=_createRequire(import.meta.url);let _pkgVersion="0.0.0";try{_pkgVersion=JSON.parse(t.readFileSync(o.join(p,"package.json"),"utf8")).version}catch{}function _rv(k){try{const r=_rq.resolve(k);const marker=o.sep+"node_modules"+o.sep+k.replace(/\//g,o.sep);const idx=r.lastIndexOf(marker);if(idx>=0)return r.substring(0,idx+marker.length);return o.dirname(r)}catch{return o.join(p,"node_modules",...k.split("/"))}}const m=o.join(p,"web"),h={"symbiote-node":_rv("symbiote-node"),symbiote:_rv("@symbiotejs/symbiote")},f={".html":"text/html",".js":"text/javascript",".mjs":"text/javascript",".css":"text/css",".json":"application/json",".svg":"image/svg+xml",".png":"image/png",".ico":"image/x-icon",".woff2":"font/woff2"};
|
|
3
|
+
const d=o.dirname(a(import.meta.url)),p=o.join(d,"..","..");const _rq=_createRequire(import.meta.url);let _pkgVersion="0.0.0";try{_pkgVersion=JSON.parse(t.readFileSync(o.join(p,"package.json"),"utf8")).version}catch{}function _rv(k){try{const r=_rq.resolve(k);const marker=o.sep+"node_modules"+o.sep+k.replace(/\//g,o.sep);const idx=r.lastIndexOf(marker);if(idx>=0)return r.substring(0,idx+marker.length);return o.dirname(r)}catch{return o.join(p,"node_modules",...k.split("/"))}}const m=o.join(p,"web"),_symNodeVendor=o.join(p,"vendor","symbiote-node"),h={"symbiote-node":t.existsSync(_symNodeVendor)?_symNodeVendor:_rv("symbiote-node"),symbiote:_rv("@symbiotejs/symbiote")},f={".html":"text/html",".js":"text/javascript",".mjs":"text/javascript",".css":"text/css",".json":"application/json",".svg":"image/svg+xml",".png":"image/png",".ico":"image/x-icon",".woff2":"font/woff2"};
|
|
4
4
|
function g(e,n){const a=o.normalize(e).replace(/^(\.\.[/\\])+/,""),s=a.match(/^[/\\]?vendor[/\\]([^/\\]+)[/\\]?(.*)/);let i,c;if(s&&h[s[1]]?(c=h[s[1]],i=o.join(c,s[2]||"index.js")):(c=m,i=o.join(m,"/"===a?"index.html":a)),!i.startsWith(c))return n.writeHead(403),void n.end("Forbidden");if(t.existsSync(i)&&t.statSync(i).isDirectory()&&(i=o.join(i,"index.html")),!t.existsSync(i))return n.writeHead(404),void n.end("Not Found");const r=o.extname(i),l=f[r]||"application/octet-stream",d=t.readFileSync(i);n.writeHead(200,{"Content-Type":l,"Cache-Control":"no-cache, no-store, must-revalidate"}),n.end(d)}
|
|
5
5
|
function u(e){return n.createHash("sha1").update(e+"258EAFA5-E914-47DA-95CA-5AB5ADF35C70").digest("base64")}
|
|
6
6
|
function y(e){const t=Buffer.from(e,"utf8"),o=t.length;let n;return o<126?(n=Buffer.alloc(2),n[0]=129,n[1]=o):o<65536?(n=Buffer.alloc(4),n[0]=129,n[1]=126,n.writeUInt16BE(o,2)):(n=Buffer.alloc(10),n[0]=129,n[1]=127,n.writeBigUInt64BE(BigInt(o),2)),Buffer.concat([n,t])}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiote-node",
|
|
3
|
-
"version": "0.3.0-alpha.
|
|
3
|
+
"version": "0.3.0-alpha.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Visual Node Graph Editor + Execution Engine — extensible, themeable graph editor and runtime built on Symbiote.js",
|
|
6
6
|
"main": "index.js",
|
|
@@ -56,4 +56,4 @@
|
|
|
56
56
|
"engines": {
|
|
57
57
|
"node": ">=18.0.0"
|
|
58
58
|
}
|
|
59
|
-
}
|
|
59
|
+
}
|
package/web/app.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// @ctx .context/web/app.ctx
|
|
2
2
|
import{Layout as e,LayoutTree as t,applyTheme as n,CARBON as o}from"symbiote-node";
|
|
3
|
+
import{followController}from"./follow-controller.js";
|
|
4
|
+
import"./components/follow-ribbon.js";
|
|
3
5
|
import{state as a,subscribe as s,onEvent as i,call as r,connect as c}from"./state.js";
|
|
4
6
|
import"./panels/file-tree.js";
|
|
5
7
|
import"./panels/code-viewer.js";
|
|
@@ -16,9 +18,10 @@ export const baseUrl=new URL(".",import.meta.url).href;const l=baseUrl;
|
|
|
16
18
|
export async function api(e,t={}){if(a.connected&&e.startsWith("/api/")){const n=await async function(e,t){const n={"/api/skeleton":{name:"get_skeleton",args:e=>({path:e.path})},"/api/file":{name:"compact",args:e=>({action:"compact_file",path:e.path,beautify:!0})},"/api/analysis":{name:"analyze",args:e=>({action:"full_analysis",path:e.path})},"/api/analysis-summary":{name:"analyze",args:e=>({action:"analysis_summary",path:e.path})},"/api/deps":{name:"navigate",args:e=>({action:"deps",symbol:e.symbol})},"/api/usages":{name:"navigate",args:e=>({action:"usages",symbol:e.symbol})},"/api/expand":{name:"navigate",args:e=>({action:"expand",symbol:e.symbol})},"/api/chain":{name:"navigate",args:e=>({action:"call_chain",from:e.from,to:e.to})}}[e];return n?r(n.name,n.args(t)):null}(e,t);if(null!==n)return n}const n=new URLSearchParams(t).toString(),o=e.replace(/^\//, ""),s=n?`${l}${o}?${n}`:`${l}${o}`,i=await fetch(s);if(!i.ok)throw new Error(`API error: ${i.status}`);return i.json()}
|
|
17
19
|
export const events=new EventTarget;
|
|
18
20
|
export function emit(e,t={}){events.dispatchEvent(new CustomEvent(e,{detail:t}))}
|
|
19
|
-
const p={"file-tree":{title:"Files",icon:"folder",component:"pg-file-tree"},"code-viewer":{title:"Code",icon:"code",component:"pg-code-viewer"},"ctx-panel":{title:"Documentation",icon:"description",component:"pg-ctx-panel"},"dep-graph":{title:"Dependencies",icon:"account_tree",component:"pg-dep-graph"},health:{title:"Health",icon:"analytics",component:"pg-health-panel"},monitor:{title:"Live Monitor",icon:"monitor_heart",component:"pg-live-monitor"},settings:{title:"Settings",icon:"settings",component:"pg-settings-panel"}},m=[{id:"explorer",icon:"folder_open",label:"Explorer"},{id:"graph",icon:"developer_board",label:"Graph"},{id:"analysis",icon:"analytics",label:"Analysis"},{id:"monitor",icon:"monitor_heart",label:"Monitor"},{id:"settings",icon:"settings",label:"Settings"}],d={explorer:()=>t.createSplit("horizontal",t.createPanel("file-tree"),t.createSplit("horizontal",t.createPanel("code-viewer"),t.createPanel("ctx-panel"),.65),.2),graph:()=>t.createSplit("horizontal",t.createPanel("file-tree"),t.createPanel("dep-graph"),.18),analysis:()=>t.createPanel("health"),monitor:()=>t.createPanel("monitor"),settings:()=>t.createPanel("settings")};
|
|
21
|
+
const p={"file-tree":{title:"Files",icon:"folder",component:"pg-file-tree"},"code-viewer":{title:"Code",icon:"code",component:"pg-code-viewer"},"ctx-panel":{title:"Documentation",icon:"description",component:"pg-ctx-panel"},"dep-graph":{title:"Dependencies",icon:"account_tree",component:"pg-dep-graph"},health:{title:"Health",icon:"analytics",component:"pg-health-panel"},monitor:{title:"Live Monitor",icon:"monitor_heart",component:"pg-live-monitor"},settings:{title:"Settings",icon:"settings",component:"pg-settings-panel"}},m=[{id:"explorer",icon:"folder_open",label:"Explorer"},{id:"graph",icon:"developer_board",label:"Graph"},{id:"follow",icon:"smart_toy",label:"Follow"},{id:"analysis",icon:"analytics",label:"Analysis"},{id:"monitor",icon:"monitor_heart",label:"Monitor"},{id:"settings",icon:"settings",label:"Settings"}],d={explorer:()=>t.createSplit("horizontal",t.createPanel("file-tree"),t.createSplit("horizontal",t.createPanel("code-viewer"),t.createPanel("ctx-panel"),.65),.2),graph:()=>t.createSplit("horizontal",t.createPanel("file-tree"),t.createPanel("dep-graph"),.18),follow:()=>t.createSplit("horizontal",t.createPanel("file-tree"),t.createSplit("vertical",t.createSplit("horizontal",t.createPanel("dep-graph"),t.createPanel("code-viewer"),.65),t.createPanel("monitor"),.72),.12),analysis:()=>t.createPanel("health"),monitor:()=>t.createPanel("monitor"),settings:()=>t.createPanel("settings")};
|
|
20
22
|
async function u(){(function(){n(document.documentElement,o);const e=document.querySelector(".app-workspace"),t=document.createElement("layout-sidebar");e.prepend(t);const a=e.querySelector(".app-content"),s=document.createElement("panel-layout");s.setAttribute("storage-key","pg-explorer-layout"),s.setAttribute("min-panel-size","150"),s.id="main-layout",a.appendChild(s),requestAnimationFrame(()=>{for(const[e,t]of Object.entries(p))s.registerPanelType(e,t);let lastSection="";function e(){const e=location.hash.replace("#","")||"explorer",t=e.indexOf("?"),n=t>=0?e.substring(0,t):e,o=n.indexOf("/"),a=o>=0?n.substring(0,o):n,i=o>=0?n.substring(o+1):"";if(d[a]&&a!==lastSection){lastSection=a;s.setLayout(d[a]())}"explorer"===a&&i&&requestAnimationFrame(()=>{state.activeFile=i,emit("file-selected",{path:i,fromRoute:!0})})}t.setSections(m),window.addEventListener("hashchange",e),events.addEventListener("file-selected",e=>{if(e.detail.fromRoute)return;if(e.detail.source==="canvas")return;const t=e.detail.path;const _sec=(location.hash.replace("#","").split("?")[0].split("/")[0])||"explorer";if(t&&_sec==="explorer")history.replaceState(null,"",`#explorer/${t}`)}),localStorage.getItem("pg-explorer-layout")||s.setLayout(d.explorer()),location.hash&&"#"!==location.hash?e():location.hash="explorer"})})(),s("project",e=>{e&&(document.title=`${e.name} — Project Graph`,document.getElementById("project-name").textContent=e.name,document.documentElement.style.setProperty("--project-accent",e.color),g(e.agents))}),events.addEventListener("skeleton-loaded",e=>{const t=e.detail;if(!t)return;state.skeleton=t;const n=new Set;for(const e of Object.values(t.n||{}))e.f&&n.add(e.f);for(const e of Object.keys(t.X||{}))n.add(e);for(const[e,o]of Object.entries(t.f||{}))for(const t of o)n.add("./"===e?t:`${e}${t}`);for(const[e,o]of Object.entries(t.a||{}))for(const t of o)n.add("./"===e?t:`${e}${t}`);const o=document.getElementById("project-files");o&&(o.textContent=`${n.size} files`)}),s("skeleton",e=>{if(!e)return;state.skeleton=e;emit("skeleton-loaded",e)}),s("connected",e=>{const t=document.getElementById("status-indicator");t&&(t.className=e?"status connected":"status disconnected")}),i(e=>{if("agent_connect"===e.type||"agent_disconnect"===e.type)return g(e.agents),void emit("agent-event",e);state.monitorEvents.push(e),state.monitorEvents.length>500&&state.monitorEvents.shift(),emit("tool-event",e)}),c()}
|
|
21
23
|
function g(e){let t=document.getElementById("agent-badge");if(!t){const e=document.querySelector(".app-topbar");if(!e)return;t=document.createElement("span"),t.id="agent-badge",t.className="agent-badge",e.appendChild(t)}t.textContent=e>0?`● ${e} agent${1!==e?"s":""}`:"",t.style.display=e>0?"":"none"}
|
|
22
24
|
function f(){document.querySelector("pg-quick-open")||document.body.appendChild(document.createElement("pg-quick-open"))}
|
|
23
|
-
function h(){const btn=document.getElementById("follow-btn");if(!btn)return;let active=false;btn.addEventListener("click",()=>{active=!active;if(active){btn.setAttribute("data-active","");btn.classList.add("active")}else{btn.removeAttribute("data-active");btn.classList.remove("active")}events.dispatchEvent(new CustomEvent("follow-mode-changed",{detail:{enabled:active}}))})}
|
|
24
|
-
"
|
|
25
|
+
function h(){const btn=document.getElementById("follow-btn");if(!btn)return;let active=false;btn.addEventListener("click",()=>{active=!active;if(active){btn.setAttribute("data-active","");btn.classList.add("active");followController.enable();location.hash="follow"}else{btn.removeAttribute("data-active");btn.classList.remove("active");const prev=followController.getPreviousHash();followController.disable();if(prev&&prev!=="#follow")location.hash=prev.replace(/^#/,"")}events.dispatchEvent(new CustomEvent("follow-mode-changed",{detail:{enabled:active}}))});events.addEventListener("follow-state-changed",e=>{const en=e.detail?.enabled;if(en&&!active){active=true;btn.setAttribute("data-active","");btn.classList.add("active")}else if(!en&&active){active=false;btn.removeAttribute("data-active");btn.classList.remove("active")}});window.addEventListener("hashchange",()=>{const sec=(location.hash.replace("#","").split("?")[0].split("/")[0])||"explorer";if(sec==="follow"&&!active){active=true;btn.setAttribute("data-active","");btn.classList.add("active");followController.enable()}else if(sec!=="follow"&&active){active=false;btn.removeAttribute("data-active");btn.classList.remove("active");followController.disable()}})}
|
|
26
|
+
function _initRibbon(){if(!document.querySelector("follow-ribbon"))document.body.appendChild(document.createElement("follow-ribbon"))}
|
|
27
|
+
"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>{u(),f(),followController.init(events,emit),h(),_initRibbon()}):(u(),f(),followController.init(events,emit),h(),_initRibbon());
|
|
@@ -16,12 +16,18 @@ const HIT_RADIUS = 14;
|
|
|
16
16
|
function getNodeRadius(node, conns, opts = {}) {
|
|
17
17
|
const hubScale = 1 + Math.min(conns, 8) * 0.1;
|
|
18
18
|
const aScale = opts.scale ?? (node.aScale || 1);
|
|
19
|
-
let
|
|
19
|
+
let baseR = DOT_RADIUS * hubScale * aScale;
|
|
20
20
|
if (node.isGroup) {
|
|
21
|
-
const childCount = node.children?.length ||
|
|
22
|
-
|
|
21
|
+
const childCount = Math.max(2, Math.min(12, node.children?.length || 3));
|
|
22
|
+
const innerR = baseR * Math.max(0.1, 0.18 - (childCount - 3) * 0.008);
|
|
23
|
+
const spacing = innerR * 2.5;
|
|
24
|
+
const orbitR = spacing / (2 * Math.sin(Math.PI / childCount));
|
|
25
|
+
// r = orbitR + innerR + ringW + padding
|
|
26
|
+
// ringW = r * 0.12
|
|
27
|
+
// r = (orbitR + innerR + 2) / 0.88
|
|
28
|
+
return (orbitR + innerR + 2) / 0.88;
|
|
23
29
|
}
|
|
24
|
-
return
|
|
30
|
+
return baseR;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
const NODE_TYPES = ['data', 'action', 'output', 'config', 'external', 'style', 'docs', 'asset'];
|
|
@@ -280,6 +286,17 @@ export class CanvasGraph extends Symbiote {
|
|
|
280
286
|
this._wakeLoop();
|
|
281
287
|
}
|
|
282
288
|
|
|
289
|
+
pulseNode(nodeId, durationMs = 1500) {
|
|
290
|
+
this._pulses = this._pulses || [];
|
|
291
|
+
this._pulses.push({
|
|
292
|
+
id: nodeId,
|
|
293
|
+
startTime: performance.now(),
|
|
294
|
+
duration: durationMs
|
|
295
|
+
});
|
|
296
|
+
this.needsDraw = true;
|
|
297
|
+
this._wakeLoop();
|
|
298
|
+
}
|
|
299
|
+
|
|
283
300
|
flyToNode(nodeId, options = {}) {
|
|
284
301
|
const node = this.graphDB?.nodes.get(nodeId);
|
|
285
302
|
if (node && node.parentId) {
|
|
@@ -617,8 +634,8 @@ export class CanvasGraph extends Symbiote {
|
|
|
617
634
|
nodeWidth: this.renderMode === 'dots' ? DOT_RADIUS * 2 : 160,
|
|
618
635
|
nodeHeight: this.renderMode === 'dots' ? DOT_RADIUS * 2 : 40,
|
|
619
636
|
mode: 'continuous',
|
|
620
|
-
activeGroupId:
|
|
621
|
-
boundaryRadius:
|
|
637
|
+
activeGroupId: this.currentGroupId,
|
|
638
|
+
boundaryRadius: this.currentGroupId ? this.graphDB.nodes.get(this.currentGroupId).w / 2 : null,
|
|
622
639
|
attractors: null,
|
|
623
640
|
};
|
|
624
641
|
|
|
@@ -1063,15 +1080,13 @@ export class CanvasGraph extends Symbiote {
|
|
|
1063
1080
|
currentCtx.fillStyle = this.blendBg(tc[0], tc[1], tc[2], layerOpacity);
|
|
1064
1081
|
currentCtx.fill();
|
|
1065
1082
|
|
|
1083
|
+
let baseR = DOT_RADIUS * hubScale * (node.aScale || 1);
|
|
1066
1084
|
const childCount = Math.max(2, Math.min(12, node.children?.length || 3));
|
|
1067
|
-
const innerR =
|
|
1085
|
+
const innerR = baseR * Math.max(0.1, 0.18 - (childCount - 3) * 0.008);
|
|
1068
1086
|
|
|
1069
1087
|
// Calculate perfect orbit radius to maintain consistent spacing between dots
|
|
1070
1088
|
const spacing = innerR * 2.5; // Gap between dots
|
|
1071
|
-
const
|
|
1072
|
-
// Ensure they never spill out of the golden ring
|
|
1073
|
-
const maxOrbitR = Math.max(0, r - ringW - innerR - 2);
|
|
1074
|
-
const orbitR = Math.min(idealOrbitR, maxOrbitR);
|
|
1089
|
+
const orbitR = spacing / (2 * Math.sin(Math.PI / childCount));
|
|
1075
1090
|
const isHovered = this.hoverNode && this.hoverNode.id === node.id;
|
|
1076
1091
|
node.aRotSpeed = node.aRotSpeed || 0;
|
|
1077
1092
|
const targetRotSpeed = (isActive || isHovered) ? 0.025 : 0;
|
|
@@ -1130,6 +1145,29 @@ export class CanvasGraph extends Symbiote {
|
|
|
1130
1145
|
mainCtx.setTransform(dpr * this.zoom, 0, 0, dpr * this.zoom, dpr * this.panX, dpr * this.panY);
|
|
1131
1146
|
}
|
|
1132
1147
|
drawDepth(0, mainCtx);
|
|
1148
|
+
|
|
1149
|
+
if (this._pulses && this._pulses.length > 0) {
|
|
1150
|
+
const now = performance.now();
|
|
1151
|
+
this._pulses = this._pulses.filter(p => {
|
|
1152
|
+
const elapsed = now - p.startTime;
|
|
1153
|
+
if (elapsed > p.duration) return false;
|
|
1154
|
+
const pos = this.getSmooth(p.id) || this.nodePositions.get(p.id);
|
|
1155
|
+
if (!pos) return false;
|
|
1156
|
+
const progress = elapsed / p.duration;
|
|
1157
|
+
const pulsePhase = (progress * 3) % 1;
|
|
1158
|
+
const r = 20 + (pulsePhase * 80);
|
|
1159
|
+
const opacity = 1 - pulsePhase;
|
|
1160
|
+
mainCtx.beginPath();
|
|
1161
|
+
mainCtx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
|
|
1162
|
+
mainCtx.fillStyle = `rgba(76, 139, 245, ${opacity * 0.4})`;
|
|
1163
|
+
mainCtx.fill();
|
|
1164
|
+
mainCtx.lineWidth = 2;
|
|
1165
|
+
mainCtx.strokeStyle = `rgba(76, 139, 245, ${opacity * 0.8})`;
|
|
1166
|
+
mainCtx.stroke();
|
|
1167
|
+
this.needsDraw = true;
|
|
1168
|
+
return true;
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1133
1171
|
}
|
|
1134
1172
|
|
|
1135
1173
|
const showMenu = this.activeNode && !this.dragNode && !this.deactivating;
|
|
@@ -1557,6 +1595,7 @@ export class CanvasGraph extends Symbiote {
|
|
|
1557
1595
|
});
|
|
1558
1596
|
|
|
1559
1597
|
this.canvas.addEventListener('pointerup', (e) => {
|
|
1598
|
+
const draggedNode = this.dragNode;
|
|
1560
1599
|
if (this.dragNode) {
|
|
1561
1600
|
this.worker.postMessage({ type: 'unpin', id: this.dragNode.id });
|
|
1562
1601
|
this.dragNode = null;
|
|
@@ -29,6 +29,6 @@ this.$.highlighted=hl;
|
|
|
29
29
|
const e=o.split("\n").length,t=[];for(let o=1;o<=e;o++)t.push(o);this.$.lineNums=t.join("\n");
|
|
30
30
|
}
|
|
31
31
|
}),this.sub("isMarkdown",v=>{this.toggleAttribute("mode-markdown",v)}),this.sub("isImage",v=>{this.toggleAttribute("mode-image",v)})
|
|
32
|
-
}setBasePath(p){this._basePath=p}}
|
|
32
|
+
}setBasePath(p){this._basePath=p}scrollToLine(line){const pre=this.querySelector('.cb-pre');const scroll=this.querySelector('.cb-scroll');if(!pre||!scroll)return;const lineHeight=parseFloat(window.getComputedStyle(pre).lineHeight)||19.2;scroll.scrollTo({top:Math.max(0,(line-1)*lineHeight-scroll.clientHeight/2+lineHeight/2),behavior:'smooth'})}}
|
|
33
33
|
CodeBlock.template='\n <div class="cb-scroll">\n <pre class="cb-gutter" bind="textContent: lineNums"></pre>\n <pre class="cb-pre"><code bind="innerHTML: highlighted"></code></pre>\n <div class="cb-md" bind="innerHTML: highlighted"></div>\n <div class="cb-img-wrap"><img class="cb-img" bind="src: imageSrc"></div>\n </div>\n';
|
|
34
34
|
CodeBlock.rootStyles="\n code-block {\n display: block;\n height: 100%;\n overflow: hidden;\n }\n code-block .cb-scroll {\n display: flex;\n height: 100%;\n overflow: auto;\n align-items: stretch;\n }\n code-block .cb-gutter {\n position: sticky;\n left: 0;\n z-index: 1;\n margin: 0;\n padding: 12px 8px 12px 12px;\n font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;\n font-size: 12px;\n line-height: 1.6;\n text-align: right;\n color: var(--sn-text-dim, hsl(30, 10%, 55%));\n opacity: 0.45;\n background: var(--sn-bg, hsl(37, 30%, 96%));\n border-right: 1px solid var(--sn-node-border, hsl(35, 18%, 88%));\n user-select: none;\n white-space: pre;\n min-width: 32px;\n flex-shrink: 0;\n }\n code-block .cb-pre {\n margin: 0;\n padding: 12px;\n flex: 1;\n min-width: 0;\n font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;\n font-size: 12px;\n line-height: 1.6;\n color: var(--sn-text, hsl(30, 15%, 18%));\n tab-size: 2;\n white-space: pre;\n box-sizing: border-box;\n }\n /* Markdown container — hidden by default */\n code-block .cb-md {\n display: none;\n padding: 20px 28px;\n flex: 1;\n min-width: 0;\n overflow-wrap: break-word;\n word-wrap: break-word;\n line-height: 1.7;\n color: var(--sn-text, hsl(30, 15%, 18%));\n font-family: var(--sn-font, 'Inter', -apple-system, sans-serif);\n font-size: 14px;\n }\n /* Image container — hidden by default */\n code-block .cb-img-wrap {\n display: none;\n flex: 1;\n padding: 20px;\n justify-content: center;\n align-items: center;\n background: repeating-conic-gradient(hsl(30, 10%, 88%) 0% 25%, hsl(30, 10%, 94%) 0% 50%) 0 0 / 16px 16px;\n }\n code-block .cb-img {\n max-width: 100%;\n max-height: 100%;\n object-fit: contain;\n border-radius: 4px;\n box-shadow: 0 2px 12px rgba(0,0,0,0.12);\n }\n /* In markdown mode: hide code, show md */\n code-block[mode-markdown] .cb-gutter { display: none; }\n code-block[mode-markdown] .cb-pre { display: none; }\n code-block[mode-markdown] .cb-md { display: block; }\n /* In image mode: hide code, show image */\n code-block[mode-image] .cb-gutter { display: none; }\n code-block[mode-image] .cb-pre { display: none; }\n code-block[mode-image] .cb-img-wrap { display: flex; }\n\n /* Markdown styles */\n code-block .md-h { margin: 20px 0 8px; color: var(--sn-text, #222); font-weight: 700; }\n code-block h1.md-h { font-size: 24px; border-bottom: 2px solid var(--sn-node-border, #ddd); padding-bottom: 8px; }\n code-block h2.md-h { font-size: 20px; border-bottom: 1px solid var(--sn-node-border, #ddd); padding-bottom: 6px; }\n code-block h3.md-h { font-size: 16px; }\n code-block h4.md-h { font-size: 14px; }\n code-block .md-p { margin: 8px 0; }\n code-block .md-quote {\n margin: 8px 0;\n padding: 8px 16px;\n border-left: 4px solid var(--sn-cat-server, hsl(210, 45%, 55%));\n background: hsla(210, 40%, 55%, 0.08);\n border-radius: 0 4px 4px 0;\n font-style: italic;\n }\n code-block .md-list {\n margin: 8px 0;\n padding-left: 24px;\n }\n code-block .md-list li {\n margin: 3px 0;\n }\n code-block .md-code-block {\n margin: 12px 0;\n padding: 12px 16px;\n background: var(--sn-bg, hsl(37, 30%, 94%));\n border: 1px solid var(--sn-node-border, hsl(35, 18%, 85%));\n border-radius: 6px;\n font-family: 'SF Mono', 'Fira Code', monospace;\n font-size: 12px;\n line-height: 1.6;\n overflow-x: auto;\n white-space: pre;\n }\n code-block .md-inline-code {\n padding: 1px 5px;\n background: hsla(30, 20%, 50%, 0.12);\n border-radius: 3px;\n font-family: 'SF Mono', 'Fira Code', monospace;\n font-size: 0.9em;\n }\n code-block .md-link {\n color: var(--sn-cat-server, hsl(210, 55%, 50%));\n text-decoration: underline;\n text-decoration-style: dotted;\n }\n code-block .md-link:hover { text-decoration-style: solid; }\n code-block .md-img {\n max-width: 100%;\n height: auto;\n border-radius: 6px;\n margin: 8px 0;\n border: 1px solid var(--sn-node-border, hsl(35, 18%, 85%));\n box-shadow: 0 2px 8px rgba(0,0,0,0.08);\n }\n code-block .md-hr {\n border: none;\n border-top: 1px solid var(--sn-node-border, hsl(35, 18%, 85%));\n margin: 16px 0;\n }\n code-block .md-table {\n width: 100%;\n border-collapse: collapse;\n margin: 12px 0;\n font-size: 13px;\n }\n code-block .md-table th,\n code-block .md-table td {\n padding: 6px 12px;\n border: 1px solid var(--sn-node-border, hsl(35, 18%, 85%));\n text-align: left;\n }\n code-block .md-table th {\n background: hsla(30, 15%, 50%, 0.08);\n font-weight: 600;\n }\n code-block .md-table tr:hover td {\n background: hsla(30, 15%, 50%, 0.04);\n }\n /* Token colors */\n code-block .t-kw { color: rgb(254, 165, 176); }\n code-block .t-str { color: rgb(251, 182, 79); }\n code-block .t-cm { color: rgb(149, 149, 149); font-style: italic; }\n code-block .t-fn { color: rgb(180, 243, 255); }\n code-block .t-num { color: rgb(251, 182, 79); }\n code-block .t-bi { color: rgb(180, 243, 255); }\n code-block .t-prop { color: rgb(238, 131, 252); }\n code-block .t-lit { color: rgb(254, 165, 176); }\n /* JSDoc */\n code-block .t-jd { color: rgb(130, 155, 130); font-style: italic; }\n code-block .t-jd-tag { color: rgb(180, 220, 140); font-style: normal; font-weight: 500; }\n code-block .t-jd-type { color: rgb(130, 210, 240); font-style: normal; }\n",CodeBlock.reg("code-block");
|
|
@@ -20,36 +20,126 @@ export class MiniGraphWidget extends Symbiote {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
_renderSVG(data) {
|
|
23
|
-
// Basic SVG renderer for mini graphs to avoid WebGL context explosion
|
|
24
|
-
// Extracts nodes and links if present in the data
|
|
25
23
|
const nodes = data.nodes || (data.n ? Object.keys(data.n).map(id => ({ id, ...data.n[id] })) : []);
|
|
26
|
-
const
|
|
24
|
+
const rawLinks = data.links || data.e || [];
|
|
25
|
+
|
|
26
|
+
// Some formats use {from, to}, some use {source, target}
|
|
27
|
+
const links = rawLinks.map(l => ({
|
|
28
|
+
from: l.from || l.source,
|
|
29
|
+
to: l.to || l.target
|
|
30
|
+
}));
|
|
27
31
|
|
|
28
32
|
if (!nodes.length) {
|
|
29
33
|
this.$.svgContent = '<text x="10" y="20" fill="var(--sn-text-dim)">No graph data</text>';
|
|
30
34
|
return;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
|
-
// Simple circle layout for demonstration
|
|
34
37
|
const width = 300;
|
|
35
38
|
const height = 150;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
|
|
40
|
+
// Initial deterministic positions (circle)
|
|
41
|
+
nodes.forEach((n, i) => {
|
|
42
|
+
const angle = (i / nodes.length) * Math.PI * 2;
|
|
43
|
+
n.x = width / 2 + Math.cos(angle) * (Math.min(width, height) / 4);
|
|
44
|
+
n.y = height / 2 + Math.sin(angle) * (Math.min(width, height) / 4);
|
|
45
|
+
n.vx = 0; n.vy = 0;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const K = 0.05; // Spring
|
|
49
|
+
const L = 40; // Ideal length
|
|
50
|
+
const REP = 300; // Repulsion
|
|
51
|
+
const DAMP = 0.8;
|
|
39
52
|
|
|
53
|
+
// Run 100 iterations
|
|
54
|
+
for (let i = 0; i < 100; i++) {
|
|
55
|
+
for (let j = 0; j < nodes.length; j++) {
|
|
56
|
+
for (let k = j + 1; k < nodes.length; k++) {
|
|
57
|
+
const a = nodes[j], b = nodes[k];
|
|
58
|
+
let dx = a.x - b.x, dy = a.y - b.y;
|
|
59
|
+
let dist = Math.sqrt(dx*dx + dy*dy) || 0.1;
|
|
60
|
+
let f = REP / (dist * dist);
|
|
61
|
+
const fx = (dx / dist) * f;
|
|
62
|
+
const fy = (dy / dist) * f;
|
|
63
|
+
a.vx += fx; a.vy += fy;
|
|
64
|
+
b.vx -= fx; b.vy -= fy;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
links.forEach(link => {
|
|
68
|
+
const a = nodes.find(n => n.id === link.from);
|
|
69
|
+
const b = nodes.find(n => n.id === link.to);
|
|
70
|
+
if (!a || !b) return;
|
|
71
|
+
let dx = b.x - a.x, dy = b.y - a.y;
|
|
72
|
+
let dist = Math.sqrt(dx*dx + dy*dy) || 0.1;
|
|
73
|
+
let f = (dist - L) * K;
|
|
74
|
+
const fx = (dx / dist) * f;
|
|
75
|
+
const fy = (dy / dist) * f;
|
|
76
|
+
a.vx += fx; a.vy += fy;
|
|
77
|
+
b.vx -= fx; b.vy -= fy;
|
|
78
|
+
});
|
|
79
|
+
nodes.forEach(n => {
|
|
80
|
+
n.vx += (width/2 - n.x) * 0.015;
|
|
81
|
+
n.vy += (height/2 - n.y) * 0.015;
|
|
82
|
+
n.vx *= DAMP; n.vy *= DAMP;
|
|
83
|
+
n.x += n.vx; n.y += n.vy;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Now build SVG
|
|
40
88
|
let svg = '';
|
|
41
89
|
|
|
42
|
-
//
|
|
43
|
-
|
|
90
|
+
// Bounds and scaling to fit within 300x150
|
|
91
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
92
|
+
nodes.forEach(n => {
|
|
93
|
+
minX = Math.min(minX, n.x);
|
|
94
|
+
minY = Math.min(minY, n.y);
|
|
95
|
+
maxX = Math.max(maxX, n.x);
|
|
96
|
+
maxY = Math.max(maxY, n.y);
|
|
97
|
+
});
|
|
44
98
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
99
|
+
const pad = 20;
|
|
100
|
+
const gW = Math.max(1, maxX - minX);
|
|
101
|
+
const gH = Math.max(1, maxY - minY);
|
|
102
|
+
const scale = Math.min((width - pad*2) / gW, (height - pad*2) / gH, 1.2);
|
|
103
|
+
const cx = (minX + maxX) / 2;
|
|
104
|
+
const cy = (minY + maxY) / 2;
|
|
105
|
+
const offX = width / 2 - cx * scale;
|
|
106
|
+
const offY = height / 2 - cy * scale;
|
|
107
|
+
|
|
108
|
+
// Draw Links
|
|
109
|
+
svg += '<g stroke="var(--sn-edge, #666)" stroke-width="1.5" opacity="0.5">';
|
|
110
|
+
links.forEach(link => {
|
|
111
|
+
const a = nodes.find(n => n.id === link.from);
|
|
112
|
+
const b = nodes.find(n => n.id === link.to);
|
|
113
|
+
if (!a || !b) return;
|
|
114
|
+
const x1 = a.x * scale + offX;
|
|
115
|
+
const y1 = a.y * scale + offY;
|
|
116
|
+
const x2 = b.x * scale + offX;
|
|
117
|
+
const y2 = b.y * scale + offY;
|
|
118
|
+
|
|
119
|
+
// Curved paths
|
|
120
|
+
const dx = x2 - x1, dy = y2 - y1;
|
|
121
|
+
const cx1 = x1 + dx * 0.3 - dy * 0.1;
|
|
122
|
+
const cy1 = y1 + dy * 0.3 + dx * 0.1;
|
|
123
|
+
const cx2 = x1 + dx * 0.7 - dy * 0.1;
|
|
124
|
+
const cy2 = y1 + dy * 0.7 + dx * 0.1;
|
|
125
|
+
|
|
126
|
+
svg += `<path d="M${x1},${y1} C${cx1},${cy1} ${cx2},${cy2} ${x2},${y2}" fill="none" />`;
|
|
127
|
+
});
|
|
128
|
+
svg += '</g>';
|
|
129
|
+
|
|
130
|
+
// Draw Nodes
|
|
131
|
+
svg += '<g>';
|
|
132
|
+
nodes.forEach(n => {
|
|
133
|
+
const x = n.x * scale + offX;
|
|
134
|
+
const y = n.y * scale + offY;
|
|
135
|
+
const tc = n.type === 'action' ? '#ff968c' :
|
|
136
|
+
n.type === 'output' ? '#78d2aa' :
|
|
137
|
+
n.type === 'config' ? '#ffc878' : '#78b4ff';
|
|
138
|
+
|
|
139
|
+
svg += `<circle cx="${x}" cy="${y}" r="4" fill="${tc}"></circle>`;
|
|
51
140
|
svg += `<text x="${x + 6}" y="${y + 3}" fill="var(--sn-text)" font-size="10">${this._esc(n.id || n.name || 'node')}</text>`;
|
|
52
141
|
});
|
|
142
|
+
svg += '</g>';
|
|
53
143
|
|
|
54
144
|
this.$.svgContent = svg;
|
|
55
145
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// @ctx .context/web/components/follow-ribbon.ctx
|
|
2
|
+
/**
|
|
3
|
+
* FollowRibbon — Floating status bar that shows current agent action.
|
|
4
|
+
* Appears at the bottom of the screen during Follow Mode.
|
|
5
|
+
* Auto-fades after 4 seconds of inactivity.
|
|
6
|
+
*/
|
|
7
|
+
import Symbiote from '@symbiotejs/symbiote';
|
|
8
|
+
import { events } from '../app.js';
|
|
9
|
+
|
|
10
|
+
export class FollowRibbon extends Symbiote {
|
|
11
|
+
init$ = {
|
|
12
|
+
statusText: '',
|
|
13
|
+
visible: false,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
_fadeTimer = null;
|
|
17
|
+
|
|
18
|
+
initCallback() {
|
|
19
|
+
// Event subscriptions are in renderCallback (after template mount)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
renderCallback() {
|
|
23
|
+
this.sub('visible', (v) => {
|
|
24
|
+
this.toggleAttribute('visible', v);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
events.addEventListener('follow-status-changed', (e) => {
|
|
28
|
+
const text = e.detail?.text || '';
|
|
29
|
+
if (!text) {
|
|
30
|
+
this.$.visible = false;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
this.$.statusText = text;
|
|
34
|
+
this.$.visible = true;
|
|
35
|
+
|
|
36
|
+
// Auto-fade after 4 seconds
|
|
37
|
+
if (this._fadeTimer) clearTimeout(this._fadeTimer);
|
|
38
|
+
this._fadeTimer = setTimeout(() => {
|
|
39
|
+
this.$.visible = false;
|
|
40
|
+
}, 4000);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
events.addEventListener('follow-state-changed', (e) => {
|
|
44
|
+
if (!e.detail?.enabled) {
|
|
45
|
+
this.$.visible = false;
|
|
46
|
+
this.$.statusText = '';
|
|
47
|
+
if (this._fadeTimer) {
|
|
48
|
+
clearTimeout(this._fadeTimer);
|
|
49
|
+
this._fadeTimer = null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
FollowRibbon.template = `
|
|
57
|
+
<div class="fr-inner">
|
|
58
|
+
<span class="fr-icon">smart_toy</span>
|
|
59
|
+
<span class="fr-text" bind="textContent: statusText"></span>
|
|
60
|
+
<span class="fr-dots"></span>
|
|
61
|
+
</div>
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
FollowRibbon.rootStyles = `
|
|
65
|
+
follow-ribbon {
|
|
66
|
+
position: fixed;
|
|
67
|
+
bottom: 20px;
|
|
68
|
+
left: 50%;
|
|
69
|
+
transform: translateX(-50%) translateY(20px);
|
|
70
|
+
z-index: 9999;
|
|
71
|
+
pointer-events: none;
|
|
72
|
+
opacity: 0;
|
|
73
|
+
transition: opacity 0.4s ease, transform 0.4s ease;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
follow-ribbon[visible] {
|
|
77
|
+
opacity: 1;
|
|
78
|
+
transform: translateX(-50%) translateY(0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.fr-inner {
|
|
82
|
+
display: flex;
|
|
83
|
+
align-items: center;
|
|
84
|
+
gap: 10px;
|
|
85
|
+
padding: 8px 20px;
|
|
86
|
+
border-radius: 24px;
|
|
87
|
+
background: rgba(20, 20, 25, 0.85);
|
|
88
|
+
backdrop-filter: blur(16px);
|
|
89
|
+
-webkit-backdrop-filter: blur(16px);
|
|
90
|
+
border: 1px solid rgba(76, 139, 245, 0.25);
|
|
91
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(76, 139, 245, 0.1);
|
|
92
|
+
font-family: 'Inter', -apple-system, sans-serif;
|
|
93
|
+
font-size: 12px;
|
|
94
|
+
font-weight: 500;
|
|
95
|
+
color: rgba(255, 255, 255, 0.9);
|
|
96
|
+
white-space: nowrap;
|
|
97
|
+
max-width: 500px;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.fr-icon {
|
|
101
|
+
font-family: 'Material Symbols Outlined';
|
|
102
|
+
font-size: 16px;
|
|
103
|
+
color: #4c8bf5;
|
|
104
|
+
animation: fr-pulse 2s ease-in-out infinite;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.fr-text {
|
|
108
|
+
overflow: hidden;
|
|
109
|
+
text-overflow: ellipsis;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.fr-dots::after {
|
|
113
|
+
content: '...';
|
|
114
|
+
animation: fr-dots 1.5s steps(3) infinite;
|
|
115
|
+
display: inline-block;
|
|
116
|
+
width: 16px;
|
|
117
|
+
text-align: left;
|
|
118
|
+
color: rgba(255, 255, 255, 0.4);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@keyframes fr-pulse {
|
|
122
|
+
0%, 100% { opacity: 1; }
|
|
123
|
+
50% { opacity: 0.5; }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@keyframes fr-dots {
|
|
127
|
+
0% { content: ''; }
|
|
128
|
+
33% { content: '.'; }
|
|
129
|
+
66% { content: '..'; }
|
|
130
|
+
100% { content: '...'; }
|
|
131
|
+
}
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
FollowRibbon.reg('follow-ribbon');
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// @ctx .context/web/follow-controller.ctx
|
|
2
|
+
/**
|
|
3
|
+
* FollowController — Central orchestrator for Follow Mode.
|
|
4
|
+
*
|
|
5
|
+
* Classifies incoming tool-events and dispatches debounced focus-change
|
|
6
|
+
* signals to subscribed panels (graph, code-viewer, monitor).
|
|
7
|
+
* Also manages the status ribbon text shown during active follow.
|
|
8
|
+
*
|
|
9
|
+
* NOTE: Does NOT import from app.js to avoid circular dependency.
|
|
10
|
+
* Call init(events, emit) before enable().
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Debounce delay for heavy visual updates (camera, code loading) */
|
|
14
|
+
const HEAVY_DEBOUNCE = 800;
|
|
15
|
+
|
|
16
|
+
class FollowController {
|
|
17
|
+
/** @type {boolean} */
|
|
18
|
+
enabled = false;
|
|
19
|
+
/** @type {{type: string, target: any, action?: string, meta?: object}|null} */
|
|
20
|
+
currentFocus = null;
|
|
21
|
+
/** @type {string} */
|
|
22
|
+
statusText = '';
|
|
23
|
+
/** @type {number|null} */
|
|
24
|
+
_debounceTimer = null;
|
|
25
|
+
/** @type {string|null} Previous hash before entering follow mode */
|
|
26
|
+
_previousHash = null;
|
|
27
|
+
/** @type {Function|null} */
|
|
28
|
+
_boundHandler = null;
|
|
29
|
+
/** @type {EventTarget|null} */
|
|
30
|
+
_events = null;
|
|
31
|
+
/** @type {Function|null} */
|
|
32
|
+
_emit = null;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Late-bind events bus and emit function (breaks circular import).
|
|
36
|
+
* Must be called once before enable().
|
|
37
|
+
* @param {EventTarget} events
|
|
38
|
+
* @param {Function} emit
|
|
39
|
+
*/
|
|
40
|
+
init(events, emit) {
|
|
41
|
+
this._events = events;
|
|
42
|
+
this._emit = emit;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
enable() {
|
|
46
|
+
if (this.enabled) return;
|
|
47
|
+
this.enabled = true;
|
|
48
|
+
|
|
49
|
+
// Save current location for restoring later
|
|
50
|
+
this._previousHash = location.hash;
|
|
51
|
+
|
|
52
|
+
// Bind tool-event listener
|
|
53
|
+
this._boundHandler = (e) => this._onToolEvent(e.detail);
|
|
54
|
+
this._events.addEventListener('tool-event', this._boundHandler);
|
|
55
|
+
|
|
56
|
+
this._emit('follow-state-changed', { enabled: true });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
disable() {
|
|
60
|
+
if (!this.enabled) return;
|
|
61
|
+
this.enabled = false;
|
|
62
|
+
|
|
63
|
+
// Clean up
|
|
64
|
+
if (this._boundHandler) {
|
|
65
|
+
this._events.removeEventListener('tool-event', this._boundHandler);
|
|
66
|
+
this._boundHandler = null;
|
|
67
|
+
}
|
|
68
|
+
if (this._debounceTimer) {
|
|
69
|
+
clearTimeout(this._debounceTimer);
|
|
70
|
+
this._debounceTimer = null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.currentFocus = null;
|
|
74
|
+
this._emitStatus('');
|
|
75
|
+
|
|
76
|
+
this._emit('follow-state-changed', { enabled: false });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @returns {string|null} */
|
|
80
|
+
getPreviousHash() {
|
|
81
|
+
return this._previousHash;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Main tool-event dispatcher. Classifies the event and routes to appropriate action.
|
|
86
|
+
* @param {object} event - Tool event from WebSocket
|
|
87
|
+
*/
|
|
88
|
+
_onToolEvent(event) {
|
|
89
|
+
if (!this.enabled) return;
|
|
90
|
+
|
|
91
|
+
const toolName = event.tool || event.name || '';
|
|
92
|
+
const args = event.args || {};
|
|
93
|
+
const isCall = event.type === 'tool_call';
|
|
94
|
+
const isResult = event.type === 'tool_result';
|
|
95
|
+
|
|
96
|
+
// Extract short tool name (strip prefixes like 'default_api:', 'mcp_project-graph_')
|
|
97
|
+
const shortName = this._shortName(toolName);
|
|
98
|
+
|
|
99
|
+
// Status ribbon — update immediately on call
|
|
100
|
+
if (isCall) {
|
|
101
|
+
const statusText = this._buildStatusText(shortName, args);
|
|
102
|
+
if (statusText) this._emitStatus(statusText);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Visual focus — classify and dispatch (debounced for heavy ops)
|
|
106
|
+
const action = this._classify(shortName, args, isCall, isResult, event);
|
|
107
|
+
if (action) {
|
|
108
|
+
if (action.immediate) {
|
|
109
|
+
this._emitFocusNow(action.focus);
|
|
110
|
+
} else {
|
|
111
|
+
this._emitFocusDebounced(action.focus, action.debounce || HEAVY_DEBOUNCE);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Classify tool event into a visual action.
|
|
118
|
+
* Only handles tools emitted by our MCP server (navigate, get_skeleton, etc.).
|
|
119
|
+
* IDE-local tools (view_file, grep_search) never arrive over WebSocket.
|
|
120
|
+
* @returns {{focus: object, debounce?: number, immediate?: boolean}|null}
|
|
121
|
+
*/
|
|
122
|
+
_classify(tool, args, isCall, isResult, raw) {
|
|
123
|
+
if (!isCall) return null;
|
|
124
|
+
|
|
125
|
+
// === Graph navigation ===
|
|
126
|
+
if (tool === 'navigate') {
|
|
127
|
+
if (args.action === 'expand' && args.symbol) {
|
|
128
|
+
return { focus: { type: 'graph', target: args.symbol, action: 'focus' }, debounce: HEAVY_DEBOUNCE };
|
|
129
|
+
}
|
|
130
|
+
if (args.action === 'deps' && args.symbol) {
|
|
131
|
+
return { focus: { type: 'graph', target: args.symbol, action: 'deps' }, debounce: HEAVY_DEBOUNCE };
|
|
132
|
+
}
|
|
133
|
+
if (args.action === 'usages' && args.symbol) {
|
|
134
|
+
return { focus: { type: 'graph', target: args.symbol, action: 'deps' }, debounce: HEAVY_DEBOUNCE };
|
|
135
|
+
}
|
|
136
|
+
if (args.action === 'call_chain' && args.from && args.to) {
|
|
137
|
+
return { focus: { type: 'graph', target: { from: args.from, to: args.to }, action: 'chain' }, immediate: true };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// === Skeleton / Overview ===
|
|
142
|
+
if (tool === 'get_skeleton' || tool === 'get_ai_context') {
|
|
143
|
+
return { focus: { type: 'graph', action: 'fit' }, immediate: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// === Code compaction (compact_file action has a file path) ===
|
|
147
|
+
if (tool === 'compact' && args.path) {
|
|
148
|
+
return { focus: { type: 'file', target: args.path }, debounce: HEAVY_DEBOUNCE };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// === Analysis ===
|
|
152
|
+
if (tool === 'analyze') {
|
|
153
|
+
return { focus: { type: 'analysis' }, immediate: true };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build human-readable status text for the ribbon.
|
|
161
|
+
* Only MCP-server tools arrive here (navigate, get_skeleton, compact, analyze, docs, etc.).
|
|
162
|
+
* @param {string} tool
|
|
163
|
+
* @param {object} args
|
|
164
|
+
* @returns {string}
|
|
165
|
+
*/
|
|
166
|
+
_buildStatusText(tool, args) {
|
|
167
|
+
const file = args.path || '';
|
|
168
|
+
const short = file ? file.split('/').slice(-2).join('/') : '';
|
|
169
|
+
|
|
170
|
+
switch (tool) {
|
|
171
|
+
case 'navigate': {
|
|
172
|
+
if (args.action === 'expand') return `Expanding ${args.symbol}`;
|
|
173
|
+
if (args.action === 'deps') return `Tracing deps of ${args.symbol}`;
|
|
174
|
+
if (args.action === 'usages') return `Finding usages of ${args.symbol}`;
|
|
175
|
+
if (args.action === 'call_chain') return `Tracing ${args.from} → ${args.to}`;
|
|
176
|
+
if (args.action === 'sub_projects') return `Scanning sub-projects`;
|
|
177
|
+
return `Navigating graph`;
|
|
178
|
+
}
|
|
179
|
+
case 'get_skeleton': return `Scanning project structure`;
|
|
180
|
+
case 'get_ai_context': return `Loading AI context`;
|
|
181
|
+
case 'compact': return `Compacting ${short}`;
|
|
182
|
+
case 'analyze': return `Analyzing: ${args.action || ''}`;
|
|
183
|
+
case 'docs': return `Documentation: ${args.action || ''}`;
|
|
184
|
+
case 'jsdoc': return `JSDoc: ${args.action || ''}`;
|
|
185
|
+
case 'db': return `Database: ${args.action || ''}`;
|
|
186
|
+
case 'testing': return `Tests: ${args.action || ''}`;
|
|
187
|
+
case 'filters': return `Filters: ${args.action || ''}`;
|
|
188
|
+
default: return tool ? `Running ${tool}` : '';
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Extract short tool name from full prefixed name.
|
|
194
|
+
* 'default_api:view_file' → 'view_file'
|
|
195
|
+
* 'mcp_project-graph_navigate' → 'navigate'
|
|
196
|
+
*/
|
|
197
|
+
_shortName(full) {
|
|
198
|
+
// Strip 'default_api:' prefix
|
|
199
|
+
let name = full.replace(/^default_api:/, '');
|
|
200
|
+
// Strip 'mcp_project-graph_' prefix
|
|
201
|
+
name = name.replace(/^mcp_project-graph_/, '');
|
|
202
|
+
return name;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Emit focus change immediately (for urgent actions like call_chain).
|
|
207
|
+
*/
|
|
208
|
+
_emitFocusNow(focus) {
|
|
209
|
+
if (this._debounceTimer) {
|
|
210
|
+
clearTimeout(this._debounceTimer);
|
|
211
|
+
this._debounceTimer = null;
|
|
212
|
+
}
|
|
213
|
+
this.currentFocus = focus;
|
|
214
|
+
this._emit('follow-focus-changed', focus);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Emit focus change with debounce (for rapid file reads, etc.).
|
|
219
|
+
*/
|
|
220
|
+
_emitFocusDebounced(focus, delay) {
|
|
221
|
+
if (this._debounceTimer) {
|
|
222
|
+
clearTimeout(this._debounceTimer);
|
|
223
|
+
}
|
|
224
|
+
this._debounceTimer = setTimeout(() => {
|
|
225
|
+
this._debounceTimer = null;
|
|
226
|
+
this.currentFocus = focus;
|
|
227
|
+
this._emit('follow-focus-changed', focus);
|
|
228
|
+
}, delay);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Emit status text for the ribbon.
|
|
233
|
+
*/
|
|
234
|
+
_emitStatus(text) {
|
|
235
|
+
this.statusText = text;
|
|
236
|
+
this._emit('follow-status-changed', { text });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Singleton instance */
|
|
241
|
+
export const followController = new FollowController();
|
|
@@ -29,7 +29,7 @@ export class CodeViewer extends e{init$={filename:"Select a file",hasFile:!1,vie
|
|
|
29
29
|
// Toggle between source and the transformation
|
|
30
30
|
this.$.viewMode=this.$.viewMode==="source"?"transformed":"source";
|
|
31
31
|
this._showCurrentMode();
|
|
32
|
-
}};_fileData=null;_isReadable=!1;_transformCache=null;_loadingTransform=!1;_currentPath=null;initCallback(){t.addEventListener("file-selected",e=>this._loadFile(e.detail.path));if(o.activeFile)requestAnimationFrame(()=>this._loadFile(o.activeFile))}renderCallback(){this.sub("hasFile",e=>{this.toggleAttribute("has-file",e)}),this.sub("viewMode",e=>{
|
|
32
|
+
}};_fileData=null;_isReadable=!1;_transformCache=null;_loadingTransform=!1;_currentPath=null;initCallback(){t.addEventListener("file-selected",e=>this._loadFile(e.detail.path));t.addEventListener("follow-focus-changed",e=>{const d=e.detail;if(d.type==="file"&&d.target){this._loadFile(d.target);if(d.meta?.startLine){setTimeout(()=>{const c=this._getCodeBlock();if(c&&c.scrollToLine)c.scrollToLine(d.meta.startLine)},200)}}});if(o.activeFile)requestAnimationFrame(()=>this._loadFile(o.activeFile))}renderCallback(){this.sub("hasFile",e=>{this.toggleAttribute("has-file",e)}),this.sub("viewMode",e=>{
|
|
33
33
|
const lang=_getLang(this._currentPath);
|
|
34
34
|
this.toggleAttribute("mode-raw","source"!==e);
|
|
35
35
|
if(lang==='md'){
|
package/web/panels/dep-graph.js
CHANGED
|
@@ -687,8 +687,6 @@ export class DepGraph extends Symbiote {
|
|
|
687
687
|
_wasDragged = false;
|
|
688
688
|
/** @type {Map<string, string>} */
|
|
689
689
|
_fileMap = new Map();
|
|
690
|
-
/** @type {boolean} */
|
|
691
|
-
_autopilot = false;
|
|
692
690
|
/** @type {HTMLElement|null} */
|
|
693
691
|
_canvas = null;
|
|
694
692
|
/** @type {object|null} Skeleton data for resolving pin names */
|
|
@@ -851,11 +849,6 @@ export class DepGraph extends Symbiote {
|
|
|
851
849
|
this._restoreFlatFocus();
|
|
852
850
|
}
|
|
853
851
|
});
|
|
854
|
-
|
|
855
|
-
// Follow mode: listen for global state (set from topbar)
|
|
856
|
-
events.addEventListener('follow-mode-changed', (e) => {
|
|
857
|
-
this._autopilot = e.detail.enabled;
|
|
858
|
-
});
|
|
859
852
|
|
|
860
853
|
// Label Mode controls
|
|
861
854
|
const labelBtns = this.querySelectorAll('.label-mode-btn');
|
|
@@ -983,17 +976,14 @@ export class DepGraph extends Symbiote {
|
|
|
983
976
|
ro.observe(this);
|
|
984
977
|
this._resizeObserver = ro;
|
|
985
978
|
|
|
986
|
-
// Bind and save global listener functions for clean up
|
|
987
979
|
this._onSkeletonLoaded = (e) => {
|
|
988
980
|
if (this._graphBuilt || this.style.display === 'none' || this.offsetWidth === 0) return;
|
|
989
981
|
requestAnimationFrame(() => this._buildGraph(e.detail));
|
|
990
982
|
};
|
|
991
983
|
|
|
992
|
-
this.
|
|
984
|
+
this._onFollowFocusChanged = (e) => {
|
|
993
985
|
if (this.style.display === 'none' || this.offsetWidth === 0) return;
|
|
994
|
-
|
|
995
|
-
this._handleAutopilot(e.detail);
|
|
996
|
-
}
|
|
986
|
+
this._handleFollowFocus(e.detail);
|
|
997
987
|
};
|
|
998
988
|
|
|
999
989
|
this._onFileSelected = (e) => {
|
|
@@ -1026,8 +1016,8 @@ export class DepGraph extends Symbiote {
|
|
|
1026
1016
|
}).catch(() => {});
|
|
1027
1017
|
}
|
|
1028
1018
|
|
|
1029
|
-
// Autopilot: listen for
|
|
1030
|
-
events.addEventListener('
|
|
1019
|
+
// Autopilot: listen for orchestrator events
|
|
1020
|
+
events.addEventListener('follow-focus-changed', this._onFollowFocusChanged);
|
|
1031
1021
|
|
|
1032
1022
|
// Update route within graph section
|
|
1033
1023
|
// On node click → save file path (just focusing)
|
|
@@ -1165,7 +1155,7 @@ export class DepGraph extends Symbiote {
|
|
|
1165
1155
|
disconnectedCallback() {
|
|
1166
1156
|
super.disconnectedCallback?.();
|
|
1167
1157
|
if (this._onSkeletonLoaded) events.removeEventListener('skeleton-loaded', this._onSkeletonLoaded);
|
|
1168
|
-
if (this.
|
|
1158
|
+
if (this._onFollowFocusChanged) events.removeEventListener('follow-focus-changed', this._onFollowFocusChanged);
|
|
1169
1159
|
if (this._onFileSelected) events.removeEventListener('file-selected', this._onFileSelected);
|
|
1170
1160
|
if (this._onHashChange) window.removeEventListener('hashchange', this._onHashChange);
|
|
1171
1161
|
if (this._resizeObserver) {
|
|
@@ -2498,37 +2488,26 @@ export class DepGraph extends Symbiote {
|
|
|
2498
2488
|
}
|
|
2499
2489
|
|
|
2500
2490
|
/**
|
|
2501
|
-
* Handle
|
|
2502
|
-
* @param {object}
|
|
2491
|
+
* Handle orchestrated visual focus from FollowController
|
|
2492
|
+
* @param {object} detail
|
|
2503
2493
|
*/
|
|
2504
|
-
|
|
2494
|
+
_handleFollowFocus({ type, target, action }) {
|
|
2505
2495
|
if (!this._editor || !this._canvas) return;
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
} else if (toolName === 'navigate' && args.action === 'call_chain') {
|
|
2517
|
-
// Phase 4: animate call chain when agent traces a path
|
|
2518
|
-
if (args.from && args.to) {
|
|
2519
|
-
this._highlightCallChain(args.from, args.to);
|
|
2520
|
-
}
|
|
2521
|
-
} else if (toolName === 'navigate' && args.action === 'usages' && args.symbol) {
|
|
2522
|
-
this._highlightDeps(args.symbol);
|
|
2523
|
-
} else if (toolName === 'get_skeleton') {
|
|
2496
|
+
if (type !== 'graph' && type !== 'file') return; // React to graph and file actions
|
|
2497
|
+
|
|
2498
|
+
if (type === 'graph') {
|
|
2499
|
+
if (action === 'focus' && target) {
|
|
2500
|
+
this._focusSymbol(target);
|
|
2501
|
+
} else if (action === 'deps' && target) {
|
|
2502
|
+
this._highlightDeps(target);
|
|
2503
|
+
} else if (action === 'chain' && target.from && target.to) {
|
|
2504
|
+
this._highlightCallChain(target.from, target.to);
|
|
2505
|
+
} else if (action === 'fit') {
|
|
2524
2506
|
this._canvas.fitView();
|
|
2525
|
-
} else if (toolName === 'compact' && args.path) {
|
|
2526
|
-
this._pulseFile(args.path);
|
|
2527
|
-
} else if (toolName === 'view_file' && args.path) {
|
|
2528
|
-
// Agent opened a file — focus it on the board
|
|
2529
|
-
this._focusFile(args.path);
|
|
2530
|
-
this._pulseFile(args.path);
|
|
2531
2507
|
}
|
|
2508
|
+
} else if (type === 'file' && target) {
|
|
2509
|
+
this._focusFile(target);
|
|
2510
|
+
this._pulseFile(target);
|
|
2532
2511
|
}
|
|
2533
2512
|
}
|
|
2534
2513
|
|
package/web/style.css
CHANGED
|
@@ -123,6 +123,12 @@ html, body {
|
|
|
123
123
|
background: rgba(76, 139, 245, 0.15);
|
|
124
124
|
color: #4c8bf5;
|
|
125
125
|
border-color: rgba(76, 139, 245, 0.3);
|
|
126
|
+
animation: follow-btn-glow 2s ease-in-out infinite;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@keyframes follow-btn-glow {
|
|
130
|
+
0%, 100% { box-shadow: 0 0 4px rgba(76, 139, 245, 0.1); }
|
|
131
|
+
50% { box-shadow: 0 0 12px rgba(76, 139, 245, 0.4); }
|
|
126
132
|
}
|
|
127
133
|
|
|
128
134
|
/* Agent badge */
|