@enigmax/dashboard 0.1.108 → 0.1.110

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.
Files changed (2) hide show
  1. package/assets/index.html +652 -27
  2. package/package.json +1 -1
package/assets/index.html CHANGED
@@ -759,6 +759,30 @@
759
759
  rather than a second label competing with the name. */
760
760
  .set-def{opacity:.72;font-variant-numeric:tabular-nums;white-space:nowrap}
761
761
  .set-ctl .gate-reset{flex:none}
762
+
763
+ /* --- Code graph canvas -------------------------------------------------------
764
+ The picture is drawn on one canvas: at a few hundred nodes an SVG/DOM node per
765
+ symbol costs more in layout than the whole simulation does in maths, and the
766
+ label/zoom rules below need per-frame control the DOM cannot give. */
767
+ .cg-wrap { position: relative; height: 460px; margin-top: 10px; border: 1px solid var(--border); border-radius: 10px; background: radial-gradient(circle at 50% 40%, rgba(255,255,255,0.03), transparent 70%), var(--surface2); overflow: hidden; }
768
+ .cg-wrap canvas { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; }
769
+ .cg-wrap canvas.grabbing { cursor: grabbing; }
770
+ .cg-wrap canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
771
+ .cg-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; text-align: center; padding: 20px; color: var(--muted); font-size: 12.5px; line-height: 1.6; background: rgba(8,10,16,0.55); }
772
+ /* `display: flex` outranks the UA's [hidden] rule, so without this the dismissed overlay
773
+ keeps covering the canvas and swallows every click, drag and wheel event on it. */
774
+ .cg-overlay[hidden], .cg-detail[hidden] { display: none; }
775
+ .cg-zoom { position: absolute; right: 10px; bottom: 10px; display: flex; flex-direction: column; gap: 4px; }
776
+ .cg-zoom .iconbtn { background: rgba(8,10,16,0.72); border: 1px solid var(--border); border-radius: 7px; padding: 5px; }
777
+ .cg-legend { position: absolute; left: 10px; top: 10px; display: flex; flex-wrap: wrap; gap: 4px 10px; max-width: 62%; pointer-events: none; }
778
+ .cg-legend span { display: inline-flex; align-items: center; gap: 5px; font-family: var(--mono); font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .6px; }
779
+ .cg-legend i { width: 8px; height: 8px; border-radius: 50%; display: block; }
780
+ .cg-detail { position: absolute; right: 10px; top: 10px; width: min(300px, 62%); max-height: calc(100% - 70px); overflow: auto; background: rgba(8,10,16,0.9); border: 1px solid var(--border); border-radius: 9px; padding: 10px 11px; }
781
+ .cg-detail h4 { margin: 0 6px 2px 0; font-size: 12.5px; font-family: var(--mono); color: var(--text); word-break: break-all; }
782
+ .cg-detail .cg-sig { font-family: var(--mono); font-size: 10.5px; color: var(--muted); white-space: pre-wrap; word-break: break-word; margin: 6px 0 0; }
783
+ .cg-detail .cg-where { font-family: var(--mono); font-size: 10.5px; color: var(--accent2); word-break: break-all; }
784
+ .cg-detail .cg-acts { display: flex; gap: 6px; margin-top: 9px; flex-wrap: wrap; }
785
+ .cg-detail .cg-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 6px; }
762
786
  </style>
763
787
  </head>
764
788
  <body>
@@ -1345,6 +1369,36 @@
1345
1369
  <button id="cgRefresh" type="button" class="iconbtn" title="Refresh" aria-label="Refresh"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
1346
1370
  </div>
1347
1371
  <div id="cgNote" class="set-note"></div>
1372
+ <div class="panel">
1373
+ <div class="set-cat-h">Graph</div>
1374
+ <div class="set-hint" style="padding:4px 0">What depends on what. Click a node for its signature and location, double-click to pull in its neighbours. Empty focus draws the code the rest of the repo leans on most.</div>
1375
+ <div class="ladd" style="flex-wrap:wrap;align-items:center">
1376
+ <input id="cgFocus" class="linput" placeholder="centre on a symbol or file (optional)" style="flex:1;min-width:220px">
1377
+ <select id="cgScope" aria-label="What the nodes are">
1378
+ <option value="symbols">Symbols</option>
1379
+ <option value="files">Files</option>
1380
+ </select>
1381
+ <select id="cgDepth" aria-label="Hops from the focus">
1382
+ <option value="1">1 hop</option>
1383
+ <option value="2">2 hops</option>
1384
+ <option value="3">3 hops</option>
1385
+ <option value="4">4 hops</option>
1386
+ </select>
1387
+ <button id="cgDraw" type="button" class="toggle on">Draw</button>
1388
+ </div>
1389
+ <div id="cgWrap" class="cg-wrap">
1390
+ <canvas id="cgCanvas" tabindex="0" role="img" aria-label="Code graph"></canvas>
1391
+ <div class="cg-legend" id="cgLegend"></div>
1392
+ <div class="cg-zoom">
1393
+ <button id="cgZoomIn" type="button" class="iconbtn" title="Zoom in" aria-label="Zoom in"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
1394
+ <button id="cgZoomOut" type="button" class="iconbtn" title="Zoom out" aria-label="Zoom out"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg></button>
1395
+ <button id="cgFit" type="button" class="iconbtn" title="Fit to view" aria-label="Fit to view"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M16 21h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg></button>
1396
+ </div>
1397
+ <div id="cgDetail" class="cg-detail" hidden></div>
1398
+ <div id="cgGraphOverlay" class="cg-overlay">Drawing...</div>
1399
+ </div>
1400
+ <div id="cgGraphMeta" class="set-hint" style="padding:6px 0 0"></div>
1401
+ </div>
1348
1402
  <div class="panel">
1349
1403
  <div class="set-cat-h">Index a project</div>
1350
1404
  <div class="set-hint" style="padding:4px 0">Build the graph for a codebase. Leave the path empty to index enigma's own working directory.</div>
@@ -2481,7 +2535,7 @@ export {
2481
2535
  "cli": "CLI (enigma compress)", "unknown": "Unattributed",
2482
2536
  };
2483
2537
  function prettySource(k) { return SOURCE_LABELS[k] || String(k).replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); }
2484
- const TYPE_LABELS = { json: "JSON", log: "Logs", text: "Text", code: "Code", diff: "Diffs", markdown: "Markdown", unknown: "Other" };
2538
+ const TYPE_LABELS = { json: "JSON", log: "Logs", text: "Text", code: "Code", diff: "Diffs", markdown: "Markdown", shell: "Command output", unknown: "Other" };
2485
2539
  function prettyType(k) { return TYPE_LABELS[k] || String(k).replace(/\b\w/g, (c) => c.toUpperCase()); }
2486
2540
 
2487
2541
  // One bar per key (source or type), sorted by tokens saved, with an optional $ column.
