clawmatrix 0.1.15 → 0.1.17

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/src/web-ui.ts CHANGED
@@ -9,6 +9,7 @@ export function renderDashboard(nodeId: string): string {
9
9
  <style>
10
10
  ${CSS}
11
11
  </style>
12
+ <script src="https://cdn.jsdelivr.net/npm/force-graph@1/dist/force-graph.min.js"></script>
12
13
  </head>
13
14
  <body>
14
15
 
@@ -72,26 +73,32 @@ ${CSS}
72
73
 
73
74
  <!-- Main content -->
74
75
  <div class="main">
75
- <!-- Left: Mesh + Details -->
76
+ <!-- Left: Mesh -->
76
77
  <div class="panel-left">
77
78
  <div class="card mesh-card">
78
79
  <div class="card-header">
79
80
  <h2>Mesh Topology</h2>
80
81
  <span id="peer-count" class="badge">0 nodes</span>
81
82
  </div>
82
- <canvas id="mesh-canvas"></canvas>
83
- </div>
84
- <div id="node-detail" class="card detail-card hidden">
85
- <div class="card-header">
86
- <h2 id="detail-title">Node Details</h2>
87
- <span id="detail-status" class="badge"></span>
88
- </div>
89
- <div id="detail-body" class="detail-body"></div>
83
+ <div id="mesh-container"></div>
90
84
  </div>
91
85
  </div>
92
86
 
93
- <!-- Right: Chat -->
87
+ <!-- Right: Detail + Chat -->
94
88
  <div class="panel-right">
89
+ <div id="node-detail" class="detail-panel hidden">
90
+ <div class="detail-header">
91
+ <div class="detail-header-left">
92
+ <span id="detail-status-dot" class="detail-dot"></span>
93
+ <span id="detail-title" class="detail-title-text"></span>
94
+ <span id="detail-status" class="badge"></span>
95
+ </div>
96
+ <button id="detail-close" class="btn-icon" title="Close">
97
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
98
+ </button>
99
+ </div>
100
+ <div id="detail-body" class="detail-body"></div>
101
+ </div>
95
102
  <div class="card chat-card">
96
103
  <div class="card-header">
97
104
  <h2 id="chat-title">Chat</h2>
@@ -313,10 +320,7 @@ body {
313
320
  flex: 1;
314
321
  display: flex;
315
322
  flex-direction: column;
316
- padding: 16px;
317
- gap: 16px;
318
323
  min-width: 0;
319
- overflow-y: auto;
320
324
  }
321
325
 
322
326
  .panel-right {
@@ -368,23 +372,83 @@ body {
368
372
  flex: 1;
369
373
  display: flex;
370
374
  flex-direction: column;
371
- min-height: 300px;
375
+ border-radius: 0;
376
+ border: none;
372
377
  }
373
378
 
374
- #mesh-canvas {
379
+ #mesh-container {
375
380
  flex: 1;
376
381
  width: 100%;
377
- cursor: default;
382
+ overflow: hidden;
383
+ }
384
+ #mesh-container canvas {
385
+ width: 100% !important;
386
+ height: 100% !important;
387
+ }
388
+
389
+ /* Node detail (right panel) */
390
+ .detail-panel {
391
+ flex: 1 1 50%;
392
+ min-height: 0;
393
+ overflow-y: auto;
394
+ border-bottom: 1px solid var(--border);
395
+ background: var(--bg-card);
396
+ animation: slideDown 0.2s ease-out;
397
+ }
398
+
399
+ @keyframes slideDown {
400
+ from { opacity: 0; }
401
+ to { opacity: 1; }
402
+ }
403
+
404
+ .detail-header {
405
+ display: flex;
406
+ align-items: center;
407
+ justify-content: space-between;
408
+ padding: 10px 16px;
409
+ border-bottom: 1px solid var(--border-subtle);
410
+ }
411
+
412
+ .detail-header-left {
413
+ display: flex;
414
+ align-items: center;
415
+ gap: 8px;
416
+ min-width: 0;
417
+ }
418
+
419
+ .detail-dot {
420
+ width: 8px;
421
+ height: 8px;
422
+ border-radius: 50%;
423
+ flex-shrink: 0;
424
+ }
425
+
426
+ .detail-title-text {
427
+ font-size: 13px;
428
+ font-weight: 600;
429
+ color: var(--text);
430
+ white-space: nowrap;
431
+ overflow: hidden;
432
+ text-overflow: ellipsis;
378
433
  }
379
434
 
380
- /* Node detail */
381
- .detail-card { flex-shrink: 0; }
435
+ .btn-icon {
436
+ background: transparent;
437
+ border: none;
438
+ color: var(--text-dim);
439
+ cursor: pointer;
440
+ padding: 4px;
441
+ border-radius: 4px;
442
+ display: flex;
443
+ align-items: center;
444
+ transition: color 0.15s, background 0.15s;
445
+ }
446
+ .btn-icon:hover { color: var(--text); background: rgba(255,255,255,0.05); }
382
447
 
383
448
  .detail-body {
384
- padding: 16px 18px;
449
+ padding: 12px 16px;
385
450
  font-size: 13px;
386
451
  line-height: 1.7;
387
- max-height: 320px;
388
452
  overflow-y: auto;
389
453
  }
390
454
 