@@ -3580,7 +3634,8 @@ export {
3580
3634
  + s.choices.map((c) => '<option value="' + esc(c) + '"' + (c === s.choice ? " selected" : "") + ">" + esc(prettyChoice(c)) + "</option>").join("")
3581
3635
  + "</select>"
3582
3636
  : '<button type="button" class="toggle' + (s.value ? " on" : "") + '" data-skey="' + esc(s.key)
3583
- + '" data-kind="bool" data-on="' + (s.value ? 1 : 0) + '">' + (s.value ? "On" : "Off") + "</button>";
3637
+ + '" data-kind="bool" role="switch" aria-checked="' + (s.value ? "true" : "false")
3638
+ + '" data-on="' + (s.value ? 1 : 0) + '">' + (s.value ? "On" : "Off") + "</button>";
3584
3639
  return '<div class="set-row"><div class="set-meta"><div class="set-name">' + settingIcon(s.key) + labelWithBadges(s.label) + "</div>"
3585
3640
  + '<div class="set-hint">' + linkify(s.hint) + (s.globalOnly ? " · global" : "") + "</div></div>"
3586
3641
  + '<div class="set-ctl">' + ctl + "</div></div>";
@@ -3598,7 +3653,19 @@ export {
3598
3653
  + '<div class="set-grid">' + c.settings.map(settingRow).join("") + "</div></div>").join("");
3599
3654
  }
3600
3655
 
3601
- function applyBool(ctl, on) { ctl.classList.toggle("on", on); ctl.textContent = on ? "On" : "Off"; ctl.dataset.on = on ? 1 : 0; }
3656
+ // The one painter for every switch on this page: the class the CSS reads, the ARIA state a
3657
+ // screen reader reads, the data-on the click handlers read, and the label when the switch
3658
+ // carries one. Painted before the write lands and rolled back if the server refuses - every
3659
+ // switch here writes through something slow (re-rendering skills, rewriting each agent's
3660
+ // settings and hooks), so one that only moves once the write returns reads as dead.
3661
+ function paintSwitch(btn, on, onLabel, offLabel) {
3662
+ if (!btn) return;
3663
+ btn.classList.toggle("on", !!on);
3664
+ btn.setAttribute("aria-checked", on ? "true" : "false");
3665
+ btn.dataset.on = on ? "1" : "0";
3666
+ if (onLabel != null) btn.textContent = on ? onLabel : offLabel;
3667
+ }
3668
+ function applyBool(ctl, on) { paintSwitch(ctl, on, "On", "Off"); }
3602
3669
 
3603
3670
  // One place that turns an /api/* call into parsed JSON, or an Error naming the real
3604
3671
  // cause. res.json() throws on any reply that is not JSON - an empty 405, or an HTML
@@ -4561,12 +4628,7 @@ export {
4561
4628
  }
4562
4629
  // The two on/off switches at the top of the page. Both are optimistic: the switch flips
4563
4630
  // first and rolls back if the server refuses, like every other control here.
4564
- function gateBool(btn, on) {
4565
- btn.textContent = on ? "on" : "off";
4566
- btn.classList.toggle("on", !!on);
4567
- btn.dataset.on = on ? "1" : "0";
4568
- btn.setAttribute("aria-checked", on ? "true" : "false");
4569
- }
4631
+ function gateBool(btn, on) { paintSwitch(btn, on, "on", "off"); }
4570
4632
  // Defaults for every gate setting, straight from the gate's own config loader (the
4571
4633
  // server resolves an empty config rather than keeping a second copy of the numbers).
4572
4634
  // They drive the "default N" hint and the per-row reset below.
@@ -5570,11 +5632,543 @@ export {
5570
5632
  + entries.map(([k, v]) => '<div class="set-row"><div class="set-meta"><div class="set-name">' + esc(k) + '</div><div class="set-hint">' + cgVal(v) + "</div></div></div>").join("")
5571
5633
  + "</div>";
5572
5634
  }