@@ -473,7 +537,8 @@ body {
473
537
  .chat-card {
474
538
  display: flex;
475
539
  flex-direction: column;
476
- height: 100%;
540
+ flex: 1 1 50%;
541
+ min-height: 0;
477
542
  border-radius: 0;
478
543
  border: none;
479
544
  border-top: none;
@@ -627,15 +692,11 @@ const JS = `
627
692
  let selectedNode = null;
628
693
  let chatMessages = [];
629
694
  let chatStreaming = false;
630
- let meshNodes = [];
631
- let meshEdges = [];
632
695
  let chatMode = 'model'; // 'model' | 'handoff'
633
696
  let handoffNodeId = null;
634
697
  let hoveredNode = null;
635
- let dragNode = null;
636
- let dragOffset = { x: 0, y: 0 };
637
- let animFrame = null;
638
698
  let pollTimer = null;
699
+ let graph = null;
639
700
 
640
701
  // ── DOM refs ──
641
702
  const $ = (id) => document.getElementById(id);
@@ -644,8 +705,6 @@ const JS = `
644
705
  const loginForm = $('login-form');
645
706
  const loginToken = $('login-token');
646
707
  const loginError = $('login-error');
647
- const canvas = $('mesh-canvas');
648
- const ctx = canvas.getContext('2d');
649
708
 
650
709
  // ── Auth ──
651
710
  loginForm.addEventListener('submit', async (e) => {
@@ -698,13 +757,10 @@ const JS = `
698
757
  initMesh();
699
758
  pollStatus();
700
759
  pollTimer = setInterval(pollStatus, 3000);
701
- requestAnimationFrame(renderLoop);
702
760
  }
703
761
 
704
762
  function stopDashboard() {
705
763
  if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
706
- if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null; }
707
- window.removeEventListener('resize', resizeCanvas);
708
764
  }
709
765
 
710
766
  async function pollStatus() {
@@ -745,360 +801,372 @@ const JS = `
745
801
  return h + 'h ' + m + 'm';
746
802
  }
747
803
 
748
- // ── Mesh visualization ──
749
- const DPR = window.devicePixelRatio || 1;
750
- let W = 0, H = 0;
804
+ // ── Mesh visualization (force-graph) ──
805
+ const NODE_COLORS = {
806
+ self: '#818cf8',
807
+ direct: '#34d399',
808
+ relay: '#fbbf24',
809
+ satellite: '#f472b6',
810
+ offline: '#555d75',
811
+ };
812
+
813
+ const GLOW_COLORS = {
814
+ self: [129, 140, 248],
815
+ direct: [52, 211, 153],
816
+ relay: [251, 191, 36],
817
+ satellite: [244, 114, 182],
818
+ };
819
+
820
+ let frameCount = 0;
751
821
 
752
822
  function initMesh() {
753
- resizeCanvas();
754
- window.addEventListener('resize', resizeCanvas);
755
- canvas.addEventListener('mousedown', onCanvasMouseDown);
756
- canvas.addEventListener('mousemove', onCanvasMouseMove);
757
- canvas.addEventListener('mouseup', onCanvasMouseUp);
758
- canvas.addEventListener('mouseleave', () => { hoveredNode = null; dragNode = null; canvas.style.cursor = 'default'; });
823
+ const container = $('mesh-container');
824
+
825
+ // Close button for detail panel
826
+ $('detail-close').addEventListener('click', () => {
827
+ selectedNode = null;
828
+ $('node-detail').classList.add('hidden');
829
+ setChatMode('model', null);
830
+ });
831
+
832
+ graph = new ForceGraph()(container)
833
+ .backgroundColor('transparent')
834
+ .nodeId('id')
835
+ .linkSource('source')
836
+ .linkTarget('target')
837
+ .nodeVal(node => node.type === 'self' ? 8 : node.type === 'satellite' ? 3 : 5)
838
+ .nodeCanvasObjectMode(() => 'replace')
839
+ .nodeCanvasObject((node, ctx, globalScale) => {
840
+ const isSelected = selectedNode === node.id;
841
+ const isHovered = hoveredNode === node.id;
842
+ const r = node.type === 'self' ? 16 : node.type === 'satellite' ? 9 : 12;
843
+ const color = NODE_COLORS[node.type] || NODE_COLORS.offline;
844
+ const glowRgb = GLOW_COLORS[node.type];
845
+
846
+ // Animated pulse glow
847
+ if (glowRgb) {
848
+ const pulse = 0.5 + 0.5 * Math.sin(frameCount * 0.03 + (node.__idx || 0) * 1.5);
849
+ const glowR = r * (2.5 + (isHovered || isSelected ? 1.0 : 0) + pulse * 0.5);
850
+ const glowAlpha = isSelected ? 0.25 : isHovered ? 0.2 : 0.08 + pulse * 0.04;
851
+ const grad = ctx.createRadialGradient(node.x, node.y, r * 0.5, node.x, node.y, glowR);
852
+ grad.addColorStop(0, 'rgba(' + glowRgb.join(',') + ',' + glowAlpha + ')');
853
+ grad.addColorStop(0.6, 'rgba(' + glowRgb.join(',') + ',' + (glowAlpha * 0.3) + ')');
854
+ grad.addColorStop(1, 'transparent');
855
+ ctx.fillStyle = grad;
856
+ ctx.fillRect(node.x - glowR, node.y - glowR, glowR * 2, glowR * 2);
857
+ }
858
+
859
+ // Outer ring for selected/hovered
860
+ if (isSelected || isHovered) {
861
+ ctx.beginPath();
862
+ ctx.arc(node.x, node.y, r + 3, 0, Math.PI * 2);
863
+ ctx.strokeStyle = color + (isSelected ? '60' : '30');
864
+ ctx.lineWidth = 2;
865
+ ctx.stroke();
866
+ }
867
+
868
+ // Main circle with gradient fill
869
+ const fillGrad = ctx.createRadialGradient(node.x - r * 0.3, node.y - r * 0.3, 0, node.x, node.y, r);
870
+ fillGrad.addColorStop(0, color);
871
+ fillGrad.addColorStop(1, color + 'aa');
872
+ ctx.beginPath();
873
+ ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
874
+ ctx.fillStyle = fillGrad;
875
+ ctx.fill();
876
+
877
+ // Border
878
+ ctx.strokeStyle = isSelected ? '#fff' : isHovered ? color : color + '50';
879
+ ctx.lineWidth = isSelected ? 2 : isHovered ? 1.5 : 1;
880
+ ctx.stroke();
881
+
882
+ // Inner ring for self with animated rotation
883
+ if (node.type === 'self') {
884
+ ctx.save();
885
+ ctx.translate(node.x, node.y);
886
+ ctx.rotate(frameCount * 0.008);
887
+ ctx.beginPath();
888
+ ctx.arc(0, 0, r - 5, 0, Math.PI * 1.5);
889
+ ctx.strokeStyle = 'rgba(255,255,255,0.35)';
890
+ ctx.lineWidth = 1.5;
891
+ ctx.stroke();
892
+ ctx.restore();
893
+ }
894
+
895
+ // Label with shadow
896
+ ctx.textAlign = 'center';
897
+ ctx.textBaseline = 'middle';
898
+ const fontSize = Math.max((node.type === 'self' ? 12 : 11) / globalScale, 3);
899
+ ctx.font = (node.type === 'self' ? '600 ' : '500 ') + fontSize + 'px -apple-system, system-ui, sans-serif';
900
+ const labelY = node.y + r + 12 / globalScale;
901
+ ctx.fillStyle = 'rgba(0,0,0,0.5)';
902
+ ctx.fillText(node.id, node.x + 0.5 / globalScale, labelY + 0.5 / globalScale);
903
+ ctx.fillStyle = isSelected || isHovered ? '#fff' : 'rgba(255,255,255,0.8)';
904
+ ctx.fillText(node.id, node.x, labelY);
905
+
906
+ // Capability counts
907
+ const data = node.data;
908
+ if (data) {
909
+ const parts = [];
910
+ if (node.type === 'satellite') {
911
+ parts.push(data.ssid ? data.ssid : 'cellular');
912
+ } else {
913
+ const agents = data.agents?.length || 0;
914
+ const models = data.models?.length || 0;
915
+ const tools = data.toolProxy?.enabled ? (data.toolProxy.allow?.length || 0) : 0;
916
+ if (models) parts.push(models + 'M');
917
+ if (agents) parts.push(agents + 'A');
918
+ if (tools) parts.push(tools + 'T');
919
+ }
920
+ if (parts.length) {
921
+ ctx.font = Math.max(10 / globalScale, 2.5) + 'px -apple-system, system-ui, sans-serif';
922
+ ctx.fillStyle = 'rgba(255,255,255,0.4)';
923
+ ctx.fillText(parts.join(' \\u00b7 '), node.x, labelY + 13 / globalScale);
924
+ }
925
+ }
926
+ })
927
+ .linkCanvasObjectMode(() => 'replace')
928
+ .linkCanvasObject((link, ctx, globalScale) => {
929
+ const src = link.source;
930
+ const tgt = link.target;
931
+ if (!src || !tgt || src.x == null || tgt.x == null) return;
932
+
933
+ const isDirect = link.type === 'direct';
934
+ const isSatellite = link.type === 'satellite';
935
+ const rgb = getLinkColorRgb(src, tgt);
936
+
937
+ // Link line with glow
938
+ ctx.beginPath();
939
+ ctx.moveTo(src.x, src.y);
940
+ ctx.lineTo(tgt.x, tgt.y);
941
+
942
+ // Glow layer
943
+ ctx.strokeStyle = 'rgba(' + rgb + ',0.12)';
944
+ ctx.lineWidth = isDirect ? 6 : 4;
945
+ ctx.setLineDash([]);
946
+ ctx.stroke();
947
+
948
+ // Main line
949
+ ctx.beginPath();
950
+ ctx.moveTo(src.x, src.y);
951
+ ctx.lineTo(tgt.x, tgt.y);
952
+ ctx.strokeStyle = 'rgba(' + rgb + ',' + (isDirect ? '0.55' : '0.4') + ')';
953
+ ctx.lineWidth = isDirect ? 2 : 1.5;
954
+ if (isSatellite) ctx.setLineDash([3, 5]);
955
+ else if (!isDirect) ctx.setLineDash([6, 4]);
956
+ else ctx.setLineDash([]);
957
+ ctx.stroke();
958
+ ctx.setLineDash([]);
959
+ })
960
+ .linkDirectionalParticles(link => link.type === 'direct' ? 4 : 2)
961
+ .linkDirectionalParticleSpeed(link => link.type === 'direct' ? 0.004 : 0.003)
962
+ .linkDirectionalParticleWidth(link => link.type === 'direct' ? 3 : 2)
963
+ .linkDirectionalParticleColor(link => {
964
+ return 'rgba(' + getLinkColorRgb(link.source, link.target) + ',0.8)';
965
+ })
966
+ .onNodeHover(node => {
967
+ hoveredNode = node ? node.id : null;
968
+ container.style.cursor = node ? 'pointer' : 'default';
969
+ })
970
+ .onNodeClick((node) => {
971
+ selectedNode = node.id;
972
+ updateDetail(node.id);
973
+ setChatMode(node.id !== state.nodeId ? 'handoff' : 'model', node.id !== state.nodeId ? node.id : null);
974
+ })
975
+ .onBackgroundClick(() => {
976
+ selectedNode = null;
977
+ $('node-detail').classList.add('hidden');
978
+ setChatMode('model', null);
979
+ })
980
+ .onNodeDragEnd(node => {
981
+ node.fx = node.x;
982
+ node.fy = node.y;
983
+ })
984
+ .onRenderFramePre((ctx, globalScale) => {
985
+ frameCount++;
986
+ // Subtle radial gradient background
987
+ const w = graph.width();
988
+ const h = graph.height();
989
+ const bgGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, Math.max(w, h) * 0.6);
990
+ bgGrad.addColorStop(0, 'rgba(99, 102, 241, 0.03)');
991
+ bgGrad.addColorStop(1, 'transparent');
992
+ ctx.fillStyle = bgGrad;
993
+ ctx.fillRect(-w / 2, -h / 2, w, h);
994
+
995
+ // Grid dots instead of lines
996
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.04)';
997
+ const gridSize = 30 / globalScale;
998
+ const dotR = 1 / globalScale;
999
+ const xMin = -w / 2;
1000
+ const yMin = -h / 2;
1001
+ const xMax = w / 2;
1002
+ const yMax = h / 2;
1003
+ for (let x = Math.floor(xMin / gridSize) * gridSize; x < xMax; x += gridSize) {
1004
+ for (let y = Math.floor(yMin / gridSize) * gridSize; y < yMax; y += gridSize) {
1005
+ ctx.beginPath();
1006
+ ctx.arc(x, y, dotR, 0, Math.PI * 2);
1007
+ ctx.fill();
1008
+ }
1009
+ }
1010
+ })
1011
+ .cooldownTime(Infinity)
1012
+ .d3AlphaMin(0);
1013
+
1014
+ // Configure forces — strong repulsion to spread nodes apart
1015
+ graph.d3Force('link').distance(200);
1016
+ graph.d3Force('charge').strength(-800).distanceMin(50);
1017
+ graph.d3Force('center', null);
1018
+
1019
+ // Observe container resize
1020
+ new ResizeObserver(() => {
1021
+ const rect = container.getBoundingClientRect();
1022
+ const headerH = container.parentElement.querySelector('.card-header')?.offsetHeight || 0;
1023
+ graph.width(rect.width).height(rect.height - headerH);
1024
+ }).observe(container.parentElement);
1025
+
1026
+ // Trigger initial size
1027
+ const rect = container.getBoundingClientRect();
1028
+ const headerH = container.parentElement.querySelector('.card-header')?.offsetHeight || 0;
1029
+ graph.width(rect.width).height(rect.height - headerH);
759
1030
  }
760
1031
 
761
- function resizeCanvas() {
762
- const rect = canvas.parentElement.getBoundingClientRect();
763
- const headerH = canvas.parentElement.querySelector('.card-header')?.offsetHeight || 0;
764
- W = rect.width;
765
- H = rect.height - headerH;
766
- canvas.width = W * DPR;
767
- canvas.height = H * DPR;
768
- canvas.style.width = W + 'px';
769
- canvas.style.height = H + 'px';
770
- ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
771
- layoutNodes();
1032
+ // Color palette for distinguishing different WSS connections
1033
+ const LINK_COLORS = [
1034
+ [129, 140, 248], // indigo
1035
+ [52, 211, 153], // emerald
1036
+ [251, 146, 60], // orange
1037
+ [167, 139, 250], // violet
1038
+ [56, 189, 248], // sky
1039
+ [251, 191, 36], // amber
1040
+ [244, 114, 182], // pink
1041
+ [45, 212, 191], // teal
1042
+ [248, 113, 113], // red
1043
+ [163, 230, 53], // lime
1044
+ ];
1045
+ let linkColorIndex = 0;
1046
+ const linkColorMap = {}; // edge key → color index
1047
+
1048
+ function getLinkColorRgb(source, target) {
1049
+ const s = typeof source === 'object' ? source.id : source;
1050
+ const t = typeof target === 'object' ? target.id : target;
1051
+ const key = [s, t].sort().join('::');
1052
+ if (!(key in linkColorMap)) {
1053
+ linkColorMap[key] = linkColorIndex % LINK_COLORS.length;
1054
+ linkColorIndex++;
1055
+ }
1056
+ const c = LINK_COLORS[linkColorMap[key]];
1057
+ return c[0] + ',' + c[1] + ',' + c[2];
772
1058
  }
773
1059
 
774
- function updateMeshData() {
775
- const allNodes = [
1060
+ function buildGraphInputs() {
1061
+ const allPeers = state.peers.map((p, i) => ({
1062
+ id: p.nodeId,
1063
+ type: p.connection === 'satellite' ? 'satellite' : (p.online ? (p.connection === 'direct' ? 'direct' : 'relay') : 'offline'),
1064
+ data: p,
1065
+ }));
1066
+ const nodes = [
776
1067
  { id: state.nodeId, type: 'self', data: state.local },
777
- ...state.peers.map(p => ({
778
- id: p.nodeId,
779
- type: p.online ? (p.connection === 'direct' ? 'direct' : 'relay') : 'offline',
780
- data: p,
781
- })),
1068
+ ...allPeers,
782
1069
  ];
1070
+ // Spread initial positions in a circle so nodes don't start at (0,0)
1071
+ const radius = 120;
1072
+ for (let i = 0; i < nodes.length; i++) {
1073
+ const n = nodes[i];
1074
+ if (n.x == null) {
1075
+ const angle = (2 * Math.PI * i) / nodes.length - Math.PI / 2;
1076
+ n.x = radius * Math.cos(angle);
1077
+ n.y = radius * Math.sin(angle);
1078
+ }
1079
+ }
783
1080
 
784
- // Preserve positions for existing nodes
785
- const oldPositions = {};
786
- for (const n of meshNodes) oldPositions[n.id] = { x: n.x, y: n.y, vx: n.vx, vy: n.vy };
787
-
788
- meshNodes = allNodes.map((n, i) => {
789
- const old = oldPositions[n.id];
790
- if (old) return { ...n, x: old.x, y: old.y, vx: old.vx, vy: old.vy };
791
- // New node: random position
792
- const angle = (i / allNodes.length) * Math.PI * 2 - Math.PI / 2;
793
- const r = n.type === 'self' ? 0 : 120 + Math.random() * 40;
794
- return {
795
- ...n,
796
- x: W / 2 + Math.cos(angle) * r,
797
- y: H / 2 + Math.sin(angle) * r,
798
- vx: 0,
799
- vy: 0,
800
- };
801
- });
802
-
803
- // Build edges
804
- meshEdges = [];
805
- const edgeSet = new Set();
1081
+ const links = [];
1082
+ const edgeMap = {}; // pairKey → link object (dedup, direct wins over relay)
806
1083
  function addEdge(a, b, type) {
807
- const key = [a, b].sort().join('::') + '::' + type;
808
- if (edgeSet.has(key)) return;
809
- edgeSet.add(key);
810
- meshEdges.push({ from: a, to: b, type: type });
1084
+ const pairKey = [a, b].sort().join('::');
1085
+ const existing = edgeMap[pairKey];
1086
+ // direct > relay > satellite: keep the stronger type
1087
+ if (existing) {
1088
+ if (existing.type === 'direct') return; // already best
1089
+ if (type === 'direct') { existing.type = 'direct'; return; }
1090
+ return; // keep first
1091
+ }
1092
+ getLinkColorRgb(a, b); // pre-register color for this pair
1093
+ const link = { source: a, target: b, type: type };
1094
+ edgeMap[pairKey] = link;
1095
+ links.push(link);
811
1096
  }
812
1097
 
813
- // Edges from self to direct peers
814
1098
  for (const p of state.peers) {
815
- if (p.connection === 'direct') {
1099
+ if (p.connection === 'satellite') {
1100
+ addEdge(state.nodeId, p.nodeId, 'satellite');
1101
+ } else if (p.connection === 'direct') {
816
1102
  addEdge(state.nodeId, p.nodeId, 'direct');
817
1103
  } else if (p.reachableVia) {
818
1104
  addEdge(p.reachableVia, p.nodeId, 'relay');
819
1105
  addEdge(state.nodeId, p.reachableVia, 'direct');
820
1106
  }
821
1107
  }
822
-
823
- // Edges between remote peers (from directPeers data)
1108
+ const nodeIds = new Set(nodes.map(n => n.id));
824
1109
  for (const p of state.peers) {
825
1110
  if (p.directPeers) {
826
1111
  for (const dp of p.directPeers) {
827
- if (dp !== state.nodeId) {
1112
+ if (dp !== state.nodeId && nodeIds.has(dp)) {
828
1113
  addEdge(p.nodeId, dp, 'direct');
829
1114
  }
830
1115
  }
831
1116
  }
832
1117
  }
833
1118
 
834
- layoutNodes();
1119
+ return { nodes, links };
835
1120
  }
836
1121
 
837
- function layoutNodes() {
838
- // Pin self to center only if not manually dragged
839
- const self = meshNodes.find(n => n.type === 'self');
840
- if (self && !self._pinned) { self.x = W / 2; self.y = H / 2; }
1122
+ function linkKey(l) {
1123
+ const s = typeof l.source === 'object' ? l.source.id : l.source;
1124
+ const t = typeof l.target === 'object' ? l.target.id : l.target;
1125
+ return [s, t].sort().join('::');
841
1126
  }
842
1127
 
843
- // Force simulation step
844
- function simulateForces() {
845
- const k = 0.005; // spring constant
846
- const repulsion = 8000;
847
- const damping = 0.85;
848
- const center = { x: W / 2, y: H / 2 };
849
-
850
- for (let i = 0; i < meshNodes.length; i++) {
851
- const a = meshNodes[i];
852
- if (a._pinned || (a.type === 'self' && !dragNode)) continue; // pinned
853
-
854
- let fx = 0, fy = 0;
855
-
856
- // Repulsion from all other nodes
857
- for (let j = 0; j < meshNodes.length; j++) {
858
- if (i === j) continue;
859
- const b = meshNodes[j];
860
- let dx = a.x - b.x;
861
- let dy = a.y - b.y;
862
- const d2 = dx * dx + dy * dy + 1;
863
- const f = repulsion / d2;
864
- fx += dx * f;
865
- fy += dy * f;
866
- }
867
-
868
- // Spring to connected nodes
869
- for (const edge of meshEdges) {
870
- let other = null;
871
- if (edge.from === a.id) other = meshNodes.find(n => n.id === edge.to);
872
- else if (edge.to === a.id) other = meshNodes.find(n => n.id === edge.from);
873
- if (!other) continue;
874
-
875
- const dx = other.x - a.x;
876
- const dy = other.y - a.y;
877
- const d = Math.sqrt(dx * dx + dy * dy);
878
- const desired = 150;
879
- const f = k * (d - desired);
880
- fx += dx / d * f;
881
- fy += dy / d * f;
1128
+ function updateMeshData() {
1129
+ if (!graph) return;
1130
+ const { nodes: newNodes, links: newLinks } = buildGraphInputs();
1131
+ const { nodes: curNodes, links: curLinks } = graph.graphData();
1132
+
1133
+ // Check if topology actually changed
1134
+ const curNodeIds = new Set(curNodes.map(n => n.id));
1135
+ const newNodeIds = new Set(newNodes.map(n => n.id));
1136
+ const curLinkKeys = new Set(curLinks.map(linkKey));
1137
+ const newLinkKeys = new Set(newLinks.map(linkKey));
1138
+
1139
+ const nodesChanged = newNodeIds.size !== curNodeIds.size || [...newNodeIds].some(id => !curNodeIds.has(id));
1140
+ const linksChanged = newLinkKeys.size !== curLinkKeys.size || [...newLinkKeys].some(k => !curLinkKeys.has(k));
1141
+
1142
+ // Always update node data (type, capabilities) in place
1143
+ const curMap = {};
1144
+ for (const n of curNodes) curMap[n.id] = n;
1145
+ for (const nn of newNodes) {
1146
+ const cur = curMap[nn.id];
1147
+ if (cur) {
1148
+ cur.type = nn.type;
1149
+ cur.data = nn.data;
882
1150
  }
883
-
884
- // Gravity toward center
885
- fx += (center.x - a.x) * 0.001;
886
- fy += (center.y - a.y) * 0.001;
887
-
888
- a.vx = (a.vx + fx) * damping;
889
- a.vy = (a.vy + fy) * damping;
890
- a.x += a.vx;
891
- a.y += a.vy;
892
-
893
- // Bounds
894
- const pad = 60;
895
- a.x = Math.max(pad, Math.min(W - pad, a.x));
896
- a.y = Math.max(pad, Math.min(H - pad, a.y));
897
1151
  }
898
- }
899
-
900
- let particleTime = 0;
901
-
902
- function renderMesh() {
903
- ctx.clearRect(0, 0, W, H);
904
-
905
- // Grid background
906
- ctx.strokeStyle = 'rgba(30, 32, 48, 0.5)';
907
- ctx.lineWidth = 0.5;
908
- const gridSize = 40;
909
- for (let x = 0; x < W; x += gridSize) {
910
- ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
911
- }
912
- for (let y = 0; y < H; y += gridSize) {
913
- ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
914
- }
915
-
916
- particleTime += 0.015;
917
-
918
- // Draw edges
919
- for (const edge of meshEdges) {
920
- const a = meshNodes.find(n => n.id === edge.from);
921
- const b = meshNodes.find(n => n.id === edge.to);
922
- if (!a || !b) continue;
923
1152
 
924
- const isDirect = edge.type === 'direct';
925
- ctx.beginPath();
926
- ctx.moveTo(a.x, a.y);
927
- ctx.lineTo(b.x, b.y);
1153
+ if (!nodesChanged && !linksChanged) return; // topology unchanged, skip
928
1154
 
929
- if (isDirect) {
930
- ctx.strokeStyle = 'rgba(99, 102, 241, 0.3)';
931
- ctx.lineWidth = 2;
932
- ctx.setLineDash([]);
933
- } else {
934
- ctx.strokeStyle = 'rgba(251, 191, 36, 0.25)';
935
- ctx.lineWidth = 1.5;
936
- ctx.setLineDash([6, 4]);
937
- }
938
- ctx.stroke();
939
- ctx.setLineDash([]);
940
-
941
- // Animated particles along edge
942
- const color = isDirect ? '99, 102, 241' : '251, 191, 36';
943
- const dx = b.x - a.x;
944
- const dy = b.y - a.y;
945
- const len = Math.sqrt(dx * dx + dy * dy);
946
- if (len < 10) continue;
947
-
948
- for (let i = 0; i < 3; i++) {
949
- const t = ((particleTime * 0.5 + i * 0.33) % 1);
950
- const px = a.x + dx * t;
951
- const py = a.y + dy * t;
952
- ctx.beginPath();
953
- ctx.arc(px, py, 2, 0, Math.PI * 2);
954
- ctx.fillStyle = 'rgba(' + color + ', ' + (0.6 - t * 0.4) + ')';
955
- ctx.fill();
956
- }
1155
+ // Topology changed — preserve positions of existing nodes
1156
+ const posMap = {};
1157
+ for (const n of curNodes) {
1158
+ posMap[n.id] = { x: n.x, y: n.y, vx: n.vx, vy: n.vy, fx: n.fx, fy: n.fy };
957
1159
  }
958
-
959
- // Draw nodes
960
- for (const node of meshNodes) {
961
- const isHovered = hoveredNode === node.id;
962
- const isSelected = selectedNode === node.id;
963
- const r = node.type === 'self' ? 28 : 22;
964
-
965
- // Glow
966
- if (node.type !== 'offline') {
967
- const glowColor = node.type === 'self' ? 'rgba(129,140,248,0.15)' :
968
- node.type === 'direct' ? 'rgba(52,211,153,0.12)' :
969
- 'rgba(251,191,36,0.1)';
970
- const grad = ctx.createRadialGradient(node.x, node.y, r, node.x, node.y, r * 2.5);
971
- grad.addColorStop(0, glowColor);
972
- grad.addColorStop(1, 'transparent');
973
- ctx.fillStyle = grad;
974
- ctx.fillRect(node.x - r * 3, node.y - r * 3, r * 6, r * 6);
975
- }
976
-
977
- // Node circle
978
- ctx.beginPath();
979
- ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
980
-
981
- const color = node.type === 'self' ? '#818cf8' :
982
- node.type === 'direct' ? '#34d399' :
983
- node.type === 'relay' ? '#fbbf24' : '#555d75';
984
-
985
- ctx.fillStyle = isHovered || isSelected ? color : adjustAlpha(color, 0.8);
986
- ctx.fill();
987
-
988
- // Border
989
- ctx.strokeStyle = isSelected ? '#fff' : isHovered ? adjustAlpha(color, 1) : adjustAlpha(color, 0.3);
990
- ctx.lineWidth = isSelected ? 2.5 : isHovered ? 2 : 1.5;
991
- ctx.stroke();
992
-
993
- // Inner ring for self
994
- if (node.type === 'self') {
995
- ctx.beginPath();
996
- ctx.arc(node.x, node.y, r - 6, 0, Math.PI * 2);
997
- ctx.strokeStyle = 'rgba(255,255,255,0.3)';
998
- ctx.lineWidth = 1;
999
- ctx.stroke();
1000
- }
1001
-
1002
- // Label
1003
- ctx.textAlign = 'center';
1004
- ctx.textBaseline = 'middle';
1005
- ctx.font = (node.type === 'self' ? '600 12px' : '500 11px') + ' -apple-system, system-ui, sans-serif';
1006
- ctx.fillStyle = '#fff';
1007
- ctx.fillText(node.id, node.x, node.y + r + 16);
1008
-
1009
- // Capability counts below label
1010
- const data = node.data;
1011
- if (data) {
1012
- const parts = [];
1013
- const agents = data.agents?.length || 0;
1014
- const models = data.models?.length || 0;
1015
- if (models) parts.push(models + 'M');
1016
- if (agents) parts.push(agents + 'A');
1017
- if (parts.length) {
1018
- ctx.font = '10px -apple-system, system-ui, sans-serif';
1019
- ctx.fillStyle = 'rgba(255,255,255,0.4)';
1020
- ctx.fillText(parts.join(' · '), node.x, node.y + r + 30);
1021
- }
1160
+ for (const n of newNodes) {
1161
+ const old = posMap[n.id];
1162
+ if (old) {
1163
+ n.x = old.x; n.y = old.y;
1164
+ n.vx = old.vx; n.vy = old.vy;
1165
+ n.fx = old.fx; n.fy = old.fy;
1022
1166
  }
1023
1167
  }
1024
- }
1025
-
1026
- function adjustAlpha(hex, alpha) {
1027
- const r = parseInt(hex.slice(1, 3), 16);
1028
- const g = parseInt(hex.slice(3, 5), 16);
1029
- const b = parseInt(hex.slice(5, 7), 16);
1030
- return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
1031
- }
1032
1168
 
1033
- function renderLoop() {
1034
- simulateForces();
1035
- renderMesh();
1036
- animFrame = requestAnimationFrame(renderLoop);
1037
- }
1038
-
1039
- function hitTest(mx, my) {
1040
- for (let i = meshNodes.length - 1; i >= 0; i--) {
1041
- const n = meshNodes[i];
1042
- const r = n.type === 'self' ? 28 : 22;
1043
- const dx = mx - n.x;
1044
- const dy = my - n.y;
1045
- if (dx * dx + dy * dy <= (r + 8) * (r + 8)) return n;
1046
- }
1047
- return null;
1048
- }
1049
-
1050
- function getCanvasPos(e) {
1051
- const rect = canvas.getBoundingClientRect();
1052
- return { x: e.clientX - rect.left, y: e.clientY - rect.top };
1053
- }
1054
-
1055
- function onCanvasMouseDown(e) {
1056
- const { x, y } = getCanvasPos(e);
1057
- const hit = hitTest(x, y);
1058
- if (hit) {
1059
- dragNode = hit;
1060
- dragOffset.x = x - hit.x;
1061
- dragOffset.y = y - hit.y;
1062
- hit._pinned = true;
1063
- canvas.style.cursor = 'grabbing';
1064
- // Also select on click
1065
- selectedNode = hit.id;
1066
- updateDetail(hit.id);
1067
- setChatMode(hit.id !== state.nodeId ? 'handoff' : 'model', hit.id !== state.nodeId ? hit.id : null);
1068
- }
1069
- }
1070
-
1071
- function onCanvasMouseMove(e) {
1072
- const { x, y } = getCanvasPos(e);
1073
- if (dragNode) {
1074
- dragNode.x = x - dragOffset.x;
1075
- dragNode.y = y - dragOffset.y;
1076
- canvas.style.cursor = 'grabbing';
1077
- return;
1078
- }
1079
- const hit = hitTest(x, y);
1080
- hoveredNode = hit ? hit.id : null;
1081
- canvas.style.cursor = hit ? 'grab' : 'default';
1082
- }
1083
-
1084
- function onCanvasMouseUp(e) {
1085
- if (dragNode) {
1086
- // Keep self pinned to where it was dropped; release others after a delay
1087
- if (dragNode.type !== 'self') {
1088
- const node = dragNode;
1089
- setTimeout(() => { node._pinned = false; }, 2000);
1090
- }
1091
- dragNode = null;
1092
- canvas.style.cursor = 'default';
1093
- } else {
1094
- const { x, y } = getCanvasPos(e);
1095
- const hit = hitTest(x, y);
1096
- if (!hit) {
1097
- selectedNode = null;
1098
- $('node-detail').classList.add('hidden');
1099
- setChatMode('model', null);
1100
- }
1101
- }
1169
+ graph.graphData({ nodes: newNodes, links: newLinks });
1102
1170
  }
1103
1171
 
1104
1172
  // ── Node detail panel ──
@@ -1109,14 +1177,29 @@ const JS = `
1109
1177
  $('node-detail').classList.add('hidden');
1110
1178
  return;
1111
1179
  }
1180
+ const isSat = nodeData.connection === 'satellite';
1112
1181
 
1113
1182
  $('node-detail').classList.remove('hidden');
1114
1183
  $('detail-title').textContent = nodeId;
1115
1184
 
1185
+ // Status dot color
1186
+ const dot = $('detail-status-dot');
1187
+ const dotColor = isLocal ? 'var(--accent)' : isSat ? '#f472b6' : nodeData.online ? 'var(--green)' : 'var(--red)';
1188
+ dot.style.background = dotColor;
1189
+ if (isLocal || isSat || nodeData.online) dot.style.boxShadow = '0 0 6px ' + dotColor;
1190
+ else dot.style.boxShadow = 'none';
1191
+
1116
1192
  const statusBadge = $('detail-status');
1193
+ statusBadge.style.background = '';
1194
+ statusBadge.style.color = '';
1117
1195
  if (isLocal) {
1118
1196
  statusBadge.textContent = 'Self';
1119
1197
  statusBadge.className = 'badge badge-self';
1198
+ } else if (isSat) {
1199
+ statusBadge.textContent = 'Satellite';
1200
+ statusBadge.style.background = 'rgba(244,114,182,0.15)';
1201
+ statusBadge.style.color = '#f472b6';
1202
+ statusBadge.className = 'badge';
1120
1203
  } else if (nodeData.online) {
1121
1204
  statusBadge.textContent = nodeData.connection === 'direct' ? 'Direct' : 'Relay';
1122
1205
  statusBadge.className = 'badge ' + (nodeData.connection === 'direct' ? 'badge-online' : 'badge-relay');
@@ -1127,6 +1210,41 @@ const JS = `
1127
1210
 
1128
1211
  let html = '';
1129
1212
 
1213
+ // Satellite node detail
1214
+ if (isSat) {
1215
+ html += '<div class="detail-section">';
1216
+ html += '<div class="detail-label">Network</div>';
1217
+ html += '<div class="detail-grid">';
1218
+ if (nodeData.cellular) {
1219
+ html += '<span class="detail-key">Type</span><span>Cellular</span>';
1220
+ } else if (nodeData.ssid) {
1221
+ html += '<span class="detail-key">WiFi</span><span>' + esc(nodeData.ssid) + '</span>';
1222
+ }
1223
+ if (nodeData.ip) html += '<span class="detail-key">IP</span><span>' + esc(nodeData.ip) + '</span>';
1224
+ if (nodeData.router) html += '<span class="detail-key">Router</span><span>' + esc(nodeData.router) + '</span>';
1225
+ if (nodeData.country) html += '<span class="detail-key">Country</span><span>' + esc(nodeData.country) + '</span>';
1226
+ if (nodeData.location) html += '<span class="detail-key">Location</span><span>' + esc(nodeData.location) + '</span>';
1227
+ if (nodeData.platform) html += '<span class="detail-key">Platform</span><span>' + esc(nodeData.platform) + '</span>';
1228
+ if (typeof nodeData.battery === 'number') {
1229
+ const bat = nodeData.battery + '%' + (nodeData.charging ? ' (charging)' : '');
1230
+ html += '<span class="detail-key">Battery</span><span>' + esc(bat) + '</span>';
1231
+ }
1232
+ if (nodeData.lastSeen) html += '<span class="detail-key">Last seen</span><span>' + new Date(nodeData.lastSeen).toLocaleTimeString() + '</span>';
1233
+ html += '</div></div>';
1234
+ const satTools = nodeData.toolProxy?.allow || [];
1235
+ if (satTools.length > 0) {
1236
+ html += '<div class="detail-section">';
1237
+ html += '<div class="detail-label">Tools (' + satTools.length + ')</div>';
1238
+ html += '<div style="display:flex;flex-wrap:wrap;gap:4px">';
1239
+ for (const t of satTools) {
1240
+ html += '<span class="badge" style="background:rgba(244,114,182,0.1);color:#f472b6;font-size:11px">' + esc(t) + '</span>';
1241
+ }
1242
+ html += '</div></div>';
1243
+ }
1244
+ $('detail-body').innerHTML = html;
1245
+ return;
1246
+ }
1247
+
1130
1248
  // Device info
1131
1249
  if (nodeData.deviceInfo) {
1132
1250
  const d = nodeData.deviceInfo;
@@ -1179,6 +1297,33 @@ const JS = `
1179
1297
  html += '</div></div>';
1180
1298
  }
1181
1299
 
1300
+ // Cluster Tools
1301
+ if (nodeData.clusterTools?.length) {
1302
+ html += '<div class="detail-section">';
1303
+ html += '<div class="detail-label collapsible" onclick="this.classList.toggle(&#39;expanded&#39;);this.nextElementSibling.classList.toggle(&#39;expanded&#39;)">Cluster Tools (' + nodeData.clusterTools.length + ')</div>';
1304
+ html += '<div class="detail-items">';
1305
+ for (const t of nodeData.clusterTools) {
1306
+ html += '<div class="item-row"><span class="item-icon" style="background:var(--orange, #f59e0b)"></span>';
1307
+ html += '<span>' + esc(t) + '</span></div>';
1308
+ }
1309
+ html += '</div></div>';
1310
+ }
1311
+
1312
+ // Tool Proxy
1313
+ if (nodeData.toolProxy) {
1314
+ html += '<div class="detail-section">';
1315
+ html += '<div class="detail-label">Tool Proxy</div>';
1316
+ html += '<div class="detail-grid">';
1317
+ html += '<span class="detail-key">Status</span><span>' + (nodeData.toolProxy.enabled ? '<span style="color:var(--green)">Enabled</span>' : '<span style="color:var(--text-dim)">Disabled</span>') + '</span>';
1318
+ if (nodeData.toolProxy.enabled && nodeData.toolProxy.allow?.length) {
1319
+ html += '<span class="detail-key">Allow</span><span>' + nodeData.toolProxy.allow.map(function(t) { return esc(t); }).join(', ') + '</span>';
1320
+ }
1321
+ if (nodeData.toolProxy.enabled && nodeData.toolProxy.deny?.length) {
1322
+ html += '<span class="detail-key">Deny</span><span>' + nodeData.toolProxy.deny.map(function(t) { return esc(t); }).join(', ') + '</span>';
1323
+ }
1324
+ html += '</div></div>';
1325
+ }
1326
+
1182
1327
  // Tags
1183
1328
  if (nodeData.tags?.length) {
1184
1329
  html += '<div class="detail-section">';