5635
+ // --- Code graph canvas ------------------------------------------------------------
5636
+ // A force-directed picture of the slice the server cut. Everything is hand-rolled on one
5637
+ // canvas: no graph library, no CDN - the dashboard ships as a single offline file, and at
5638
+ // a few hundred nodes the whole simulation costs less than a DOM node per symbol would.
5639
+ // Colour says WHERE a node lives, shape says WHAT it is (a file is a rounded square, a
5640
+ // symbol a circle). Colouring by kind instead made the file view - where every node is a
5641
+ // file - a single grey cloud, and told you nothing you could not read off the shape.
5642
+ const CG_GROUP_COLOR = ["#e0a458", "#5aa9e6", "#6fcf97", "#a78bfa", "#d7875f", "#56c8c8", "#e3c14e", "#c586c0", "#7f9cf5", "#8a93a3"];
5643
+ const CG_UNGROUPED = "#8a93a3";
5644
+ const CG_REL_COLOR = {
5645
+ imports: "rgba(138,147,163,0.55)", calls: "rgba(224,164,88,0.55)",
5646
+ references: "rgba(138,147,163,0.30)", extends: "rgba(111,207,151,0.55)",
5647
+ implements: "rgba(90,169,230,0.55)", contains: "rgba(138,147,163,0.20)"
5648
+ };
5649
+ // Tuned together: weaker links or a longer cutoff and dense clusters never separate.
5650
+ const CG_LINK_LEN = 82, CG_LINK_K = 0.22, CG_CHARGE = 4200, CG_CUTOFF = 260, CG_GRAVITY = 0.05, CG_DAMP = 0.74;
5651
+ const CG_ALPHA_DECAY = 0.021, CG_ALPHA_MIN = 0.004, CG_MAX_TICKS_PER_FRAME = 3, CG_SEPARATION = 7;
5652
+ const cg = {
5653
+ nodes: [], byId: new Map(), edges: [], meta: null,
5654
+ alpha: 0, raf: 0, seq: 0,
5655
+ zoom: 1, tx: 0, ty: 0,
5656
+ hover: null, selected: null, drag: null, pan: null, moved: false,
5657
+ groupColor: new Map(), groupCount: new Map()
5658
+ };
5659
+ const cgColor = (group) => cg.groupColor.get(group) || CG_UNGROUPED;
5660
+ /**
5661
+ * Group every node by the first directory segment that actually separates them: the paths
5662
+ * in a slice usually share a long prefix (a whole slice can sit under one package), and
5663
+ * grouping on the raw first segment would put every node in the same bucket.
5664
+ */
5665
+ function cgRegroup() {
5666
+ const paths = cg.nodes.map((n) => n.path || "");
5667
+ let prefix = "";
5668
+ if (paths.length > 1) {
5669
+ const parts = paths[0].split("/").slice(0, -1);
5670
+ let common = parts.length;
5671
+ for (const path of paths) {
5672
+ const p = path.split("/");
5673
+ let i = 0;
5674
+ while (i < common && i < p.length - 1 && p[i] === parts[i]) i++;
5675
+ common = i;
5676
+ if (!common) break;
5677
+ }
5678
+ prefix = parts.slice(0, common).join("/");
5679
+ }
5680
+ const counts = new Map();
5681
+ for (const n of cg.nodes) {
5682
+ const rest = prefix && n.path.startsWith(prefix + "/") ? n.path.slice(prefix.length + 1) : n.path;
5683
+ const cut = rest.indexOf("/");
5684
+ // Files sitting directly in the shared prefix have no segment of their own; "."
5685
+ // reads as a path fragment in the legend, where the label has to name a place.
5686
+ n.group = cut === -1 ? "(root)" : rest.slice(0, cut);
5687
+ counts.set(n.group, (counts.get(n.group) || 0) + 1);
5688
+ }
5689
+ // The biggest groups take the most distinct colours; everything past the palette
5690
+ // shares the neutral one rather than repeating a colour that already means something.
5691
+ cg.groupColor = new Map([...counts.entries()]
5692
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
5693
+ .slice(0, CG_GROUP_COLOR.length - 1)
5694
+ .map(([name], i) => [name, CG_GROUP_COLOR[i]]));
5695
+ cg.groupCount = counts;
5696
+ }
5697
+ // In WORLD units, so the separation pass in cgTick can guarantee the drawn circles never
5698
+ // overlap: a radius measured in screen pixels is a size the simulation cannot see, and at
5699
+ // any zoom below 1 the dots grow past the spacing the layout worked out for them.
5700
+ const cgRadius = (n) => 4 + Math.min(8, Math.sqrt(n.inDegree || 0) * 2.2);
5701
+ // Labels declutter by zoom: far out only the hubs are named, and every step in lowers the
5702
+ // bar. Without it a 300-node slice is a wall of overlapping text at every scale - but the
5703
+ // bar is set by how crowded the picture actually is, not by the zoom alone, or a slice of
5704
+ // fifteen nodes hides most of its own names for no reason.
5705
+ const cgLabelFloor = (z, count) => {
5706
+ if (count <= 40) return z >= 0.45 ? 0 : 4;
5707
+ if (count <= 120) return z >= 1 ? 0 : z >= 0.7 ? 3 : 9;
5708
+ return z >= 2.6 ? 0 : z >= 1.8 ? 2 : z >= 1.2 ? 5 : z >= 0.8 ? 10 : 22;
5709
+ };
5710
+
5711
+ function cgCanvasSize() {
5712
+ const c = $("cgCanvas");
5713
+ const dpr = Math.min(2, window.devicePixelRatio || 1);
5714
+ // Bail when unchanged: a ResizeObserver fires for subpixel and browser-zoom rounding
5715
+ // too, and resizing the canvas every time clears it and can feed back into a shake.
5716
+ const w = Math.round(c.clientWidth * dpr), h = Math.round(c.clientHeight * dpr);
5717
+ if (w > 0 && h > 0 && (c.width !== w || c.height !== h)) { c.width = w; c.height = h; }
5718
+ return { c, dpr, w: c.clientWidth, h: c.clientHeight };
5719
+ }
5720
+ const cgToWorld = (px, py, box) => ({ x: (px - box.w / 2 - cg.tx) / cg.zoom, y: (py - box.h / 2 - cg.ty) / cg.zoom });
5721
+
5722
+ function cgDraw() {
5723
+ const box = cgCanvasSize();
5724
+ const ctx = box.c.getContext("2d");
5725
+ ctx.setTransform(box.dpr, 0, 0, box.dpr, 0, 0);
5726
+ ctx.clearRect(0, 0, box.w, box.h);
5727
+ ctx.save();
5728
+ ctx.translate(box.w / 2 + cg.tx, box.h / 2 + cg.ty);
5729
+ ctx.scale(cg.zoom, cg.zoom);
5730
+ const focusId = (cg.selected && cg.selected.id) || (cg.hover && cg.hover.id) || null;
5731
+ const lit = new Set();
5732
+ if (focusId) for (const e of cg.edges) { if (e.source === focusId) lit.add(e.target); else if (e.target === focusId) lit.add(e.source); }
5733
+ ctx.lineWidth = 1 / cg.zoom;
5734
+ for (const e of cg.edges) {
5735
+ const s = e.s, t = e.t;
5736
+ if (!s || !t) continue;
5737
+ const on = focusId && (e.source === focusId || e.target === focusId);
5738
+ ctx.strokeStyle = on ? "rgba(224,164,88,0.85)" : (focusId ? "rgba(138,147,163,0.13)" : (CG_REL_COLOR[e.relation] || CG_REL_COLOR.references));
5739
+ ctx.beginPath();
5740
+ ctx.moveTo(s.x, s.y);
5741
+ ctx.lineTo(t.x, t.y);
5742
+ ctx.stroke();
5743
+ }
5744
+ const z = cg.zoom;
5745
+ const floor = cgLabelFloor(z, cg.nodes.length);
5746
+ const seeded = !!(cg.meta && cg.meta.focus && cg.meta.focus.length);
5747
+ const dimmed = (n) => focusId && n.id !== focusId && !lit.has(n.id);
5748
+ for (const n of cg.nodes) {
5749
+ const r = cgRadius(n);
5750
+ ctx.globalAlpha = dimmed(n) ? 0.28 : 1;
5751
+ ctx.beginPath();
5752
+ if (n.kind === "file") ctx.roundRect(n.x - r, n.y - r, r * 2, r * 2, r * 0.35);
5753
+ else ctx.arc(n.x, n.y, r, 0, 6.283185);
5754
+ ctx.fillStyle = cgColor(n.group);
5755
+ ctx.fill();
5756
+ if (n.id === focusId) { ctx.strokeStyle = "#eceef2"; ctx.lineWidth = 2 / z; ctx.stroke(); }
5757
+ else if (n.depth === 0 && seeded) { ctx.strokeStyle = "rgba(236,238,242,0.55)"; ctx.lineWidth = 1.5 / z; ctx.stroke(); }
5758
+ ctx.globalAlpha = 1;
5759
+ }
5760
+ // Labels in a second pass so a node drawn later cannot cover the caption of one drawn
5761
+ // earlier - the single most common way a readable graph turns into half-words.
5762
+ ctx.textBaseline = "middle";
5763
+ ctx.font = (11 / z) + "px ui-monospace, Menlo, Consolas, monospace";
5764
+ for (const n of cg.nodes) {
5765
+ if (dimmed(n) || (n.id !== focusId && (n.inDegree || 0) < floor)) continue;
5766
+ const r = cgRadius(n);
5767
+ ctx.textAlign = "left";
5768
+ const tw = ctx.measureText(n.name).width;
5769
+ ctx.fillStyle = "rgba(8,10,16,0.78)";
5770
+ ctx.fillRect(n.x + r + 3 / z, n.y - 7 / z, tw + 5 / z, 14 / z);
5771
+ ctx.fillStyle = n.id === focusId ? "#eceef2" : "#c3cad6";
5772
+ ctx.fillText(n.name, n.x + r + 5 / z, n.y);
5773
+ }
5774
+ // The "expand me" badge sits above both, or a neighbouring node hides the one cue that
5775
+ // says this node is standing in front of more of the graph.
5776
+ for (const n of cg.nodes) {
5777
+ if (!n.hidden || z < 1.1 || dimmed(n)) continue;
5778
+ const r = cgRadius(n);
5779
+ const bx = n.x + r * 0.78, by = n.y - r * 0.78;
5780
+ ctx.beginPath();
5781
+ ctx.arc(bx, by, 5.5 / z, 0, 6.283185);
5782
+ ctx.fillStyle = "rgba(8,10,16,0.9)";
5783
+ ctx.fill();
5784
+ ctx.fillStyle = "#8a93a3";
5785
+ ctx.font = (9 / z) + "px ui-monospace, Menlo, Consolas, monospace";
5786
+ ctx.textAlign = "center";
5787
+ ctx.fillText("+", bx, by);
5788
+ ctx.font = (11 / z) + "px ui-monospace, Menlo, Consolas, monospace";
5789
+ }
5790
+ ctx.restore();
5791
+ }
5792
+
5793
+ function cgTick() {
5794
+ const nodes = cg.nodes;
5795
+ if (!nodes.length) return;
5796
+ const a = cg.alpha;
5797
+ for (const e of cg.edges) {
5798
+ const s = e.s, t = e.t;
5799
+ if (!s || !t || s === t) continue;
5800
+ const dx = t.x - s.x, dy = t.y - s.y;
5801
+ const d = Math.sqrt(dx * dx + dy * dy) || 0.01;
5802
+ const f = ((d - CG_LINK_LEN) / d) * CG_LINK_K * a;
5803
+ s.vx += dx * f; s.vy += dy * f;
5804
+ t.vx -= dx * f; t.vy -= dy * f;
5805
+ }
5806
+ // Repulsion over a uniform grid, not every pair: at the 2000-node cap the all-pairs
5807
+ // version is four million distance checks a tick and the panel stops responding.
5808
+ const grid = new Map();
5809
+ for (const n of nodes) {
5810
+ const k = ((n.x / CG_CUTOFF) | 0) + ":" + ((n.y / CG_CUTOFF) | 0);
5811
+ const arr = grid.get(k);
5812
+ if (arr) arr.push(n); else grid.set(k, [n]);
5813
+ }
5814
+ for (const n of nodes) {
5815
+ const cx = (n.x / CG_CUTOFF) | 0, cy = (n.y / CG_CUTOFF) | 0;
5816
+ const rn = cgRadius(n);
5817
+ for (let gx = cx - 1; gx <= cx + 1; gx++) {
5818
+ for (let gy = cy - 1; gy <= cy + 1; gy++) {
5819
+ const arr = grid.get(gx + ":" + gy);
5820
+ if (!arr) continue;
5821
+ for (const o of arr) {
5822
+ if (o === n) continue;
5823
+ const dx = n.x - o.x, dy = n.y - o.y;
5824
+ const d2 = Math.max(1, dx * dx + dy * dy);
5825
+ if (d2 > CG_CUTOFF * CG_CUTOFF) continue;
5826
+ const d = Math.sqrt(d2);
5827
+ n.vx += (dx * CG_CHARGE * a) / (d2 * d);
5828
+ n.vy += (dy * CG_CHARGE * a) / (d2 * d);
5829
+ // Separation, applied to the POSITION and not the velocity: charge
5830
+ // alone loses to a node's own springs once it has a dozen neighbours,
5831
+ // and the result is the hairball where every hub sits under its
5832
+ // callers. This is the only thing that guarantees drawn circles do
5833
+ // not overlap, so it runs regardless of how cold the layout is.
5834
+ const minD = rn + cgRadius(o) + CG_SEPARATION;
5835
+ if (d >= minD) continue;
5836
+ const push = ((minD - d) / d) * 0.5;
5837
+ if (!n.pinned) { n.x += dx * push; n.y += dy * push; }
5838
+ if (!o.pinned) { o.x -= dx * push; o.y -= dy * push; }
5839
+ }
5840
+ }
5841
+ }
5842
+ }
5843
+ for (const n of nodes) {
5844
+ if (n.pinned) { n.vx = 0; n.vy = 0; continue; }
5845
+ n.vx -= n.x * CG_GRAVITY * a;
5846
+ n.vy -= n.y * CG_GRAVITY * a;
5847
+ n.vx *= CG_DAMP; n.vy *= CG_DAMP;
5848
+ // Clamped so a node that lands on top of another cannot be flung off the canvas
5849
+ // by the inverse-square term and take the layout's whole scale with it.
5850
+ n.x += Math.max(-40, Math.min(40, n.vx));
5851
+ n.y += Math.max(-40, Math.min(40, n.vy));
5852
+ }
5853
+ cg.alpha -= cg.alpha * CG_ALPHA_DECAY;
5854
+ if (cg.alpha < CG_ALPHA_MIN) cg.alpha = 0;
5855
+ }
5856
+
5857
+ function cgLoop() {
5858
+ cg.raf = 0;
5859
+ // A hidden tab must not keep a simulation running: the loop stops, and anything that
5860
+ // matters re-enters through cgKick when the tab comes back.
5861
+ if ($("view-codegraph").style.display === "none") { cg.alpha = 0; return; }
5862
+ for (let i = 0; i < CG_MAX_TICKS_PER_FRAME && cg.alpha > 0; i++) cgTick();
5863
+ cgDraw();
5864
+ if (cg.alpha > 0 || cg.drag) cg.raf = requestAnimationFrame(cgLoop);
5865
+ }
5866
+ function cgKick(alpha) {
5867
+ if (typeof alpha === "number") cg.alpha = Math.max(cg.alpha, alpha);
5868
+ if (!cg.raf) cg.raf = requestAnimationFrame(cgLoop);
5869
+ }
5870
+
5871
+ function cgFit() {
5872
+ if (!cg.nodes.length) return;
5873
+ const box = cgCanvasSize();
5874
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
5875
+ for (const n of cg.nodes) { x0 = Math.min(x0, n.x); y0 = Math.min(y0, n.y); x1 = Math.max(x1, n.x); y1 = Math.max(y1, n.y); }
5876
+ const bw = Math.max(1, x1 - x0), bh = Math.max(1, y1 - y0);
5877
+ cg.zoom = Math.max(0.12, Math.min(1.6, Math.min((box.w - 90) / bw, (box.h - 70) / bh)));
5878
+ cg.tx = -((x0 + x1) / 2) * cg.zoom;
5879
+ cg.ty = -((y0 + y1) / 2) * cg.zoom;
5880
+ cgKick(0);
5881
+ }
5882
+ function cgZoomBy(factor, at) {
5883
+ const box = cgCanvasSize();
5884
+ const next = Math.max(0.08, Math.min(8, cg.zoom * factor));
5885
+ const px = at ? at.x : box.w / 2, py = at ? at.y : box.h / 2;
5886
+ // Anchor the point under the cursor: without it wheel-zoom drifts the graph away from
5887
+ // whatever the user was pointing at.
5888
+ const w = cgToWorld(px, py, box);
5889
+ cg.zoom = next;
5890
+ cg.tx = px - box.w / 2 - w.x * next;
5891
+ cg.ty = py - box.h / 2 - w.y * next;
5892
+ cgKick(0);
5893
+ }
5894
+
5895
+ function cgNodeAt(px, py) {
5896
+ const box = cgCanvasSize();
5897
+ const w = cgToWorld(px, py, box);
5898
+ let best = null, bestD = Infinity;
5899
+ for (const n of cg.nodes) {
5900
+ const r = cgRadius(n) + 4 / cg.zoom;
5901
+ const d = (n.x - w.x) * (n.x - w.x) + (n.y - w.y) * (n.y - w.y);
5902
+ if (d <= r * r && d < bestD) { best = n; bestD = d; }
5903
+ }
5904
+ return best;
5905
+ }
5906
+
5907
+ function cgRenderLegend() {
5908
+ const rest = cg.groupCount.size - cg.groupColor.size;
5909
+ $("cgLegend").innerHTML = [...cg.groupColor.keys()]
5910
+ .map((g) => '<span><i style="background:' + cgColor(g) + '"></i>' + esc(g) + "</span>").join("")
5911
+ + (rest > 0 ? '<span><i style="background:' + CG_UNGROUPED + '"></i>' + rest + " more</span>" : "");
5912
+ }
5913
+ function cgRenderDetail() {
5914
+ const el = $("cgDetail");
5915
+ const n = cg.selected;
5916
+ if (!n) { el.hidden = true; el.innerHTML = ""; return; }
5917
+ const where = n.kind === "file" ? n.path : n.path + ":" + n.line;
5918
+ el.hidden = false;
5919
+ el.innerHTML = '<div class="cg-head"><h4>' + esc(n.name) + "</h4>"
5920
+ + '<button type="button" class="iconbtn" data-cg-close title="Close" aria-label="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>'
5921
+ + '<div class="cg-where">' + esc(where) + "</div>"
5922
+ + '<div class="set-hint" style="padding:4px 0 0">' + esc(n.kind) + " - " + n.inDegree + " file" + (n.inDegree === 1 ? "" : "s") + " depend on it"
5923
+ + (n.hidden > 0 ? " - " + n.hidden + " neighbour" + (n.hidden === 1 ? "" : "s") + " not shown" : "") + "</div>"
5924
+ + (n.signature ? '<pre class="cg-sig">' + esc(n.signature.slice(0, 400)) + "</pre>" : "")
5925
+ + '<div class="cg-acts">'
5926
+ + '<button type="button" class="toggle on" data-cg-focus="' + esc(n.id) + '">Centre here</button>'
5927
+ + (n.hidden > 0 ? '<button type="button" class="toggle" data-cg-expand="' + esc(n.id) + '">Expand</button>' : "")
5928
+ + '<button type="button" class="iconbtn" data-cg-copy="' + esc(where) + '" title="Copy location" aria-label="Copy location"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>'
5929
+ + "</div>";
5930
+ }
5931
+ function cgRenderMeta() {
5932
+ const m = cg.meta;
5933
+ if (!m) { $("cgGraphMeta").textContent = ""; return; }
5934
+ // A real but tiny share rounds to "0%", which reads as "nothing" - the one thing it
5935
+ // is not - so it is reported as "<1%" instead. Measured against the population this
5936
+ // scope draws from: the file view carries import edges only.
5937
+ const totals = m.totals || {};
5938
+ const total = (m.scope === "files" ? totals.importEdges : totals.edges) || 0;
5939
+ const pct = total && cg.edges.length ? (cg.edges.length / total) * 100 : 0;
5940
+ const share = !pct ? "0%" : pct < 1 ? "<1%" : Math.round(pct) + "%";
5941
+ const bits = [cg.nodes.length + " nodes", cg.edges.length + " edges", share + " of the graph's wiring"];
5942
+ if (m.focus && m.focus.length) bits.push("centred on " + m.focus.map((f) => f.name).join(", "));
5943
+ // Both caps are stated: one hid part of the picture, the other means part of the tree
5944
+ // was never indexed - a drawn graph reads as complete unless it says otherwise.
5945
+ if (m.truncated) bits.push("truncated at the node cap");
5946
+ if (m.incomplete) bits.push("index incomplete: the scan hit a limit");
5947
+ $("cgGraphMeta").textContent = bits.join(" - ");
5948
+ $("cgCanvas").setAttribute("aria-label",
5949
+ "Code graph: " + cg.nodes.length + " nodes and " + cg.edges.length + " edges"
5950
+ + (m.focus && m.focus.length ? " around " + m.focus.map((f) => f.name).join(", ") : ""));
5951
+ }
5952
+ function cgOverlay(msg) {
5953
+ const el = $("cgGraphOverlay");
5954
+ if (!msg) { el.hidden = true; return; }
5955
+ el.hidden = false;
5956
+ el.textContent = msg;
5957
+ }
5958
+
5959
+ /** Seed a node deterministically: phyllotaxis, or beside the node it was expanded from. */
5960
+ function cgPlace(node, index, near) {
5961
+ const th = index * 2.399963;
5962
+ if (near) {
5963
+ node.x = near.x + Math.cos(th) * (30 + index * 1.5);
5964
+ node.y = near.y + Math.sin(th) * (30 + index * 1.5);
5965
+ } else {
5966
+ const r = 12 * Math.sqrt(index + 1);
5967
+ node.x = Math.cos(th) * r;
5968
+ node.y = Math.sin(th) * r;
5969
+ }
5970
+ node.vx = 0;
5971
+ node.vy = 0;
5972
+ }
5973
+
5974
+ /**
5975
+ * Recount, per node, the neighbours NOT on screen. The server's `hidden` is relative to
5976
+ * the slice it came from, so after a merge it would claim neighbours already drawn beside
5977
+ * it - a "+" badge and an Expand button that add nothing. `neighbours` is the whole-graph
5978
+ * total, so the honest count is that minus what the merged picture actually draws.
5979
+ *
5980
+ * Same asymmetry the engine counts by: expanding a node re-seeds the symbols a file
5981
+ * contains, but a symbol's own file is never reachable across a `contains` edge.
5982
+ */
5983
+ function cgRecount() {
5984
+ const drawn = new Map();
5985
+ const bump = (id) => drawn.set(id, (drawn.get(id) || 0) + 1);
5986
+ for (const e of cg.edges) {
5987
+ if (cg.byId.has(e.source)) bump(e.source);
5988
+ if (cg.byId.has(e.target) && e.relation !== "contains") bump(e.target);
5989
+ }
5990
+ for (const n of cg.nodes) n.hidden = Math.max(0, (n.neighbours || 0) - (drawn.get(n.id) || 0));
5991
+ }
5992
+
5993
+ function cgApply(slice, merge, near) {
5994
+ const before = cg.nodes.length;
5995
+ if (!merge) { cg.nodes = []; cg.byId = new Map(); cg.edges = []; cg.selected = null; cg.hover = null; }
5996
+ let added = 0;
5997
+ for (const raw of slice.nodes) {
5998
+ const seen = cg.byId.get(raw.id);
5999
+ // Keep the live object on a merge: replacing it would reset its position, and every
6000
+ // expand would look like the whole layout jumped.
6001
+ if (seen) { seen.inDegree = raw.inDegree; seen.neighbours = raw.neighbours; continue; }
6002
+ const n = Object.assign({}, raw);
6003
+ cgPlace(n, merge ? added : cg.nodes.length, near);
6004
+ cg.byId.set(n.id, n);
6005
+ cg.nodes.push(n);
6006
+ added++;
6007
+ }
6008
+ const key = (e) => e.source + "" + e.target + "" + e.relation;
6009
+ const have = new Set(cg.edges.map(key));
6010
+ for (const e of slice.edges) {
6011
+ if (have.has(key(e))) continue;
6012
+ have.add(key(e));
6013
+ cg.edges.push({ source: e.source, target: e.target, relation: e.relation });
6014
+ }
6015
+ for (const e of cg.edges) { e.s = cg.byId.get(e.source); e.t = cg.byId.get(e.target); }
6016
+ cgRecount();
6017
+ // An expand widens the picture; it does not re-answer what the picture is OF. Taking
6018
+ // the sub-slice's meta would relabel the whole canvas with the expanded node's focus
6019
+ // and report its cap instead of the one the drawn graph was cut at.
6020
+ cg.meta = merge && cg.meta
6021
+ ? Object.assign({}, cg.meta, { truncated: cg.meta.truncated || slice.truncated })
6022
+ : slice;
6023
+ cgRegroup();
6024
+ cgRenderLegend();
6025
+ cgRenderMeta();
6026
+ cgRenderDetail();
6027
+ cgKick(merge ? 0.55 : 1);
6028
+ if (!merge || before === 0) setTimeout(cgFit, 420);
6029
+ return added;
6030
+ }
6031
+
6032
+ async function cgFetchGraph(payload) {
6033
+ try {
6034
+ const res = await fetch("/api/codegraph", {
6035
+ method: "POST",
6036
+ headers: { "Content-Type": "application/json" },
6037
+ body: JSON.stringify(Object.assign({ op: "graph", project: cgProject || undefined }, payload))
6038
+ });
6039
+ return await res.json();
6040
+ } catch { return { ok: false, error: "Could not reach the server." }; }
6041
+ }
6042
+
6043
+ async function cgDrawGraph(opts) {
6044
+ const o = opts || {};
6045
+ const seq = ++cg.seq;
6046
+ if (!o.merge) cgOverlay("Drawing...");
6047
+ const out = await cgFetchGraph({
6048
+ focus: o.focus || undefined,
6049
+ depth: Number($("cgDepth").value) || 1,
6050
+ scope: $("cgScope").value,
6051
+ // Deliberately below the engine's cap: past roughly this many nodes an overview
6052
+ // stops being a picture you can read and becomes one you can only zoom into, and
6053
+ // the meta line says when the cap cut something off.
6054
+ limit: 200
6055
+ });
6056
+ // A slower earlier request must not overwrite a newer answer: clicking through three
6057
+ // nodes quickly would otherwise leave whichever call happened to finish last.
6058
+ if (seq !== cg.seq) return;
6059
+ // The legend goes with the nodes: leaving the previous slice's colour key over an
6060
+ // empty canvas describes a picture that is no longer on it.
6061
+ const reset = () => {
6062
+ cg.nodes = []; cg.edges = []; cg.byId = new Map(); cg.selected = null;
6063
+ cgRegroup(); cgRenderLegend(); cgRenderDetail(); cgDraw();
6064
+ };
6065
+ // The overlay covers the whole canvas, so it belongs only to a draw that has nothing
6066
+ // to show. An expand that failed leaves a perfectly good graph on screen: veiling it
6067
+ // would swallow every click on it until the next Draw, so that one reports as a toast.
6068
+ if (!out || !out.ok || !out.graph) {
6069
+ const why = (out && out.error) || "Could not draw the graph.";
6070
+ if (o.merge) { toast.error("Could not expand that node", { description: why }); return; }
6071
+ reset();
6072
+ cg.meta = null;
6073
+ cgRenderMeta();
6074
+ cgOverlay(why);
6075
+ return;
6076
+ }
6077
+ const slice = out.graph;
6078
+ if (!slice.nodes.length) {
6079
+ const why = slice.note || "Nothing to draw for this project yet.";
6080
+ if (o.merge) { toast.info("Nothing to add", { description: why }); return; }
6081
+ reset();
6082
+ cg.meta = slice;
6083
+ cgRenderMeta();
6084
+ cgOverlay(why);
6085
+ return;
6086
+ }
6087
+ cgOverlay("");
6088
+ const added = cgApply(slice, !!o.merge, o.merge ? cg.byId.get(o.focus) : null);
6089
+ if (o.merge && !added) toast.info("Nothing to add", { description: "Every neighbour of that node is already drawn." });
6090
+ }
6091
+
6092
+ function cgWireCanvas() {
6093
+ const c = $("cgCanvas");
6094
+ const local = (e) => { const r = c.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; };
6095
+ c.addEventListener("pointerdown", (e) => {
6096
+ const p = local(e);
6097
+ const hit = cgNodeAt(p.x, p.y);
6098
+ cg.moved = false;
6099
+ c.setPointerCapture(e.pointerId);
6100
+ if (hit) { cg.drag = { node: hit, wasPinned: hit.pinned }; hit.pinned = true; }
6101
+ else { cg.pan = { x: p.x - cg.tx, y: p.y - cg.ty }; c.classList.add("grabbing"); }
6102
+ });
6103
+ c.addEventListener("pointermove", (e) => {
6104
+ const p = local(e);
6105
+ if (cg.drag) {
6106
+ const w = cgToWorld(p.x, p.y, cgCanvasSize());
6107
+ cg.drag.node.x = w.x;
6108
+ cg.drag.node.y = w.y;
6109
+ cg.moved = true;
6110
+ cgKick(0.28);
6111
+ return;
6112
+ }
6113
+ if (cg.pan) { cg.tx = p.x - cg.pan.x; cg.ty = p.y - cg.pan.y; cg.moved = true; cgKick(0); return; }
6114
+ const hit = cgNodeAt(p.x, p.y);
6115
+ if (hit !== cg.hover) { cg.hover = hit; c.style.cursor = hit ? "pointer" : ""; cgKick(0); }
6116
+ });
6117
+ const release = (e) => {
6118
+ if (cg.drag) { if (!cg.moved) cg.drag.node.pinned = cg.drag.wasPinned; cg.drag = null; }
6119
+ if (cg.pan) { cg.pan = null; c.classList.remove("grabbing"); }
6120
+ if (!cg.moved && e) {
6121
+ const p = local(e);
6122
+ cg.selected = cgNodeAt(p.x, p.y) || null;
6123
+ cgRenderDetail();
6124
+ }
6125
+ cgKick(0);
6126
+ };
6127
+ c.addEventListener("pointerup", release);
6128
+ c.addEventListener("pointercancel", () => release(null));
6129
+ c.addEventListener("dblclick", (e) => {
6130
+ const p = local(e);
6131
+ const hit = cgNodeAt(p.x, p.y);
6132
+ if (hit) cgDrawGraph({ focus: hit.id, merge: true });
6133
+ });
6134
+ c.addEventListener("wheel", (e) => {
6135
+ e.preventDefault();
6136
+ cgZoomBy(e.deltaY < 0 ? 1.14 : 1 / 1.14, local(e));
6137
+ }, { passive: false });
6138
+ // The canvas is focusable, so the same picture is reachable without a pointer.
6139
+ c.addEventListener("keydown", (e) => {
6140
+ if (e.key === "Escape") { cg.selected = null; cgRenderDetail(); cgKick(0); }
6141
+ else if (e.key === "+" || e.key === "=") cgZoomBy(1.2);
6142
+ else if (e.key === "-") cgZoomBy(1 / 1.2);
6143
+ else if (e.key === "0") cgFit();
6144
+ else return;
6145
+ e.preventDefault();
6146
+ });
6147
+ new ResizeObserver(() => cgKick(0)).observe($("cgWrap"));
6148
+ const redraw = () => cgDrawGraph({ focus: $("cgFocus").value.trim() });
6149
+ $("cgZoomIn").addEventListener("click", () => cgZoomBy(1.25));
6150
+ $("cgZoomOut").addEventListener("click", () => cgZoomBy(1 / 1.25));
6151
+ $("cgFit").addEventListener("click", cgFit);
6152
+ $("cgDraw").addEventListener("click", redraw);
6153
+ $("cgFocus").addEventListener("keydown", (e) => { if (e.key === "Enter") redraw(); });
6154
+ $("cgScope").addEventListener("change", redraw);
6155
+ $("cgDepth").addEventListener("change", redraw);
6156
+ $("cgDetail").addEventListener("click", (e) => {
6157
+ const close = e.target.closest("[data-cg-close]");
6158
+ if (close) { cg.selected = null; cgRenderDetail(); cgKick(0); return; }
6159
+ const focus = e.target.closest("[data-cg-focus]");
6160
+ if (focus) { $("cgFocus").value = focus.dataset.cgFocus; cgDrawGraph({ focus: focus.dataset.cgFocus }); return; }
6161
+ const expand = e.target.closest("[data-cg-expand]");
6162
+ if (expand) { cgDrawGraph({ focus: expand.dataset.cgExpand, merge: true }); return; }
6163
+ const copy = e.target.closest("[data-cg-copy]");
6164
+ if (copy) copyToClipboard(copy.dataset.cgCopy, copy);
6165
+ });
6166
+ }
6167
+
5573
6168
  function renderCodeGraph(view) {
5574
6169
  view = view || {};
5575
6170
  const on = !!view.enabled;
5576
- const t = $("cgToggle");
5577
- t.classList.toggle("on", on); t.setAttribute("aria-checked", on ? "true" : "false"); t.textContent = on ? "On" : "Off";
6171
+ paintSwitch($("cgToggle"), on, "On", "Off");
5578
6172
  const projects = view.projects || [];
5579
6173
  const projHtml = projects.length
5580
6174
  ? projects.map((p) => {
@@ -5610,7 +6204,12 @@ export {
5610
6204
  $("cgNote").textContent = "";
5611
6205
  try {
5612
6206
  const q = cgProject ? "?project=" + encodeURIComponent(cgProject) : "";
5613
- renderCodeGraph(await (await fetch("/api/codegraph" + q, { cache: "no-store" })).json());
6207
+ const view = await (await fetch("/api/codegraph" + q, { cache: "no-store" })).json();
6208
+ renderCodeGraph(view);
6209
+ // The picture is a second request on purpose: it is the expensive half, and the
6210
+ // panel around it (projects, freshness, architecture) must not wait on it.
6211
+ if (view && (view.projects || []).length) cgDrawGraph({ focus: $("cgFocus").value.trim() });
6212
+ else cgOverlay("Index a project to draw its graph.");
5614
6213
  } catch { $("cgBody").innerHTML = '<div class="empty" style="padding:12px 0">Code graph unavailable.</div>'; }
5615
6214
  }
5616
6215
  async function cgPost(payload, working) {
@@ -5622,14 +6221,27 @@ export {
5622
6221
  } catch { setNote("cgNote", "Could not reach the server.", "err"); return null; }
5623
6222
  }
5624
6223
  async function cgToggleAction() {
5625
- const turnOn = $("cgToggle").getAttribute("aria-checked") !== "true";
5626
- const out = await cgPost({ op: "toggle", on: turnOn, project: cgProject || undefined }, "Saving...");
5627
- if (out) setNote("cgNote", out.ok ? (turnOn ? "Code graph enabled - tools exposed to your agents (restart them to load it)." : "Code graph disabled.") : (out.error || "Action failed."), out.ok ? "ok" : "err");
6224
+ const btn = $("cgToggle");
6225
+ const was = btn.getAttribute("aria-checked") === "true";
6226
+ paintSwitch(btn, !was, "On", "Off");
6227
+ const out = await cgPost({ op: "toggle", on: !was }, was ? "Disabling..." : "Enabling...");
6228
+ if (!out || !out.ok) {
6229
+ paintSwitch(btn, was, "On", "Off");
6230
+ if (out) setNote("cgNote", out.error || "Action failed.", "err"); // cgPost already reported an unreachable server
6231
+ return;
6232
+ }
6233
+ paintSwitch(btn, !!out.enabled, "On", "Off"); // the server's answer wins over the optimistic paint
6234
+ setNote("cgNote", out.enabled
6235
+ ? "Code graph enabled - tools and session hooks wired; agents pick it up on their next session."
6236
+ : "Code graph disabled - tools and session hooks removed.", "ok");
5628
6237
  }
5629
6238
  async function cgIndexAction() {
5630
6239
  const root = $("cgRoot").value.trim();
5631
6240
  const out = await cgPost({ op: "index", root: root || undefined }, "Indexing...");
5632
- if (out) { if (out.view && out.view.selected) cgProject = out.view.selected; setNote("cgNote", out.ok ? "Indexed." : (out.error || "Indexing failed."), out.ok ? "ok" : "err"); }
6241
+ if (!out) return;
6242
+ if (out.view && out.view.selected) cgProject = out.view.selected;
6243
+ setNote("cgNote", out.ok ? "Indexed." : (out.error || "Indexing failed."), out.ok ? "ok" : "err");
6244
+ if (out.ok) cgDrawGraph({ focus: $("cgFocus").value.trim() });
5633
6245
  }
5634
6246
  async function cgAskAction() {
5635
6247
  const query = $("cgQuery").value.trim();
@@ -5638,6 +6250,7 @@ export {
5638
6250
  if (out) setNote("cgNote", out.ok ? "" : (out.error || "Query failed."), out.ok ? "ok" : "err");
5639
6251
  }
5640
6252
  function wireCodeGraph() {
6253
+ cgWireCanvas();
5641
6254
  $("cgRefresh").addEventListener("click", loadCodeGraph);
5642
6255
  $("cgToggle").addEventListener("click", cgToggleAction);
5643
6256
  $("cgIndex").addEventListener("click", cgIndexAction);
@@ -5647,12 +6260,17 @@ export {
5647
6260
  const reindex = e.target.closest("button[data-cg-reindex]");
5648
6261
  if (reindex) {
5649
6262
  cgPost({ op: "index", root: reindex.dataset.cgReindex }, "Re-indexing...")
5650
- .then((out) => { if (out) setNote("cgNote", out.ok ? "Re-indexed." : (out.error || "Indexing failed."), out.ok ? "ok" : "err"); });
6263
+ .then((out) => {
6264
+ if (!out) return;
6265
+ setNote("cgNote", out.ok ? "Re-indexed." : (out.error || "Indexing failed."), out.ok ? "ok" : "err");
6266
+ if (out.ok) cgDrawGraph({ focus: $("cgFocus").value.trim() });
6267
+ });
5651
6268
  return;
5652
6269
  }
5653
6270
  const b = e.target.closest("button[data-cg-project]");
5654
6271
  if (!b) return;
5655
6272
  cgProject = b.dataset.cgProject;
6273
+ $("cgFocus").value = "";
5656
6274
  loadCodeGraph();
5657
6275
  });
5658
6276
  }
@@ -5766,7 +6384,7 @@ export {
5766
6384
  $("pgSend").addEventListener("click", pgSend);
5767
6385
  $("pgSaveDefault").addEventListener("click", pgSaveDefault);
5768
6386
  const tog = $("pgTools");
5769
- tog.addEventListener("click", () => { const on = tog.getAttribute("aria-checked") !== "true"; tog.classList.toggle("on", on); tog.setAttribute("aria-checked", on ? "true" : "false"); tog.textContent = on ? "On" : "Off"; });
6387
+ tog.addEventListener("click", () => paintSwitch(tog, tog.getAttribute("aria-checked") !== "true", "On", "Off"));
5770
6388
  $("pgMode").addEventListener("change", pgSyncMode);
5771
6389
  const copyBtn = $("pgCopyCurl");
5772
6390
  // The button is an icon, so the toast is not the only confirmation: swap the icon for a
@@ -5859,11 +6477,7 @@ export {
5859
6477
  $("recallKey").disabled = !!p.keyFromEnv;
5860
6478
  // claude-local needs no base/key; hide that row for it.
5861
6479
  $("recallKeyRow").style.display = p.provider === "claude-local" ? "none" : "";
5862
- const llmBtn = $("recallLlmBtn");
5863
- llmBtn.textContent = p.llm ? "on" : "off";
5864
- llmBtn.classList.toggle("on", !!p.llm);
5865
- llmBtn.dataset.on = p.llm ? "1" : "0";
5866
- llmBtn.setAttribute("aria-checked", p.llm ? "true" : "false");
6480
+ paintSwitch($("recallLlmBtn"), p.llm, "on", "off");
5867
6481
  }
5868
6482
  let recallItemsById = {};
5869
6483
  // Multi-select state for the Recall list: a Set of selected ids, the ordered ids as
@@ -5958,14 +6572,25 @@ export {
5958
6572
  setNote("recallNote", out.ok ? (op === "sync" ? "Synced." : "Cleared.") : (out.error || "Action failed."), out.ok ? "ok" : "err");
5959
6573
  } catch { setNote("recallNote", "Could not reach the server.", "err"); }
5960
6574
  }
5961
- async function setRecallEnabled(on) {
6575
+ // The recall switch carries its state in its own label ("Enable recall" when off, "Disable"
6576
+ // when on), in its title and in what the next click asks for - so an optimistic paint has to
6577
+ // move all three. Painting the switch alone left a button reading "Enable recall" while
6578
+ // already on, offering to turn on what it had just turned on, until loadRecall() re-rendered.
6579
+ function paintRecallSwitch(btn, on) {
6580
+ paintSwitch(btn, on, "Disable", "Enable recall");
6581
+ if (!btn) return;
6582
+ btn.dataset.recallOn = on ? "0" : "1";
6583
+ btn.title = on ? "Turn session memory off" : "Turn session memory on";
6584
+ }
6585
+ async function setRecallEnabled(on, btn) {
6586
+ paintRecallSwitch(btn, on);
5962
6587
  setNote("recallNote", on ? "Enabling recall..." : "Disabling recall...");
5963
6588
  try {
5964
6589
  const out = await (await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: "recall", value: on }) })).json();
5965
- if (!out.ok) { setNote("recallNote", "Could not change recall: " + (out.error || "error"), "err"); return; }
6590
+ if (!out.ok) { paintRecallSwitch(btn, !on); setNote("recallNote", "Could not change recall: " + (out.error || "error"), "err"); return; }
5966
6591
  setNote("recallNote", out.restartNote || (on ? "Recall enabled." : "Recall disabled."), out.restartNote ? "warn" : "ok");
5967
6592
  loadRecall();
5968
- } catch { setNote("recallNote", "Could not reach the server.", "err"); }
6593
+ } catch { paintRecallSwitch(btn, !on); setNote("recallNote", "Could not reach the server.", "err"); }
5969
6594
  }
5970
6595
  async function recallProviderPost(extra, msg) {
5971
6596
  setNote("recallNote", msg || "Saving provider...");
@@ -5990,7 +6615,7 @@ export {
5990
6615
  $("recallClear").addEventListener("click", () => recallAction("clear", "Delete all stored session memory? This cannot be undone."));
5991
6616
  $("recallBody").addEventListener("click", (e) => {
5992
6617
  const t = e.target.closest("[data-recall-on]");
5993
- if (t) setRecallEnabled(t.dataset.recallOn === "1");
6618
+ if (t) setRecallEnabled(t.dataset.recallOn === "1", t);
5994
6619
  });
5995
6620
  // Provider panel: save provider; flip LLM curation. The provider picker is the
5996
6621
  // custom icon dropdown (recallProvVal), wired in renderRecallProvider.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enigmax/dashboard",
3
- "version": "0.1.108",
3
+ "version": "0.1.110",
4
4
  "description": "Local browser dashboard UI for enigma: the static page and chart assets enigma serves on its loopback dashboard (savings, real tool usage, in-browser settings). Installed on demand by enigma-cli; not a runtime dependency.",
5
5
  "type": "module",
6
6
  "files": [