@sleep2agi/agent-network-dashboard 0.4.6-preview.13 → 0.4.6-preview.131

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 (335) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +1 -0
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/diagnostics/route-bundle-stats.json +41 -41
  5. package/.next/fallback-build-manifest.json +3 -3
  6. package/.next/prerender-manifest.json +3 -3
  7. package/.next/routes-manifest.json +6 -0
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app/_not-found.html +2 -2
  17. package/.next/server/app/_not-found.rsc +12 -12
  18. package/.next/server/app/_not-found.segments/_full.segment.rsc +12 -12
  19. package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  20. package/.next/server/app/_not-found.segments/_index.segment.rsc +7 -7
  21. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  22. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  23. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  24. package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
  25. package/.next/server/app/admin.html +2 -2
  26. package/.next/server/app/admin.rsc +14 -14
  27. package/.next/server/app/admin.segments/_full.segment.rsc +14 -14
  28. package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
  29. package/.next/server/app/admin.segments/_index.segment.rsc +7 -7
  30. package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
  31. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +4 -4
  32. package/.next/server/app/admin.segments/admin.segment.rsc +3 -3
  33. package/.next/server/app/api/hub/servers/route/app-paths-manifest.json +3 -0
  34. package/.next/server/app/api/hub/servers/route/build-manifest.json +9 -0
  35. package/.next/server/app/api/hub/servers/route/server-reference-manifest.json +4 -0
  36. package/.next/server/app/api/hub/servers/route.js +7 -0
  37. package/.next/server/app/api/hub/servers/route.js.map +5 -0
  38. package/.next/server/app/api/hub/servers/route.js.nft.json +1 -0
  39. package/.next/server/app/api/hub/servers/route_client-reference-manifest.js +3 -0
  40. package/.next/server/app/index.html +2 -2
  41. package/.next/server/app/index.rsc +14 -14
  42. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  43. package/.next/server/app/index.segments/_full.segment.rsc +14 -14
  44. package/.next/server/app/index.segments/_head.segment.rsc +4 -4
  45. package/.next/server/app/index.segments/_index.segment.rsc +7 -7
  46. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  47. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  48. package/.next/server/app/login.html +2 -2
  49. package/.next/server/app/login.rsc +14 -14
  50. package/.next/server/app/login.segments/_full.segment.rsc +14 -14
  51. package/.next/server/app/login.segments/_head.segment.rsc +4 -4
  52. package/.next/server/app/login.segments/_index.segment.rsc +7 -7
  53. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  54. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
  55. package/.next/server/app/login.segments/login.segment.rsc +3 -3
  56. package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
  57. package/.next/server/app/logs.html +2 -2
  58. package/.next/server/app/logs.rsc +14 -14
  59. package/.next/server/app/logs.segments/_full.segment.rsc +14 -14
  60. package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
  61. package/.next/server/app/logs.segments/_index.segment.rsc +7 -7
  62. package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
  63. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
  64. package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
  65. package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
  66. package/.next/server/app/messages.html +2 -2
  67. package/.next/server/app/messages.rsc +14 -14
  68. package/.next/server/app/messages.segments/_full.segment.rsc +14 -14
  69. package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
  70. package/.next/server/app/messages.segments/_index.segment.rsc +7 -7
  71. package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
  72. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
  73. package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
  74. package/.next/server/app/node/page_client-reference-manifest.js +1 -1
  75. package/.next/server/app/node.html +2 -2
  76. package/.next/server/app/node.rsc +14 -14
  77. package/.next/server/app/node.segments/_full.segment.rsc +14 -14
  78. package/.next/server/app/node.segments/_head.segment.rsc +4 -4
  79. package/.next/server/app/node.segments/_index.segment.rsc +7 -7
  80. package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
  81. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +4 -4
  82. package/.next/server/app/node.segments/node.segment.rsc +3 -3
  83. package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
  84. package/.next/server/app/nodes.html +2 -2
  85. package/.next/server/app/nodes.rsc +14 -14
  86. package/.next/server/app/nodes.segments/_full.segment.rsc +14 -14
  87. package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
  88. package/.next/server/app/nodes.segments/_index.segment.rsc +7 -7
  89. package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
  90. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
  91. package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
  92. package/.next/server/app/page_client-reference-manifest.js +1 -1
  93. package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
  94. package/.next/server/app/server-logs.html +2 -2
  95. package/.next/server/app/server-logs.rsc +14 -14
  96. package/.next/server/app/server-logs.segments/_full.segment.rsc +14 -14
  97. package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
  98. package/.next/server/app/server-logs.segments/_index.segment.rsc +7 -7
  99. package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
  100. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +4 -4
  101. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
  102. package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
  103. package/.next/server/app/settings/networks.html +2 -2
  104. package/.next/server/app/settings/networks.rsc +14 -14
  105. package/.next/server/app/settings/networks.segments/_full.segment.rsc +14 -14
  106. package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
  107. package/.next/server/app/settings/networks.segments/_index.segment.rsc +7 -7
  108. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
  109. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +4 -4
  110. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
  111. package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
  112. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  113. package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
  114. package/.next/server/app/settings/tokens.html +2 -2
  115. package/.next/server/app/settings/tokens.rsc +14 -14
  116. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +14 -14
  117. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
  118. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +7 -7
  119. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
  120. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +4 -4
  121. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
  122. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
  123. package/.next/server/app/settings.html +2 -2
  124. package/.next/server/app/settings.rsc +14 -14
  125. package/.next/server/app/settings.segments/_full.segment.rsc +14 -14
  126. package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
  127. package/.next/server/app/settings.segments/_index.segment.rsc +7 -7
  128. package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  129. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
  130. package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
  131. package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  132. package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
  133. package/.next/server/app/tasks.html +2 -2
  134. package/.next/server/app/tasks.rsc +14 -14
  135. package/.next/server/app/tasks.segments/_full.segment.rsc +14 -14
  136. package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
  137. package/.next/server/app/tasks.segments/_index.segment.rsc +7 -7
  138. package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
  139. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
  140. package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
  141. package/.next/server/app-paths-manifest.json +1 -0
  142. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_servers_route_actions_08-m-cs.js +3 -0
  143. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_servers_route_actions_08-m-cs.js.map +1 -0
  144. package/.next/server/chunks/[root-of-the-server]__0tx8s8i._.js +3 -0
  145. package/.next/server/chunks/[root-of-the-server]__0tx8s8i._.js.map +1 -0
  146. package/.next/server/chunks/ssr/[root-of-the-server]__03b.f76._.js +1 -1
  147. package/.next/server/chunks/ssr/[root-of-the-server]__03b.f76._.js.map +1 -1
  148. package/.next/server/chunks/ssr/[root-of-the-server]__0fhoq8i._.js +1 -1
  149. package/.next/server/chunks/ssr/[root-of-the-server]__0fhoq8i._.js.map +1 -1
  150. package/.next/server/chunks/ssr/[root-of-the-server]__0nw~zhp._.js +1 -1
  151. package/.next/server/chunks/ssr/[root-of-the-server]__0nw~zhp._.js.map +1 -1
  152. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js +4 -1
  153. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js.map +1 -1
  154. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js +1 -1
  155. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js.map +1 -1
  156. package/.next/server/chunks/ssr/agent-network-dashboard_app_057q.ne._.js +1 -1
  157. package/.next/server/chunks/ssr/agent-network-dashboard_app_057q.ne._.js.map +1 -1
  158. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js +1 -1
  159. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js.map +1 -1
  160. package/.next/server/chunks/ssr/agent-network-dashboard_app_0_d45-d._.js +1 -1
  161. package/.next/server/chunks/ssr/agent-network-dashboard_app_0_d45-d._.js.map +1 -1
  162. package/.next/server/chunks/ssr/agent-network-dashboard_app_0i3759l._.js +1 -1
  163. package/.next/server/chunks/ssr/agent-network-dashboard_app_0i3759l._.js.map +1 -1
  164. package/.next/server/chunks/ssr/agent-network-dashboard_app_0xgney8._.js +1 -1
  165. package/.next/server/chunks/ssr/agent-network-dashboard_app_0xgney8._.js.map +1 -1
  166. package/.next/server/chunks/ssr/agent-network-dashboard_app_10hjgv4._.js +1 -1
  167. package/.next/server/chunks/ssr/agent-network-dashboard_app_10hjgv4._.js.map +1 -1
  168. package/.next/server/chunks/ssr/agent-network-dashboard_app_1153xeb._.js +1 -1
  169. package/.next/server/chunks/ssr/agent-network-dashboard_app_1153xeb._.js.map +1 -1
  170. package/.next/server/chunks/ssr/agent-network-dashboard_app_12l4oto._.js +1 -1
  171. package/.next/server/chunks/ssr/agent-network-dashboard_app_12l4oto._.js.map +1 -1
  172. package/.next/server/chunks/ssr/agent-network-dashboard_app_components_0mvyi-4._.js +1 -1
  173. package/.next/server/chunks/ssr/agent-network-dashboard_app_components_0mvyi-4._.js.map +1 -1
  174. package/.next/server/middleware-build-manifest.js +3 -3
  175. package/.next/server/pages/404.html +2 -2
  176. package/.next/server/pages/500.html +1 -1
  177. package/.next/server/server-reference-manifest.js +1 -1
  178. package/.next/server/server-reference-manifest.json +1 -1
  179. package/.next/static/chunks/0-mpa_947ipeq.js +1 -0
  180. package/.next/static/chunks/01lmbsd37fybu.js +1 -0
  181. package/.next/static/chunks/03o.h6kvmw4l_.js +1 -0
  182. package/.next/static/chunks/03~~oirxz7~vc.js +1 -0
  183. package/.next/static/chunks/05uk96gc~9mni.js +1 -0
  184. package/.next/static/chunks/08kf-.bgs10ze.js +1 -0
  185. package/.next/static/chunks/09pxnkf-1.1fj.js +1 -0
  186. package/.next/static/chunks/0eo167c-wlmfz.js +4 -0
  187. package/.next/static/chunks/0glmrdj9112tc.js +7 -0
  188. package/.next/static/chunks/0rxrez_cjn.eh.js +1 -0
  189. package/.next/static/chunks/0x8x.29hkju5a.js +1 -0
  190. package/.next/static/chunks/0yv1jpx~5v038.js +7 -0
  191. package/.next/static/chunks/10tq~cwj-obp4.js +1 -0
  192. package/.next/static/chunks/1450fa_~v9qpc.css +2 -0
  193. package/.next/static/chunks/174re5ar~kxne.js +1 -0
  194. package/.next/trace +2 -2
  195. package/.next/trace-build +1 -1
  196. package/.next/types/routes.d.ts +2 -1
  197. package/.next/types/validator.ts +9 -0
  198. package/app/api/hub/servers/route.ts +39 -0
  199. package/app/components/ChatPopover.tsx +38 -2
  200. package/app/components/CommandPalette.tsx +107 -0
  201. package/app/components/HelpOverlay.tsx +87 -0
  202. package/app/components/ServersDrawer.tsx +64 -27
  203. package/app/components/TopoGraph.tsx +2419 -163
  204. package/app/components/utils.ts +8 -7
  205. package/app/globals.css +19 -1
  206. package/app/lib/time.ts +36 -0
  207. package/package.json +1 -1
  208. package/scripts/help-overlay-topo-test.mjs +44 -0
  209. package/scripts/servers-drawer-live-test.mjs +96 -0
  210. package/scripts/topo-active-filter-pill-test.mjs +101 -0
  211. package/scripts/topo-active-links-chip-hover-test.mjs +103 -0
  212. package/scripts/topo-active-links-chip-test.mjs +73 -0
  213. package/scripts/topo-active-links-tooltip-test.mjs +79 -0
  214. package/scripts/topo-arrival-ping-test.mjs +87 -0
  215. package/scripts/topo-arrow-tier-test.mjs +61 -0
  216. package/scripts/topo-auto-fit-test.mjs +73 -0
  217. package/scripts/topo-chat-focus-ring-test.mjs +77 -0
  218. package/scripts/topo-chat-target-breath-test.mjs +84 -0
  219. package/scripts/topo-chip-pin-mirror-test.mjs +113 -0
  220. package/scripts/topo-click-ripple-test.mjs +66 -0
  221. package/scripts/topo-cmdk-edge-clear-test.mjs +109 -0
  222. package/scripts/topo-cmdk-layout-view-test.mjs +108 -0
  223. package/scripts/topo-cmdk-pin-test.mjs +123 -0
  224. package/scripts/topo-cmdk-vendor-clear-test.mjs +133 -0
  225. package/scripts/topo-cmdk-vendor-pin-test.mjs +103 -0
  226. package/scripts/topo-cwd-tooltip-test.mjs +64 -0
  227. package/scripts/topo-dblclick-reset-test.mjs +74 -0
  228. package/scripts/topo-dispatch-pulse-test.mjs +83 -0
  229. package/scripts/topo-edge-badge-click-pin-test.mjs +94 -0
  230. package/scripts/topo-edge-badge-hover-test.mjs +85 -0
  231. package/scripts/topo-edge-count-badge-test.mjs +86 -0
  232. package/scripts/topo-edge-endpoint-highlight-test.mjs +111 -0
  233. package/scripts/topo-edge-focus-test.mjs +113 -0
  234. package/scripts/topo-edge-hover-test.mjs +93 -0
  235. package/scripts/topo-edge-pill-test.mjs +105 -0
  236. package/scripts/topo-edge-tooltip-test.mjs +70 -0
  237. package/scripts/topo-endpoint-ring-test.mjs +93 -0
  238. package/scripts/topo-esc-clears-pin-test.mjs +98 -0
  239. package/scripts/topo-filter-pill-aliases-test.mjs +121 -0
  240. package/scripts/topo-filter-pill-body-click-test.mjs +112 -0
  241. package/scripts/topo-filter-pill-count-test.mjs +110 -0
  242. package/scripts/topo-filter-pill-mobile-test.mjs +90 -0
  243. package/scripts/topo-fit-key-test.mjs +106 -0
  244. package/scripts/topo-freshness-chip-test.mjs +74 -0
  245. package/scripts/topo-group-box-pinned-state-test.mjs +95 -0
  246. package/scripts/topo-group-count-test.mjs +68 -0
  247. package/scripts/topo-group-hover-test.mjs +82 -0
  248. package/scripts/topo-group-label-hover-test.mjs +119 -0
  249. package/scripts/topo-group-label-tint-test.mjs +99 -0
  250. package/scripts/topo-group-label-title-test.mjs +83 -0
  251. package/scripts/topo-group-pin-test.mjs +119 -0
  252. package/scripts/topo-group-status-pips-test.mjs +103 -0
  253. package/scripts/topo-groupbox-live-test.mjs +100 -0
  254. package/scripts/topo-groupbox-test.mjs +4 -2
  255. package/scripts/topo-help-overlay-r109-test.mjs +67 -0
  256. package/scripts/topo-help-overlay-r118-test.mjs +69 -0
  257. package/scripts/topo-help-overlay-r123-test.mjs +69 -0
  258. package/scripts/topo-help-overlay-r59-test.mjs +67 -0
  259. package/scripts/topo-help-overlay-r65-test.mjs +64 -0
  260. package/scripts/topo-help-overlay-r70-test.mjs +63 -0
  261. package/scripts/topo-help-overlay-r78-test.mjs +59 -0
  262. package/scripts/topo-help-overlay-r81-test.mjs +58 -0
  263. package/scripts/topo-help-overlay-r87-test.mjs +64 -0
  264. package/scripts/topo-help-overlay-r91-test.mjs +65 -0
  265. package/scripts/topo-hub-busyness-test.mjs +100 -0
  266. package/scripts/topo-hub-click-fit-test.mjs +137 -0
  267. package/scripts/topo-hub-hover-ring-test.mjs +82 -0
  268. package/scripts/topo-hub-title-test.mjs +93 -0
  269. package/scripts/topo-ingroup-edge-test.mjs +103 -0
  270. package/scripts/topo-label-lift-test.mjs +119 -0
  271. package/scripts/topo-label-scale-test.mjs +78 -0
  272. package/scripts/topo-lastseen-tooltip-test.mjs +59 -0
  273. package/scripts/topo-layout-key-test.mjs +91 -0
  274. package/scripts/topo-legend-count-test.mjs +88 -0
  275. package/scripts/topo-legend-hover-test.mjs +113 -0
  276. package/scripts/topo-legend-panel-header-test.mjs +83 -0
  277. package/scripts/topo-legend-pin-test.mjs +128 -0
  278. package/scripts/topo-legend-row-tint-test.mjs +95 -0
  279. package/scripts/topo-minimap-test.mjs +68 -0
  280. package/scripts/topo-mobile-chips-test.mjs +103 -0
  281. package/scripts/topo-node-hover-lift-test.mjs +85 -0
  282. package/scripts/topo-node-title-test.mjs +101 -0
  283. package/scripts/topo-offline-dim-test.mjs +70 -0
  284. package/scripts/topo-overlap-test.mjs +32 -5
  285. package/scripts/topo-pan-cursor-test.mjs +62 -0
  286. package/scripts/topo-panel-elevation-test.mjs +76 -0
  287. package/scripts/topo-particle-stagger-test.mjs +82 -0
  288. package/scripts/topo-pin-intersection-test.mjs +121 -0
  289. package/scripts/topo-pin-persistence-test.mjs +105 -0
  290. package/scripts/topo-popover-meta-test.mjs +78 -0
  291. package/scripts/topo-pressure-bar-test.mjs +69 -0
  292. package/scripts/topo-pressure-hover-test.mjs +123 -0
  293. package/scripts/topo-pressure-pin-test.mjs +119 -0
  294. package/scripts/topo-pressure-seg-tooltip-test.mjs +98 -0
  295. package/scripts/topo-pulse-rate-test.mjs +71 -0
  296. package/scripts/topo-recent-panel-count-test.mjs +72 -0
  297. package/scripts/topo-recent-row-hover-test.mjs +122 -0
  298. package/scripts/topo-recent-row-pin-test.mjs +126 -0
  299. package/scripts/topo-recent-row-tint-test.mjs +91 -0
  300. package/scripts/topo-recent-row-ts-test.mjs +92 -0
  301. package/scripts/topo-recent-signal-empty-hint-test.mjs +73 -0
  302. package/scripts/topo-recent-signal-empty-test.mjs +63 -0
  303. package/scripts/topo-reduced-motion-test.mjs +77 -0
  304. package/scripts/topo-status-chip-hover-test.mjs +101 -0
  305. package/scripts/topo-status-chip-tooltip-test.mjs +95 -0
  306. package/scripts/topo-tier-ring-occupancy-test.mjs +87 -0
  307. package/scripts/topo-tier-ring-test.mjs +69 -0
  308. package/scripts/topo-tier-ring-tint-test.mjs +110 -0
  309. package/scripts/topo-tier-stagger-test.mjs +89 -0
  310. package/scripts/topo-tz-ghost-test.mjs +69 -0
  311. package/scripts/topo-vendor-chip-hover-test.mjs +111 -0
  312. package/scripts/topo-vendor-letter-tooltip-test.mjs +92 -0
  313. package/scripts/topo-vendor-pill-test.mjs +125 -0
  314. package/scripts/topo-vendor-pin-test.mjs +125 -0
  315. package/scripts/topo-wheel-zoom-test.mjs +79 -0
  316. package/scripts/topo-working-halo-breath-test.mjs +86 -0
  317. package/scripts/topo-zoom-keys-test.mjs +89 -0
  318. package/.next/static/chunks/0-7iowzu4.798.js +0 -1
  319. package/.next/static/chunks/02t5jr4shh.hy.js +0 -1
  320. package/.next/static/chunks/03u_oa2o2jrr~.js +0 -1
  321. package/.next/static/chunks/0_qfg5s5uzs7..js +0 -7
  322. package/.next/static/chunks/0dttftqqehpdj.js +0 -1
  323. package/.next/static/chunks/0elalb9n_.~di.js +0 -1
  324. package/.next/static/chunks/0fqy_0wj_1xa7.js +0 -7
  325. package/.next/static/chunks/0j_q__o8p6g83.js +0 -1
  326. package/.next/static/chunks/0jl6_w50zh4l5.js +0 -1
  327. package/.next/static/chunks/0q_z8ad2uojv5.js +0 -1
  328. package/.next/static/chunks/0r4q73.-1bkyl.js +0 -1
  329. package/.next/static/chunks/0r_c8eccg3z2o.js +0 -1
  330. package/.next/static/chunks/10oi7l-tycgl9.js +0 -1
  331. package/.next/static/chunks/11uk1izuv3~0t.js +0 -1
  332. package/.next/static/chunks/17h~0~zsxjx6x.css +0 -2
  333. /package/.next/static/{nhazRrlYzcsAAYMwx8NFz → A3ssjbOXf7a3BaCWBznPU}/_buildManifest.js +0 -0
  334. /package/.next/static/{nhazRrlYzcsAAYMwx8NFz → A3ssjbOXf7a3BaCWBznPU}/_clientMiddlewareManifest.js +0 -0
  335. /package/.next/static/{nhazRrlYzcsAAYMwx8NFz → A3ssjbOXf7a3BaCWBznPU}/_ssgManifest.js +0 -0
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { Session } from './types';
5
5
  import { aliasAvatarColors, aliasInitial } from './AliasAvatar';
6
6
  import { ChatPopover } from './ChatPopover';
7
7
  import { vendorForModel, runtimeIdentity, identityLine } from '../lib/vendorIdentity';
8
+ import { parseHubTime, relativeAgo } from '../lib/time';
8
9
 
9
10
  interface MessageFlow {
10
11
  from_alias: string;
@@ -32,6 +33,9 @@ interface FlowLink {
32
33
  to: string;
33
34
  count: number;
34
35
  content: string;
36
+ /** ISO timestamp of the most recent message on this edge — drives
37
+ * the Round 10 freshness fade so dormant links recede visually. */
38
+ last_at: string;
35
39
  }
36
40
 
37
41
  const cx = 500;
@@ -85,36 +89,118 @@ function truncate(value: string, max: number) {
85
89
  return value.length > max ? `${value.slice(0, max - 1)}...` : value;
86
90
  }
87
91
 
92
+ /** Round 46 / Loop: SWR-freshness chip.
93
+ *
94
+ * TopoGraph's data refreshes every 5 s via SWR but the user has no
95
+ * visible signal that the canvas they're looking at is current. The
96
+ * flow particles and chips (R42 active-links, R43 hub) tell you when
97
+ * the FLEET last did something, not when the DATA last syncing. This
98
+ * chip ticks `live · Ns` against a 1-second interval so the operator
99
+ * can trust freshness without doing internal math.
100
+ *
101
+ * Owns its own setInterval so the parent topology doesn't re-render
102
+ * every second (only this small chip does). lastSyncRef is updated
103
+ * whenever the `sessions` prop reference changes — that's the SWR
104
+ * refresh signal. */
105
+ function FreshnessChip({ sessions }: { sessions: unknown }) {
106
+ const lastSyncRef = useRef<number>(Date.now());
107
+ const [now, setNow] = useState(() => Date.now());
108
+ useEffect(() => {
109
+ // sessions reference changed → SWR just delivered a new payload.
110
+ lastSyncRef.current = Date.now();
111
+ }, [sessions]);
112
+ useEffect(() => {
113
+ const id = setInterval(() => setNow(Date.now()), 1000);
114
+ return () => clearInterval(id);
115
+ }, []);
116
+ const sec = Math.max(0, Math.floor((now - lastSyncRef.current) / 1000));
117
+ // Tint the chip warmer when the data goes stale (>10s since last sync).
118
+ // SWR's default refreshInterval here is 5s, so anything past ~10s is
119
+ // either a poll miss or a network hiccup.
120
+ const stale = sec > 10;
121
+ return (
122
+ <span
123
+ className={stale
124
+ ? "hidden sm:inline px-2.5 py-1 rounded-md bg-amber-500/10 text-amber-300 border border-amber-500/20 font-mono"
125
+ : "hidden sm:inline px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20 font-mono"}
126
+ title={stale ? `Last sync ${sec}s ago — SWR refresh may be lagging` : `Live data · refreshes every 5s · last sync ${sec}s ago`}
127
+ data-freshness-chip
128
+ >
129
+ live · {sec}s
130
+ </span>
131
+ );
132
+ }
133
+
134
+ /** Round 36 / Loop: prefers-reduced-motion hook.
135
+ *
136
+ * Round 29's a11y sweep zeroed CSS animations via media query, but SVG
137
+ * SMIL `<animate>` / `<animateMotion>` elements aren't reachable from CSS
138
+ * — they need JS to opt out. This hook reads the media query and listens
139
+ * for changes so the topology's flow particles, pulses, click ripple
140
+ * and hub breath honour the OS-level preference. */
141
+ function useReducedMotion(): boolean {
142
+ const [reduced, setReduced] = useState(false);
143
+ useEffect(() => {
144
+ if (typeof window === 'undefined' || !window.matchMedia) return;
145
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
146
+ setReduced(mq.matches);
147
+ const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
148
+ mq.addEventListener('change', onChange);
149
+ return () => mq.removeEventListener('change', onChange);
150
+ }, []);
151
+ return reduced;
152
+ }
153
+
154
+ // Round 38 / Loop: parseHubTime + relativeAgo factored to app/lib/time.ts
155
+ // — the Round 35 fix lives there now alongside the Round 37 mirror so
156
+ // any future TZ-safe parse update happens in one place.
157
+
158
+ /** Round 12 / Loop: status trio audit.
159
+ *
160
+ * Each (status × theme) cell returns a {primary, halo, text} trio. The trio
161
+ * invariant — keep it when adding states or tweaking shades:
162
+ *
163
+ * light: primary = <hue>-600 halo = <hue>-100 * text = <hue>-800/900
164
+ * dark: primary = <hue>-400/500 halo = <hue>-900 text = <hue>-100
165
+ *
166
+ * * offline.halo light deviates intentionally to slate-200 (#e2e8f0):
167
+ * slate-100 (#f1f5f9) is too close to the panel bg (#f8fafc) and the
168
+ * halo would vanish. The other three rows keep the 100-shade.
169
+ *
170
+ * Audit caught one cross-family drift before this round: online-other halo
171
+ * light was #dbeafe (blue-100) while its primary (#0284c7 sky-600) and text
172
+ * (#0c4a6e sky-900) were sky-family — now sky-100 (#e0f2fe). Tiny visual
173
+ * difference; large hygiene win — every trio is now mono-hue. */
88
174
  function nodeStatus(session: Session, isOnline: boolean, isLight: boolean) {
89
175
  if (!isOnline) {
90
176
  return {
91
177
  label: 'offline',
92
- primary: isLight ? '#94a3b8' : '#6b7280',
93
- halo: isLight ? '#e2e8f0' : '#111827',
94
- text: isLight ? '#475569' : '#9ca3af',
178
+ primary: isLight ? '#94a3b8' : '#6b7280', // slate-400 / gray-500
179
+ halo: isLight ? '#e2e8f0' : '#111827', // slate-200* / gray-900
180
+ text: isLight ? '#475569' : '#9ca3af', // slate-600 / gray-400
95
181
  };
96
182
  }
97
183
  if (session.status === 'working') {
98
184
  return {
99
185
  label: 'working',
100
- primary: isLight ? '#059669' : '#22c55e',
101
- halo: isLight ? '#d1fae5' : '#14532d',
102
- text: isLight ? '#065f46' : '#dcfce7',
186
+ primary: isLight ? '#059669' : '#22c55e', // emerald-600 / green-500
187
+ halo: isLight ? '#d1fae5' : '#14532d', // emerald-100 / green-900
188
+ text: isLight ? '#065f46' : '#dcfce7', // emerald-800 / green-100
103
189
  };
104
190
  }
105
191
  if (session.status === 'idle') {
106
192
  return {
107
193
  label: 'idle',
108
- primary: isLight ? '#0d9488' : '#2dd4bf',
109
- halo: isLight ? '#ccfbf1' : '#134e4a',
110
- text: isLight ? '#115e59' : '#ccfbf1',
194
+ primary: isLight ? '#0d9488' : '#2dd4bf', // teal-600 / teal-400
195
+ halo: isLight ? '#ccfbf1' : '#134e4a', // teal-100 / teal-900
196
+ text: isLight ? '#115e59' : '#ccfbf1', // teal-800 / teal-100
111
197
  };
112
198
  }
113
199
  return {
114
200
  label: session.status || 'online',
115
- primary: isLight ? '#0284c7' : '#38bdf8',
116
- halo: isLight ? '#dbeafe' : '#0c4a6e',
117
- text: isLight ? '#0c4a6e' : '#e0f2fe',
201
+ primary: isLight ? '#0284c7' : '#38bdf8', // sky-600 / sky-400
202
+ halo: isLight ? '#e0f2fe' : '#0c4a6e', // sky-100 / sky-900 (was blue-100 — drift fixed Round 12)
203
+ text: isLight ? '#0c4a6e' : '#e0f2fe', // sky-900 / sky-100
118
204
  };
119
205
  }
120
206
 
@@ -312,12 +398,20 @@ function buildFlowLinks(messages: MessageFlow[], positions: Record<string, Point
312
398
  const key = `${message.from_alias}->${message.to_alias}`;
313
399
  const current = links.get(key);
314
400
 
401
+ // Keep the most-recent timestamp per pair so the render can fade
402
+ // dormant edges (Round 10 freshness fade).
403
+ const incoming = message.created_at || '';
404
+ const last_at = !current
405
+ ? incoming
406
+ : (incoming > current.last_at ? incoming : current.last_at);
407
+
315
408
  links.set(key, {
316
409
  key,
317
410
  from: message.from_alias,
318
411
  to: message.to_alias,
319
412
  count: (current?.count || 0) + 1,
320
413
  content: current?.content || message.content,
414
+ last_at,
321
415
  });
322
416
  });
323
417
 
@@ -327,6 +421,7 @@ function buildFlowLinks(messages: MessageFlow[], positions: Record<string, Point
327
421
  export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProps) {
328
422
  const theme = useTheme();
329
423
  const isLight = theme === 'light';
424
+ const reducedMotion = useReducedMotion();
330
425
  const pal = isLight ? LIGHT_PALETTE : DARK_PALETTE;
331
426
  const brand = useBrand();
332
427
  const isIntern = brand === 'intern';
@@ -388,6 +483,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
388
483
  activeAliases,
389
484
  groupKeys,
390
485
  groupBoxes,
486
+ gridContentBottom,
391
487
  } = useMemo(() => {
392
488
  const sseCount = (s: { alias: string; network_id?: string }) =>
393
489
  (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
@@ -397,17 +493,25 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
397
493
  // index, so contiguous-in-array becomes contiguous-in-ring, i.e.
398
494
  // each team visually clusters. localeCompare keeps CJK ordering sane.
399
495
  const byAlias = (a: Session, b: Session) => a.alias.localeCompare(b.alias);
400
- // #112 umbrella: ghost age-out — an offline node not seen for over a day
401
- // is almost certainly a deleted node the server `/api/status` still
496
+ // #112 umbrella: ghost age-out — an offline node not seen recently is
497
+ // almost certainly a deleted node the server `/api/status` still
402
498
  // returns (#74 root cause is server-side; this is the dashboard-side
403
499
  // fallback so stale ghosts stop cluttering the topology). A missing
404
500
  // last_seen_at is kept (conservative — could be a legitimately new node).
405
- const GHOST_MS = 24 * 60 * 60 * 1000;
501
+ // Round 27 / P0: the 24h threshold was too lenient — Vincent's preview.29
502
+ // screenshot showed B站马 nodes deleted ~4 h earlier still visible. A
503
+ // healthy agent heartbeats every few seconds; if it's been silent for
504
+ // an hour it's effectively gone. 1 h gives a fresh disconnect time to
505
+ // come back while removing dead nodes well within an operator session.
506
+ const GHOST_MS = 60 * 60 * 1000;
406
507
  const now = Date.now();
407
508
  const isGhost = (s: Session) => {
408
509
  if (s.status !== 'offline' || sseCount(s) || !s.last_seen_at) return false;
409
- const t = Date.parse(s.last_seen_at);
410
- return !Number.isNaN(t) && now - t > GHOST_MS;
510
+ // Round 35 / Loop: parseHubTime normalises SQL-style timestamps to UTC
511
+ // before parsing so non-UTC browsers don't see a phantom 8-hour skew
512
+ // and ghost freshly-disconnected nodes.
513
+ const t = parseHubTime(s.last_seen_at);
514
+ return t !== null && now - t > GHOST_MS;
411
515
  };
412
516
  const online = sessions.filter(s => s.status !== 'offline' || sseCount(s)).sort(byAlias);
413
517
  const offline = sessions.filter(s => s.status === 'offline' && !sseCount(s) && !isGhost(s)).sort(byAlias);
@@ -470,11 +574,25 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
470
574
  // padding always fit and stacked group boxes never touch; past the
471
575
  // floor the grid overflows and zoom/pan handles it.
472
576
  const GROUP_TOP = nodeR + 20;
473
- // Row-height floor = the tightest of: a node + its label below it
474
- // (2·nodeR + ~22), and the label band + min box padding (GROUP_TOP + 12).
475
- // Below this, rows would overlap; past it the grid overflows and
476
- // zoom/pan covers the rest.
477
- const cellH = Math.max(2 * nodeR + 22, GROUP_TOP + 12, Math.min(100, (gy1 - gy0) / totalRows));
577
+ // Round 27 / P0 (Vincent screenshot preview.29): dense plain-text
578
+ // node labels (`pos.y + radius + denseDrop`, with a 3 px containerBg
579
+ // stroke halo for readability) at the BOTTOM row of band N would
580
+ // paint OVER the start of band N+1's group label, creating the
581
+ // "blueleap eleap", "agent-network-dashboard t-network / board"
582
+ // visual chopping. Geometry of the collision:
583
+ // dense label visual bottom = node_N.y + radius + denseDrop + halo
584
+ // group label glyph top = boxY + 4 (text y=boxY+14, ~10px ascent)
585
+ // = node_N+1.y - GROUP_TOP + 4
586
+ // node_N+1.y - node_N.y = cellH for consecutive rows, so no-collide:
587
+ // cellH ≥ radius + denseDrop + halo + GROUP_TOP - 4 + buffer
588
+ // With halo=3 buffer=4: cellH ≥ nodeR + denseDrop + GROUP_TOP + 3.
589
+ const denseDrop = nodeScale < 0.8 ? 12 : 14;
590
+ const cellH = Math.max(
591
+ 2 * nodeR + 22, // node + dense label within band
592
+ GROUP_TOP + 12, // group-label band + box padding
593
+ nodeR + denseDrop + GROUP_TOP + 3, // round-27 dense↔group label clearance
594
+ Math.min(100, (gy1 - gy0) / totalRows),
595
+ );
478
596
 
479
597
  // Pass 2 — place each band's members.
480
598
  for (const band of bands) {
@@ -505,14 +623,36 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
505
623
  const xs = pts.map(p => p.x);
506
624
  const ys = pts.map(p => p.y);
507
625
  const minX = Math.min(...xs), minY = Math.min(...ys);
626
+ // Round 58 / Loop: per-group status mix for the label pip strip.
627
+ // Working = status==='working'. Idle = online but not working.
628
+ // Offline = !isOnline (either status==='offline' AND no SSE, or
629
+ // ghost-purged elsewhere — but ghosts never reach groupBoxes
630
+ // since they're filtered out upstream). Counts feed the label
631
+ // tspans directly so the strip stays inside the label's bbox,
632
+ // preserving the node↔label overlap-test guarantee from R19.
633
+ let w = 0, i = 0, o = 0;
634
+ for (const s of band.members) {
635
+ const isOn = s.status !== 'offline' || !!sseCount(s);
636
+ if (s.status === 'working') w++;
637
+ else if (isOn) i++;
638
+ else o++;
639
+ }
508
640
  return {
509
641
  key: band.members.length ? groupKeys[band.members[0].alias] : '',
642
+ count: band.members.length,
643
+ statuses: { working: w, idle: i, offline: o },
510
644
  x: minX - GROUP_PAD,
511
645
  y: minY - GROUP_TOP,
512
646
  w: Math.max(...xs) - minX + GROUP_PAD * 2,
513
647
  h: Math.max(...ys) - minY + GROUP_TOP + GROUP_PAD,
514
648
  };
515
649
  });
650
+ // Round 28 / Loop: surface the grid's natural content bottom so the
651
+ // mount effect can auto-fit zoom when the layout would overflow the
652
+ // viewBox. = bottom of the last node row + its label drop + a small
653
+ // breathing buffer. Round 27's cellH bump makes this overflow more
654
+ // common (30-node fleets reach ~774 px, viewBox is 680).
655
+ const gridContentBottom = gy0 + totalRows * cellH + 8;
516
656
  return {
517
657
  onlineNodes: online,
518
658
  offlineNodes: offline,
@@ -521,6 +661,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
521
661
  activeAliases: active,
522
662
  groupKeys,
523
663
  groupBoxes,
664
+ gridContentBottom,
524
665
  };
525
666
  }
526
667
 
@@ -602,16 +743,231 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
602
743
  groupKeys,
603
744
  // #111: group boxes are a grid-layout feature only — radially scattered
604
745
  // ring nodes can't be cleanly boxed. Ring keeps the #83 prefix hue.
605
- groupBoxes: [] as { key: string; x: number; y: number; w: number; h: number }[],
746
+ groupBoxes: [] as { key: string; count: number; statuses: { working: number; idle: number; offline: number }; x: number; y: number; w: number; h: number }[],
747
+ // ring fits within VIEWBOX_H by construction (offlineRadius=325 + centre at y=330)
748
+ gridContentBottom: 0,
606
749
  };
607
750
  }, [messages, sessions, sseSessions, layout, nodeScale]);
608
751
 
609
752
  const workingCount = onlineNodes.filter(s => s.status === 'working').length;
753
+ // Round 6 / Loop: vendor distribution for the header chip — at a glance
754
+ // "what's in the fleet" (A:5 M:2 O:8 书:12 …) without opening a node.
755
+ // Sorted by count desc; "unknown" vendors collapse into a "?" bucket.
756
+ const vendorDist = useMemo(() => {
757
+ const tally = new Map<string, { initial: string; count: number; color: string }>();
758
+ for (const s of [...onlineNodes, ...offlineNodes]) {
759
+ const v = vendorForModel(s.model);
760
+ const key = v.id === 'unknown' ? '?' : v.initial;
761
+ const cur = tally.get(key);
762
+ if (cur) cur.count++;
763
+ else tally.set(key, { initial: key, count: 1, color: v.mono.text });
764
+ }
765
+ return [...tally.values()].sort((a, b) => b.count - a.count);
766
+ }, [onlineNodes, offlineNodes]);
610
767
  // Round 109 (Vincent 4582 P0): hover-gated labels above this node count
611
768
  // so dense fleets show clean avatars instead of a wall of overlapping
612
769
  // label cards. 16 ≈ where the triple-tier rings start to crowd.
613
770
  const denseLayout = onlineNodes.length + offlineNodes.length > 16;
614
771
  const [hoveredAlias, setHoveredAlias] = useState<string | null>(null);
772
+ // Round 86 / Loop: pointer-over-label preview. R63 wired the group
773
+ // label to click-pin but hover gave no preview — the same hover/click
774
+ // gap R83 closed for pressure-bar segments. This state lets the
775
+ // label trigger the same dim mechanism a node hover already drives,
776
+ // independently of whether the cursor happens to be over a member
777
+ // node. ORs into hoveredGroup below so the existing derivation logic
778
+ // (which composes with hoveredAlias, activeGroup, R85 marching ants)
779
+ // keeps working unchanged.
780
+ const [hoveredGroupLabel, setHoveredGroupLabel] = useState<string | null>(null);
781
+ // Round 8 / Loop: which group is currently focused. Nodes outside this
782
+ // group + other group boxes fade so the eye locks onto the team you're
783
+ // pointing at. Singletons use their own alias as the group key.
784
+ const hoveredGroup = hoveredGroupLabel
785
+ ?? (hoveredAlias ? (groupKeys[hoveredAlias] ?? hoveredAlias) : null);
786
+ // Round 63 / Loop: sticky group focus. R8 dims non-group nodes while
787
+ // a member is hovered, but releasing the hover lets the focus fade.
788
+ // Clicking the group label pins the group key here; activeGroup =
789
+ // hoveredGroup ?? pinnedGroup so hover transiently overrides the
790
+ // pin (handy for spot-comparing teams). Same compose pattern as
791
+ // R60/R61 pinnedStatus.
792
+ const [pinnedGroup, setPinnedGroup] = useState<string | null>(() => {
793
+ // R66: same per-tab persistence treatment as pinnedStatus above.
794
+ // The group key is opaque text (could be any prefix), so the init
795
+ // reader can't validate it against a known enum — it just reads
796
+ // whatever's stored. A useEffect below clears it if the value no
797
+ // longer matches any current group (sessions changed since save).
798
+ if (typeof window === 'undefined') return null;
799
+ try { return sessionStorage.getItem('anet-topo-pinned-group'); } catch { return null; }
800
+ });
801
+ const activeGroup = hoveredGroup ?? pinnedGroup;
802
+ // R66: sync pinnedGroup into sessionStorage when it changes; clear
803
+ // it if it no longer matches any current group (the session set
804
+ // can change between loads). The pinnedStatus sync sits next to
805
+ // its declaration further down.
806
+ useEffect(() => {
807
+ try {
808
+ if (pinnedGroup) sessionStorage.setItem('anet-topo-pinned-group', pinnedGroup);
809
+ else sessionStorage.removeItem('anet-topo-pinned-group');
810
+ } catch {}
811
+ }, [pinnedGroup]);
812
+ useEffect(() => {
813
+ if (!pinnedGroup) return;
814
+ const known = new Set(Object.values(groupKeys));
815
+ if (!known.has(pinnedGroup)) setPinnedGroup(null);
816
+ }, [pinnedGroup, groupKeys]);
817
+ // Round 49 / Loop: reverse-direction of R40's edge-on-node-hover linkage.
818
+ // R48 widened the flow hitbox to 16 px, so edges are precise enough to
819
+ // serve as a state trigger. When the user hovers a flow edge, light up its
820
+ // two endpoint nodes and dim the rest — "who is this edge between" becomes
821
+ // visible without reading the tooltip. The set is the link's two aliases
822
+ // (null when no edge hovered); node opacity composes this after inFocus.
823
+ const [hoveredEdgeKey, setHoveredEdgeKey] = useState<string | null>(null);
824
+ // Round 116 / Loop: sticky variant of hoveredEdgeKey. R56 made the
825
+ // recent-signal rows brighten the matching edge on hover, but the
826
+ // filter released on mouseleave — no way to lock a flow for a
827
+ // closer look. Click-to-pin closes that gap, matching the
828
+ // established hover→pin idiom (R60 status, R61 legend, R63 group,
829
+ // R88 vendor). activeEdgeKey = hoveredEdgeKey ?? pinnedEdgeKey
830
+ // below so hover still wins for spot-comparison while a pin is set.
831
+ const [pinnedEdgeKey, setPinnedEdgeKey] = useState<string | null>(null);
832
+ const activeEdgeKey = hoveredEdgeKey ?? pinnedEdgeKey;
833
+ // Round 77 / Loop: hovering the "N active links" header chip globally
834
+ // brightens every flow edge (1.5× opacity). Transient affordance — the
835
+ // chip already says HOW MANY active flows exist; this answers WHERE
836
+ // they are in one glance, without the user scanning the canvas for
837
+ // moving particles. Reset on mouse leave.
838
+ const [hoveredActiveLinks, setHoveredActiveLinks] = useState(false);
839
+ // Round 115 / Loop: hub-center hover state. R52 made the hub
840
+ // clickable (fit view) + cursor:pointer, but there was no
841
+ // hover-time visual feedback — the click target felt guessed at
842
+ // rather than confirmed. This state drives a subtle hint ring on
843
+ // hover so users see the affordance before committing the click.
844
+ const [hoveredHub, setHoveredHub] = useState(false);
845
+ // Round 80 / Loop: vendor-letter hover in the distribution chip. The
846
+ // chip already names vendor mix (`C:5 G:3 ?:1`); hover a letter and
847
+ // every node from OTHER vendors dims. Surfaces the breakdown spatially
848
+ // without inventing a new pin slot. Stores the vendor `initial`
849
+ // (single char or "?") that matches the tally key.
850
+ const [hoveredVendor, setHoveredVendor] = useState<string | null>(null);
851
+ // Round 88 / Loop: vendor filter pin. R80 added hover-to-dim on the
852
+ // vendor letters but the filter released as soon as the cursor left
853
+ // — vendor was the only filter dimension without a sticky variant
854
+ // (R60 status, R63 group, R69 Cmd+K all support pin). This state
855
+ // closes the gap. Same pattern as pinnedStatus / pinnedGroup:
856
+ // per-tab sessionStorage persistence, Esc clears, activeVendor =
857
+ // hoveredVendor ?? pinnedVendor so hover still wins for spot-
858
+ // comparison while a pin is active.
859
+ const [pinnedVendor, setPinnedVendor] = useState<string | null>(() => {
860
+ if (typeof window === 'undefined') return null;
861
+ try { return sessionStorage.getItem('anet-topo-pinned-vendor'); } catch { return null; }
862
+ });
863
+ const activeVendor = hoveredVendor ?? pinnedVendor;
864
+ useEffect(() => {
865
+ try {
866
+ if (pinnedVendor) sessionStorage.setItem('anet-topo-pinned-vendor', pinnedVendor);
867
+ else sessionStorage.removeItem('anet-topo-pinned-vendor');
868
+ } catch {}
869
+ }, [pinnedVendor]);
870
+ // R89: stale-purge. If the fleet's vendor distribution changes such
871
+ // that a previously-pinned vendor is gone (last node of that
872
+ // vendor disconnected), clear the pin so the chip row doesn't
873
+ // show "filter: A · 0" forever. Matches the same defensive purge
874
+ // pattern pinnedGroup uses higher up — sessionStorage survives
875
+ // reloads, but a stored value that no longer matches reality
876
+ // would paint with an impossible filter.
877
+ useEffect(() => {
878
+ if (pinnedVendor && !vendorDist.some(v => v.initial === pinnedVendor)) {
879
+ setPinnedVendor(null);
880
+ }
881
+ }, [pinnedVendor, vendorDist]);
882
+ // Round 55 / Loop: hovering a legend status row dims nodes whose status
883
+ // doesn't match. The legend was passive — "what does this colour mean".
884
+ // Now it answers "show me all of these" the same way R8 group-focus
885
+ // answers "show me this team". Three values match the legend rows.
886
+ const [hoveredStatus, setHoveredStatus] = useState<'working' | 'idle' | 'offline' | null>(null);
887
+ // Round 60 / Loop: sticky variant of `hoveredStatus`. R55 only filters
888
+ // while the user is actively hovering the legend; for sweeping a fleet
889
+ // you want to LOCK the filter. Each segment of the R31 pressure bar
890
+ // toggles a pin — click again on the same segment to release. The node
891
+ // opacity formula reads `activeStatus = hoveredStatus ?? pinnedStatus`
892
+ // so hover transiently overrides a pin (handy for spot-comparison)
893
+ // without nuking it.
894
+ // Round 66 / Loop: pin survives a page reload via sessionStorage —
895
+ // per-tab not per-browser (a new tab starts clean, intentionally).
896
+ // The init reader validates against the known status set so a stale
897
+ // / corrupt value can't paint the canvas with an impossible filter.
898
+ const [pinnedStatus, setPinnedStatus] = useState<'working' | 'idle' | 'offline' | null>(() => {
899
+ if (typeof window === 'undefined') return null;
900
+ try {
901
+ const v = sessionStorage.getItem('anet-topo-pinned-status');
902
+ return (v === 'working' || v === 'idle' || v === 'offline') ? v : null;
903
+ } catch { return null; }
904
+ });
905
+ const activeStatus = hoveredStatus ?? pinnedStatus;
906
+ // R66: sync pinnedStatus into sessionStorage when it changes. Paired
907
+ // with the matching effect for pinnedGroup higher up.
908
+ useEffect(() => {
909
+ try {
910
+ if (pinnedStatus) sessionStorage.setItem('anet-topo-pinned-status', pinnedStatus);
911
+ else sessionStorage.removeItem('anet-topo-pinned-status');
912
+ } catch {}
913
+ }, [pinnedStatus]);
914
+ // R69: listen for Cmd+K palette pin actions. The palette can't reach
915
+ // this component's state directly so it dispatches a CustomEvent;
916
+ // we react by setting pinnedStatus / pinnedGroup. The palette also
917
+ // writes to sessionStorage in lockstep, so the R66 init readers
918
+ // would pick it up on reload — the event handler is what keeps the
919
+ // currently-mounted canvas in sync without one.
920
+ useEffect(() => {
921
+ const onPin = (e: Event) => {
922
+ const detail = (e as CustomEvent).detail || {};
923
+ if (detail.kind === 'clear') {
924
+ // R90: extend the universal clear to vendor. R69 only cleared
925
+ // status + group because pinnedVendor didn't exist yet; R88
926
+ // shipped the third pin and R90 closes the palette gap so
927
+ // "Clear topology filters" really means all of them.
928
+ // R117: extend again to pinnedEdgeKey — R116 added the 4th
929
+ // pin dim and this command must clear it too or the edge stays
930
+ // bright in the canvas while every other pill clears.
931
+ setPinnedStatus(null);
932
+ setPinnedGroup(null);
933
+ setPinnedVendor(null);
934
+ setPinnedEdgeKey(null);
935
+ } else if (detail.kind === 'clear-vendor') {
936
+ // R90: granular vendor-only clear so power users can release
937
+ // just the vendor without disturbing status/group pins.
938
+ setPinnedVendor(null);
939
+ } else if (detail.kind === 'clear-edge') {
940
+ // R117: granular edge-only clear, mirror of R90 clear-vendor.
941
+ setPinnedEdgeKey(null);
942
+ } else if (detail.kind === 'status') {
943
+ const v = detail.value;
944
+ if (v === 'working' || v === 'idle' || v === 'offline') setPinnedStatus(v);
945
+ } else if (detail.kind === 'group' && typeof detail.value === 'string') {
946
+ setPinnedGroup(detail.value);
947
+ } else if (detail.kind === 'vendor' && typeof detail.value === 'string') {
948
+ // R108: palette pin-vendor support. R69 added pin-status, R88
949
+ // added clickable vendor letters in the chip row, R90 added
950
+ // granular clear-vendor — but the palette never gained a
951
+ // PIN-vendor command. This branch listens for it; the matching
952
+ // commands live in CommandPalette R108. The stale-purge
953
+ // useEffect higher up already drops a pin whose vendor isn't
954
+ // in the current distribution, so commands for an absent
955
+ // vendor are harmless.
956
+ setPinnedVendor(detail.value);
957
+ }
958
+ };
959
+ window.addEventListener('anet:topo-pin', onPin);
960
+ return () => window.removeEventListener('anet:topo-pin', onPin);
961
+ }, []);
962
+ // R74 listener for layout + view palette commands lives below
963
+ // fitView's declaration — see further down in the file.
964
+ const hoveredEdgeEndpoints = useMemo<Set<string> | null>(() => {
965
+ // R116: compose hover ?? pin so pinning a row via click keeps the
966
+ // endpoint ring + edge ladder lit after mouseleave.
967
+ if (!activeEdgeKey) return null;
968
+ const link = flowLinks.find(l => l.key === activeEdgeKey);
969
+ return link ? new Set([link.from, link.to]) : null;
970
+ }, [activeEdgeKey, flowLinks]);
615
971
 
616
972
  // --- Round 103 (issue #81): fullscreen + zoom + pan interaction layer ---
617
973
  // DIY native (no d3 / svg-pan-zoom): wrap the topology content in a single
@@ -631,6 +987,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
631
987
  // Issue #100: singleton chat popover. One alias at a time — clicking
632
988
  // another node swaps the target and the conversation switches in place.
633
989
  const [chatAlias, setChatAlias] = useState<string | null>(null);
990
+ // Round 14 / Loop: one-shot click ripple — fades outward from the clicked
991
+ // node so the click registers physically before the popover snaps in.
992
+ // Pairs with the Round 11 chat-focus ring: ripple expands, ring locks on.
993
+ // Keyed by ts so re-clicking the same node remounts the <circle> and the
994
+ // SMIL <animate> replays. Cleared 600ms after click (longer than the
995
+ // 500ms animation so a final frame at opacity 0 is still in the tree).
996
+ const [clickRipple, setClickRipple] = useState<{
997
+ ts: number; x: number; y: number; r0: number; color: string;
998
+ } | null>(null);
634
999
  // #84: when a node is renamed while its chat popover is open, follow the
635
1000
  // rename so the conversation keeps targeting a live alias.
636
1001
  useEffect(() => {
@@ -661,6 +1026,42 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
661
1026
  } catch {}
662
1027
  }, []);
663
1028
 
1029
+ // Round 28 / Loop: first-paint auto-fit. Round 27's cellH bump means
1030
+ // dense grids (≥6-7 rows) overflow the 680-px viewBox at the natural
1031
+ // 100% zoom — a new user sees the topology with its bottom rows
1032
+ // clipped and no hint that there's more below. Auto-fit on first
1033
+ // paint (and only if the user has no persisted view) sets the
1034
+ // initial zoom so all content fits. Subsequent zoom changes persist
1035
+ // and override the auto-fit on reload; explicit reset (0 key) still
1036
+ // goes back to 100% so the gesture's "reset to natural size" semantic
1037
+ // is preserved.
1038
+ //
1039
+ // Capture pre-mount persistence in a useState initializer — the
1040
+ // existing `persist` effect (declared below) runs on first render
1041
+ // with the default {1,0,0} view and writes it to localStorage before
1042
+ // the auto-fit effect (deps on async-arriving sessions) gets a turn.
1043
+ // Reading localStorage from the effect would see that write and
1044
+ // skip the fit. The useState snapshot fires once, before any effects.
1045
+ const [hadPersistedViewOnMount] = useState<boolean>(
1046
+ () => typeof window !== 'undefined' && !!localStorage.getItem('anet-topo-view'),
1047
+ );
1048
+ const autoFitDoneRef = useRef(false);
1049
+ useEffect(() => {
1050
+ if (autoFitDoneRef.current) return;
1051
+ if (hadPersistedViewOnMount) {
1052
+ autoFitDoneRef.current = true;
1053
+ return;
1054
+ }
1055
+ if (layout !== 'grid' || sessions.length === 0 || !gridContentBottom) return;
1056
+ if (gridContentBottom <= VIEWBOX_H) {
1057
+ autoFitDoneRef.current = true; // no overflow → no fit needed
1058
+ return;
1059
+ }
1060
+ const fitZoom = Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
1061
+ setView({ zoom: fitZoom, x: 0, y: 0 });
1062
+ autoFitDoneRef.current = true;
1063
+ }, [layout, sessions.length, gridContentBottom, hadPersistedViewOnMount]);
1064
+
664
1065
  // persist view
665
1066
  useEffect(() => {
666
1067
  try { localStorage.setItem('anet-topo-view', JSON.stringify(view)); } catch {}
@@ -680,6 +1081,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
680
1081
  const svg = svgRef.current;
681
1082
  if (!svg) return;
682
1083
  const onWheel = (e: WheelEvent) => {
1084
+ // Round 23 / Loop: only zoom on Ctrl/Meta+wheel when inline; plain
1085
+ // wheel over the canvas keeps scrolling the page (the topo sits at
1086
+ // the top of /; trapping page scroll mid-page is the classic
1087
+ // "scroll-jail" anti-pattern). In fullscreen the canvas owns the
1088
+ // viewport so any wheel zooms — no page scroll to preserve. Pinch-
1089
+ // zoom on a trackpad surfaces as ctrlKey=true natively, which is
1090
+ // also what we want (zoom the canvas, not the browser).
1091
+ if (!isFullscreen && !e.ctrlKey && !e.metaKey) return;
683
1092
  e.preventDefault();
684
1093
  const rect = svg.getBoundingClientRect();
685
1094
  const mx = ((e.clientX - rect.left) / rect.width) * VIEWBOX_W;
@@ -700,8 +1109,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
700
1109
  };
701
1110
  svg.addEventListener('wheel', onWheel, { passive: false });
702
1111
  return () => svg.removeEventListener('wheel', onWheel);
703
- }, []);
704
-
1112
+ // Re-attach when the fullscreen flag toggles so the handler's
1113
+ // closure picks up the new value (capture-by-closure means we'd
1114
+ // otherwise read the stale boolean forever).
1115
+ }, [isFullscreen]);
1116
+
1117
+ // Round 21 / Loop: pan-aware cursor. Static `grab` gave no tactile cue
1118
+ // that the canvas was actually being dragged — "grabbing" while
1119
+ // dragRef.current.active = true tells the hand it's moving the
1120
+ // viewport. Mirroring dragRef.active into state is the only way to
1121
+ // re-render the SVG style; the ref itself doesn't trigger renders.
1122
+ const [isPanning, setIsPanning] = useState(false);
705
1123
  const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
706
1124
  if (e.button !== 0) return;
707
1125
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
@@ -712,6 +1130,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
712
1130
  baseX: viewRef.current.x,
713
1131
  baseY: viewRef.current.y,
714
1132
  };
1133
+ setIsPanning(true);
715
1134
  };
716
1135
  const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
717
1136
  const d = dragRef.current;
@@ -726,6 +1145,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
726
1145
  const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
727
1146
  if (!dragRef.current.active) return;
728
1147
  dragRef.current.active = false;
1148
+ setIsPanning(false);
729
1149
  try {
730
1150
  (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
731
1151
  } catch {}
@@ -743,6 +1163,91 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
743
1163
  };
744
1164
  const resetView = () => setView({ zoom: 1, x: 0, y: 0 });
745
1165
 
1166
+ // Round 29 / Loop: `f` = fit-to-content. Shared by the Round 28
1167
+ // first-paint auto-fit effect and the keyboard handler so the math is
1168
+ // in one place. When content already fits at natural zoom, this is
1169
+ // effectively a "recenter" — `f` always lands on a known good view.
1170
+ const fitView = useCallback(() => {
1171
+ const zoom = !gridContentBottom || gridContentBottom <= VIEWBOX_H
1172
+ ? 1
1173
+ : Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
1174
+ setView({ zoom, x: 0, y: 0 });
1175
+ }, [gridContentBottom]);
1176
+
1177
+ // R74: listen for layout + view palette commands. Sister to R69's
1178
+ // pin listener — palette dispatches a CustomEvent, the reducer here
1179
+ // calls toggleLayout / fitView. Sits below fitView's declaration so
1180
+ // the deps list resolves cleanly.
1181
+ useEffect(() => {
1182
+ const onLayout = (e: Event) => {
1183
+ const detail = (e as CustomEvent).detail || {};
1184
+ if (detail.kind === 'toggle') toggleLayout();
1185
+ };
1186
+ const onView = (e: Event) => {
1187
+ const detail = (e as CustomEvent).detail || {};
1188
+ if (detail.kind === 'fit') fitView();
1189
+ };
1190
+ window.addEventListener('anet:topo-layout', onLayout);
1191
+ window.addEventListener('anet:topo-view', onView);
1192
+ return () => {
1193
+ window.removeEventListener('anet:topo-layout', onLayout);
1194
+ window.removeEventListener('anet:topo-view', onView);
1195
+ };
1196
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1197
+ }, [fitView]);
1198
+
1199
+ // Round 22 / Loop: keyboard zoom — +/= zoom in, - zoom out, 0 reset.
1200
+ // Round 29 / Loop: +f to fit content.
1201
+ // Listen on window so the user doesn't need to focus the SVG first,
1202
+ // but only act when no text input has focus (otherwise typing "-" in
1203
+ // a chat would zoom the topology — surprising and bad). Modifier keys
1204
+ // pass through so Cmd/Ctrl combos still hit their owners. The button
1205
+ // titles below document these shortcuts so users discover them.
1206
+ useEffect(() => {
1207
+ const onKey = (e: KeyboardEvent) => {
1208
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
1209
+ const ae = document.activeElement as HTMLElement | null;
1210
+ if (ae) {
1211
+ const tag = ae.tagName;
1212
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || ae.isContentEditable) return;
1213
+ }
1214
+ if (e.key === '+' || e.key === '=') { zoomBy(1.2); e.preventDefault(); }
1215
+ else if (e.key === '-' || e.key === '_') { zoomBy(1 / 1.2); e.preventDefault(); }
1216
+ else if (e.key === '0') { resetView(); e.preventDefault(); }
1217
+ else if (e.key === 'f' || e.key === 'F') { fitView(); e.preventDefault(); }
1218
+ // Round 32 / Loop: `l` toggles ring|grid. The vim-style `g l` route
1219
+ // (Audit Log) requires a preceding `g` within 1500ms; a bare `l`
1220
+ // outside that window is free for topology use.
1221
+ else if (e.key === 'l' || e.key === 'L') { toggleLayout(); e.preventDefault(); }
1222
+ // Round 62 / Loop: Esc clears the R60/R61 pinned status filter so
1223
+ // users have a universal-cancel keyboard out. Esc on an open chat
1224
+ // is owned by ChatPopover (which only mounts when chatAlias is
1225
+ // set), so this handler is effectively scoped to "no chat open".
1226
+ // We additionally guard on chatAlias to be explicit — if the chat
1227
+ // closed mid-cycle, the pin can still be cleared on the next Esc.
1228
+ // R63: extends to pinnedGroup too. Clears WHATEVER pin is active
1229
+ // (one Esc collapses all topology pins) so the keyboard escape
1230
+ // route stays a single key, not "Esc maybe Esc again".
1231
+ else if (e.key === 'Escape' && !chatAlias && (pinnedStatus || pinnedGroup || pinnedVendor || pinnedEdgeKey)) {
1232
+ // R88/R116: extend the universal-cancel to vendor + edge too.
1233
+ // One Esc collapses every topology pin (matches R62/R63's
1234
+ // "single key, not Esc-maybe-Esc-again" promise).
1235
+ if (pinnedStatus) setPinnedStatus(null);
1236
+ if (pinnedGroup) setPinnedGroup(null);
1237
+ if (pinnedVendor) setPinnedVendor(null);
1238
+ if (pinnedEdgeKey) setPinnedEdgeKey(null);
1239
+ e.preventDefault();
1240
+ }
1241
+ };
1242
+ window.addEventListener('keydown', onKey);
1243
+ return () => window.removeEventListener('keydown', onKey);
1244
+ // zoomBy / resetView are stable wrt setView callback; fitView changes
1245
+ // with gridContentBottom so deps list catches it. R62 adds chatAlias
1246
+ // and pinnedStatus so the Escape branch reads fresh state (re-binding
1247
+ // the listener on these state changes is sub-ms — cheaper than refs).
1248
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1249
+ }, [fitView, chatAlias, pinnedStatus, pinnedGroup, pinnedVendor, pinnedEdgeKey]);
1250
+
746
1251
  const toggleFullscreen = () => {
747
1252
  const el = containerRef.current;
748
1253
  if (!el) return;
@@ -770,6 +1275,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
770
1275
  <button
771
1276
  onClick={() => { if (layout !== 'ring') toggleLayout(); }}
772
1277
  aria-pressed={layout === 'ring'}
1278
+ title="Ring layout (l to toggle)"
773
1279
  className={`px-2.5 py-1 transition-colors ${layout === 'ring' ? 'bg-cyan-500/15 text-cyan-300' : 'text-gray-500 hover:text-gray-400'}`}
774
1280
  >
775
1281
  Ring
@@ -777,20 +1283,565 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
777
1283
  <button
778
1284
  onClick={() => { if (layout !== 'grid') toggleLayout(); }}
779
1285
  aria-pressed={layout === 'grid'}
1286
+ title="Grid layout (l to toggle)"
780
1287
  className={`px-2.5 py-1 border-l border-gray-500/25 transition-colors ${layout === 'grid' ? 'bg-cyan-500/15 text-cyan-300' : 'text-gray-500 hover:text-gray-400'}`}
781
1288
  >
782
1289
  Grid
783
1290
  </button>
784
1291
  </div>
785
- <span className="px-2.5 py-1 rounded-md bg-green-500/10 text-green-300 border border-green-500/20">
786
- {workingCount} working
787
- </span>
788
- <span className="px-2.5 py-1 rounded-md bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
789
- {onlineNodes.length} online
790
- </span>
791
- <span className="px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20">
792
- {flowLinks.length} active links
793
- </span>
1292
+ {/* R79: working + online count chips become hover affordances —
1293
+ extends R77's chip-hover pattern to status counts. Hover the
1294
+ working chip → setHoveredStatus('working') so the R55 dim
1295
+ mechanic kicks in (same as hovering the SVG legend "working"
1296
+ row, just a different surface for the same gesture).
1297
+ The online chip uses the same setHoveredStatus('idle') path
1298
+ when there's idle but no working falling back to working
1299
+ if any working node exists; "online" without a single bucket
1300
+ isn't part of the R55 type set, so this chip routes to the
1301
+ dominant online sub-tier instead of inventing a new state.
1302
+ Cursor only flips when there's anything to highlight. */}
1303
+ {/* R82: pin-mirror. R60 pressure-bar segments visualise the
1304
+ pinned status via an inset boxShadow; R61 legend rows via a
1305
+ concentric r=8 ring; the chip-row chips next to them did
1306
+ not — so a user who pinned via Cmd+K (R69) or the legend
1307
+ had no chip-row signal that "working" was the current
1308
+ filter. Mirror the pressure-bar treatment here so all
1309
+ status surfaces sing in unison. The online chip mirrors
1310
+ for idle pins (working ⊆ online; the routing in
1311
+ onMouseEnter already treats online as the idle fallback). */}
1312
+ {(() => {
1313
+ // R113 / Loop: extend the R97-R102/R101 alias-list tooltip
1314
+ // sweep to the remaining chip-row affordances. R79 made
1315
+ // these chips hoverable; their generic "Hover to highlight"
1316
+ // titles never said WHICH nodes match. Compute the alias
1317
+ // lists once for both chips to share.
1318
+ const workingAliases = onlineNodes.filter(s => s.status === 'working').map(s => s.alias);
1319
+ const onlineAliases = onlineNodes.map(s => s.alias);
1320
+ const truncate = (list: string[]) => {
1321
+ const head = list.slice(0, 8).join(', ');
1322
+ const tail = list.length > 8 ? ` + ${list.length - 8} more` : '';
1323
+ return head + tail;
1324
+ };
1325
+ const workingTitle = workingCount === 0
1326
+ ? undefined
1327
+ : pinnedStatus === 'working'
1328
+ ? `${truncate(workingAliases)} — pinned, Esc to clear`
1329
+ : `${truncate(workingAliases)} — hover highlights, click to pin`;
1330
+ const onlineTitle = onlineNodes.length === 0
1331
+ ? undefined
1332
+ : pinnedStatus === 'idle'
1333
+ ? `${truncate(onlineAliases)} — pinned, Esc to clear`
1334
+ : `${truncate(onlineAliases)} — hover highlights online`;
1335
+ return (
1336
+ <>
1337
+ <span
1338
+ className="px-2.5 py-1 rounded-md bg-green-500/10 text-green-300 border border-green-500/20"
1339
+ data-working-chip
1340
+ data-working-chip-aliases={workingAliases.join(',')}
1341
+ data-pin-mirror={pinnedStatus === 'working' ? 'true' : 'false'}
1342
+ title={workingTitle}
1343
+ style={{
1344
+ cursor: workingCount > 0 ? 'pointer' : undefined,
1345
+ boxShadow: pinnedStatus === 'working' ? 'inset 0 0 0 1px #4ade80, inset 0 0 0 2px rgba(255,255,255,0.45)' : undefined,
1346
+ }}
1347
+ onMouseEnter={() => { if (workingCount > 0) setHoveredStatus('working'); }}
1348
+ onMouseLeave={() => setHoveredStatus(prev => prev === 'working' ? null : prev)}
1349
+ >
1350
+ {workingCount} working
1351
+ </span>
1352
+ <span
1353
+ className="px-2.5 py-1 rounded-md bg-cyan-500/10 text-cyan-300 border border-cyan-500/20"
1354
+ data-online-chip
1355
+ data-online-chip-aliases={onlineAliases.join(',')}
1356
+ data-pin-mirror={pinnedStatus === 'idle' ? 'true' : 'false'}
1357
+ title={onlineTitle}
1358
+ style={{
1359
+ cursor: onlineNodes.length > 0 ? 'pointer' : undefined,
1360
+ boxShadow: pinnedStatus === 'idle' ? 'inset 0 0 0 1px #67e8f9, inset 0 0 0 2px rgba(255,255,255,0.45)' : undefined,
1361
+ }}
1362
+ onMouseEnter={() => {
1363
+ // If a working filter would isolate nothing, route to idle.
1364
+ const idleCount = onlineNodes.length - workingCount;
1365
+ if (workingCount > 0) setHoveredStatus('working');
1366
+ else if (idleCount > 0) setHoveredStatus('idle');
1367
+ }}
1368
+ onMouseLeave={() => setHoveredStatus(prev => prev === 'working' || prev === 'idle' ? null : prev)}
1369
+ >
1370
+ {onlineNodes.length} online
1371
+ </span>
1372
+ </>
1373
+ );
1374
+ })()}
1375
+ {/* Round 31 / Loop: fleet-health pressure bar. The "X working /
1376
+ Y online" chips already carry the raw counts; the bar lets
1377
+ the eye get the working/idle/offline RATIO in one glance
1378
+ without mental math. Stacked 3-segment chip, ~64 px wide. */}
1379
+ {(() => {
1380
+ const w = workingCount;
1381
+ const i = onlineNodes.length - workingCount; // idle = online - working
1382
+ const o = offlineNodes.length;
1383
+ const total = w + i + o;
1384
+ if (total === 0) return null;
1385
+ // Round 60 / Loop: each segment toggles a sticky filter via
1386
+ // `pinnedStatus`. Click the working segment → all non-working
1387
+ // nodes dim; click again → release. Segments share width with
1388
+ // their proportion of `total`, so a 1-node working share is
1389
+ // ~3 px wide on a 64-px bar. We pad the click target with a
1390
+ // negative-margin overlay wrapper to give thin slices a
1391
+ // 14-px minimum hit area without disturbing the rendered
1392
+ // chip width. cursor:pointer + a one-line title hint at the
1393
+ // affordance.
1394
+ const seg = (n: number, color: string, key: 'working' | 'idle' | 'offline', label: string) => {
1395
+ if (n === 0) return null;
1396
+ const isPinned = pinnedStatus === key;
1397
+ // R102: list the aliases that match this segment's bucket
1398
+ // so the title answers WHICH n, not just HOW MANY. Closes
1399
+ // the last "info-density gap" in the chip-row surfaces
1400
+ // (R97 pills / R99 group labels / R101 vendor letters all
1401
+ // already enumerate). Truncates at 8 with "+N more".
1402
+ const matchAliases = key === 'working'
1403
+ ? onlineNodes.filter(s => s.status === 'working').map(s => s.alias)
1404
+ : key === 'idle'
1405
+ ? onlineNodes.filter(s => s.status !== 'working').map(s => s.alias)
1406
+ : offlineNodes.map(s => s.alias);
1407
+ const previewList = matchAliases.slice(0, 8).join(', ');
1408
+ const suffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
1409
+ const titleAction = isPinned ? 'click to release filter' : 'click to highlight';
1410
+ return (
1411
+ <span
1412
+ key={key}
1413
+ data-pressure-seg={key}
1414
+ data-pressure-seg-aliases={matchAliases.join(',')}
1415
+ role="button"
1416
+ tabIndex={0}
1417
+ aria-pressed={isPinned}
1418
+ title={`${n} ${label}\n${previewList}${suffix}\n${titleAction}`}
1419
+ style={{
1420
+ width: `${(n / total) * 100}%`,
1421
+ background: color,
1422
+ height: '100%',
1423
+ cursor: 'pointer',
1424
+ boxShadow: isPinned ? `inset 0 0 0 1px ${color}, inset 0 0 0 2px rgba(255,255,255,0.6)` : undefined,
1425
+ }}
1426
+ onClick={(e) => {
1427
+ e.stopPropagation();
1428
+ setPinnedStatus(prev => prev === key ? null : key);
1429
+ }}
1430
+ onKeyDown={(e) => {
1431
+ if (e.key === 'Enter' || e.key === ' ') {
1432
+ e.preventDefault();
1433
+ setPinnedStatus(prev => prev === key ? null : key);
1434
+ }
1435
+ }}
1436
+ // R83: hover preview — segments now match the R55 legend,
1437
+ // R79 working/online chips, and R80 vendor letters in
1438
+ // offering a hover-transient highlight before the click
1439
+ // commits to a pin. Users get to feel what the filter
1440
+ // does before they lock it in. Same activeStatus =
1441
+ // hoveredStatus ?? pinnedStatus formula; releasing the
1442
+ // pointer falls back to the pin if one is set, or to
1443
+ // baseline. Thin (3-px) segments still hit-test fine
1444
+ // because the chip itself is a flex row — span doesn't
1445
+ // need any extra hit padding for hover.
1446
+ onMouseEnter={() => setHoveredStatus(key)}
1447
+ onMouseLeave={() => setHoveredStatus(prev => prev === key ? null : prev)}
1448
+ />
1449
+ );
1450
+ };
1451
+ // Round 47 / Loop: hidden on mobile — at <640px the chip row
1452
+ // wraps to multiple lines and crowds the topology header;
1453
+ // pressure ratio is best read with the working+online raw
1454
+ // counts (kept visible) anyway.
1455
+ return (
1456
+ <span
1457
+ className="hidden sm:inline-flex items-center gap-2 px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20 font-mono"
1458
+ title={`${w} working · ${i} idle · ${o} offline`}
1459
+ data-fleet-pressure
1460
+ >
1461
+ <span className="text-[10px] tracking-wide">pressure</span>
1462
+ <span className="inline-flex h-1.5 w-16 rounded-full overflow-hidden" style={{ background: 'rgb(75 85 99 / 0.25)' }}>
1463
+ {seg(w, isLight ? '#059669' : '#22c55e', 'working', 'working')}
1464
+ {seg(i, isLight ? '#0d9488' : '#2dd4bf', 'idle', 'idle')}
1465
+ {seg(o, isLight ? '#94a3b8' : '#6b7280', 'offline', 'offline')}
1466
+ </span>
1467
+ </span>
1468
+ );
1469
+ })()}
1470
+ {/* Round 64 / Loop: active-filter pills. When pinnedStatus or
1471
+ pinnedGroup is set, show a small "filter: <key> ×" pill so
1472
+ the user can see the pin from the chip row even if they
1473
+ scrolled the canvas off-screen. × button clears the
1474
+ specific pin (Esc still clears all — both paths are
1475
+ valid). pinnedStatus and pinnedGroup pin independently so
1476
+ both pills may render simultaneously. */}
1477
+ {/* R67: pills enter via anet-fade-in so they appear softly
1478
+ instead of popping. The "filter: " prefix collapses below
1479
+ sm — at narrow viewports the chip row is precious real
1480
+ estate (R47 already hides pressure / vendor / freshness),
1481
+ so dropping the redundant label keeps the working/idle/
1482
+ alpha keys readable without overflow. */}
1483
+ {/* R71: each pill picks up a "· N" match count tail. Tells the
1484
+ user at a glance how many sessions the active filter
1485
+ matches without scanning the canvas. Counts come from the
1486
+ already-computed workingCount + onlineNodes + offlineNodes
1487
+ for status, and groupKeys for group. */}
1488
+ {/* R73: entire pill body is a click-to-clear target — matches
1489
+ the Notion / Linear tag UX (the whole chip releases). The
1490
+ × keeps its dedicated <button> + aria-label for screen
1491
+ readers; the outer span just adds an extra mouse-friendly
1492
+ hit area with a title hint. ×'s onClick stopPropagation
1493
+ so the redundant outer onClick doesn't double-fire (no
1494
+ functional difference since both clear, but cleaner
1495
+ event flow). */}
1496
+ {pinnedStatus && (() => {
1497
+ const matchCount = pinnedStatus === 'working' ? workingCount
1498
+ : pinnedStatus === 'idle' ? (onlineNodes.length - workingCount)
1499
+ : offlineNodes.length;
1500
+ // Round 97 / Loop: the pill says HOW MANY but not WHICH.
1501
+ // Hovering it now shows the matched alias list in the
1502
+ // tooltip — answers "exactly who is this filtering to"
1503
+ // without forcing the user to scan the dim mask on the
1504
+ // canvas. Truncates at 8 names so a 50-node working
1505
+ // bucket doesn't produce a 12-line tooltip; the count
1506
+ // chip already answers "how many overall".
1507
+ const matchAliases = pinnedStatus === 'working'
1508
+ ? onlineNodes.filter(s => s.status === 'working').map(s => s.alias)
1509
+ : pinnedStatus === 'idle'
1510
+ ? onlineNodes.filter(s => s.status !== 'working').map(s => s.alias)
1511
+ : offlineNodes.map(s => s.alias);
1512
+ const matchPreview = matchAliases.slice(0, 8).join(', ');
1513
+ const matchSuffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
1514
+ return (
1515
+ <span
1516
+ data-active-filter="status"
1517
+ data-filter-match-count={matchCount}
1518
+ data-filter-match-aliases={matchAliases.join(',')}
1519
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in"
1520
+ title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
1521
+ onClick={() => setPinnedStatus(null)}
1522
+ style={{
1523
+ background: pinnedStatus === 'working' ? (isLight ? '#05966914' : '#22c55e1f')
1524
+ : pinnedStatus === 'idle' ? (isLight ? '#0d948814' : '#2dd4bf1f')
1525
+ : (isLight ? '#94a3b814' : '#6b72801f'),
1526
+ color: pinnedStatus === 'working' ? (isLight ? '#047857' : '#86efac')
1527
+ : pinnedStatus === 'idle' ? (isLight ? '#0f766e' : '#5eead4')
1528
+ : (isLight ? '#475569' : '#9ca3af'),
1529
+ borderColor: 'currentColor',
1530
+ cursor: 'pointer',
1531
+ }}
1532
+ >
1533
+ <span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{pinnedStatus}<span className="opacity-70"> · {matchCount}</span></span>
1534
+ <button
1535
+ type="button"
1536
+ aria-label={`Clear ${pinnedStatus} filter`}
1537
+ onClick={(e) => { e.stopPropagation(); setPinnedStatus(null); }}
1538
+ className="ml-0.5 leading-none hover:opacity-70"
1539
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1540
+ >×</button>
1541
+ </span>
1542
+ );
1543
+ })()}
1544
+ {pinnedGroup && (() => {
1545
+ const matchCount = Object.values(groupKeys).filter(k => k === pinnedGroup).length;
1546
+ // R97: list group members in the tooltip.
1547
+ const matchAliases = Object.entries(groupKeys)
1548
+ .filter(([, key]) => key === pinnedGroup)
1549
+ .map(([alias]) => alias);
1550
+ const matchPreview = matchAliases.slice(0, 8).join(', ');
1551
+ const matchSuffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
1552
+ return (
1553
+ <span
1554
+ data-active-filter="group"
1555
+ data-filter-match-count={matchCount}
1556
+ data-filter-match-aliases={matchAliases.join(',')}
1557
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in"
1558
+ title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
1559
+ onClick={() => setPinnedGroup(null)}
1560
+ style={{
1561
+ background: isLight ? '#67e8f914' : '#67e8f91f',
1562
+ color: pal.legendAccent,
1563
+ borderColor: 'currentColor',
1564
+ cursor: 'pointer',
1565
+ }}
1566
+ >
1567
+ <span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{pinnedGroup}<span className="opacity-70"> · {matchCount}</span></span>
1568
+ <button
1569
+ type="button"
1570
+ aria-label={`Clear group filter ${pinnedGroup}`}
1571
+ onClick={(e) => { e.stopPropagation(); setPinnedGroup(null); }}
1572
+ className="ml-0.5 leading-none hover:opacity-70"
1573
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1574
+ >×</button>
1575
+ </span>
1576
+ );
1577
+ })()}
1578
+ {/* R89: vendor pin gets its own filter pill, matching the R64
1579
+ status + group pattern. R88 added the pin but only the
1580
+ letter itself carried the state; the chip-row had no
1581
+ "filter: A · 2 ×" affordance the other two pins have. The
1582
+ pill colour borrows the vendor's own swatch so each pin
1583
+ still reads in its native hue (A green, O cyan, 书 blue,
1584
+ ? slate). Same body-click-clears + × button pattern as
1585
+ R64. */}
1586
+ {pinnedVendor && (() => {
1587
+ const matchEntry = vendorDist.find(v => v.initial === pinnedVendor);
1588
+ const matchCount = matchEntry?.count ?? 0;
1589
+ const vendorColor = matchEntry?.color ?? pal.legendText;
1590
+ // R97: list vendor users in the tooltip. The vendorIdentity
1591
+ // resolver maps model strings to a vendor initial — match
1592
+ // any session whose initial equals the pinned letter (with
1593
+ // unknowns folded to '?').
1594
+ const matchAliases = [...onlineNodes, ...offlineNodes]
1595
+ .filter(s => {
1596
+ const v = vendorForModel(s.model);
1597
+ return (v.id === 'unknown' ? '?' : v.initial) === pinnedVendor;
1598
+ })
1599
+ .map(s => s.alias);
1600
+ const matchPreview = matchAliases.slice(0, 8).join(', ');
1601
+ const matchSuffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
1602
+ return (
1603
+ <span
1604
+ data-active-filter="vendor"
1605
+ data-filter-match-count={matchCount}
1606
+ data-filter-match-aliases={matchAliases.join(',')}
1607
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in"
1608
+ title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear vendor filter'}
1609
+ onClick={() => setPinnedVendor(null)}
1610
+ style={{
1611
+ background: `${vendorColor}1f`,
1612
+ color: vendorColor,
1613
+ borderColor: 'currentColor',
1614
+ cursor: 'pointer',
1615
+ }}
1616
+ >
1617
+ <span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{pinnedVendor}<span className="opacity-70"> · {matchCount}</span></span>
1618
+ <button
1619
+ type="button"
1620
+ aria-label={`Clear vendor filter ${pinnedVendor}`}
1621
+ onClick={(e) => { e.stopPropagation(); setPinnedVendor(null); }}
1622
+ className="ml-0.5 leading-none hover:opacity-70"
1623
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1624
+ >×</button>
1625
+ </span>
1626
+ );
1627
+ })()}
1628
+ {/* R119: edge pin filter pill — completes the R64 / R89 pill
1629
+ pattern across all four pin dimensions. R116 added
1630
+ pinnedEdgeKey but the chip row never grew the matching
1631
+ pill, leaving the locked flow visible only on the canvas +
1632
+ recent-signal row tint. This pill shows "filter:
1633
+ alpha→beta · 3" so the locked flow appears in the same
1634
+ row as the other three pin types. Body-click + × button
1635
+ clear, same pattern as R64. */}
1636
+ {pinnedEdgeKey && (() => {
1637
+ const link = flowLinks.find(l => l.key === pinnedEdgeKey);
1638
+ if (!link) return null;
1639
+ return (
1640
+ <span
1641
+ data-active-filter="edge"
1642
+ data-filter-match-count={link.count}
1643
+ data-filter-match-aliases={`${link.from},${link.to}`}
1644
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in"
1645
+ title={`${link.from} → ${link.to} (${link.count} msg${link.count === 1 ? '' : 's'}) — click to clear`}
1646
+ onClick={() => setPinnedEdgeKey(null)}
1647
+ style={{
1648
+ background: isLight ? `${pal.flowEdge}14` : `${pal.flowEdge}1f`,
1649
+ color: pal.flowEdge,
1650
+ borderColor: 'currentColor',
1651
+ cursor: 'pointer',
1652
+ }}
1653
+ >
1654
+ <span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{link.from}→{link.to}<span className="opacity-70"> · {link.count}</span></span>
1655
+ <button
1656
+ type="button"
1657
+ aria-label={`Clear edge filter ${link.from} → ${link.to}`}
1658
+ onClick={(e) => { e.stopPropagation(); setPinnedEdgeKey(null); }}
1659
+ className="ml-0.5 leading-none hover:opacity-70"
1660
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1661
+ >×</button>
1662
+ </span>
1663
+ );
1664
+ })()}
1665
+ {/* Round 124 / Loop: pin-intersection summary chip. The four
1666
+ filter pills (R64 status, R63 group, R89 vendor, R119
1667
+ edge) each report their own dim's match count in
1668
+ isolation — that answers "what does THIS filter catch"
1669
+ but not "what survives ALL pins". Since the node-opacity
1670
+ chain AND-composes the four pin dimensions, two active
1671
+ pins routinely produce an intersection smaller than
1672
+ either individual count. With three or four pins active
1673
+ the gap widens further. This chip appears ONLY when
1674
+ ≥ 2 pin dims are active (a single pin's pill already
1675
+ tells the whole story) and shows the count of nodes
1676
+ that satisfy every active pin simultaneously. Color is
1677
+ deliberately neutral (gray) since the chip represents
1678
+ the AND of mixed-color filters — borrowing any one of
1679
+ the pill hues would mis-signal which dim dominates.
1680
+ Tooltip lists the surviving aliases with the same
1681
+ 8-truncate + "+N more" pattern as the individual pill
1682
+ tooltips (R97). No click handler — it's a pure readout;
1683
+ Esc / per-pill × are still the cancel paths. */}
1684
+ {(() => {
1685
+ const pinDimCount =
1686
+ (pinnedStatus ? 1 : 0) +
1687
+ (pinnedGroup ? 1 : 0) +
1688
+ (pinnedVendor ? 1 : 0) +
1689
+ (pinnedEdgeKey ? 1 : 0);
1690
+ if (pinDimCount < 2) return null;
1691
+ const edgeLink = pinnedEdgeKey
1692
+ ? flowLinks.find(l => l.key === pinnedEdgeKey)
1693
+ : null;
1694
+ const edgeEndpoints: Set<string> | null = edgeLink
1695
+ ? new Set([edgeLink.from, edgeLink.to])
1696
+ : null;
1697
+ const allSessions = [...onlineNodes, ...offlineNodes];
1698
+ const survivors = allSessions.filter(s => {
1699
+ const isOnline = s.status !== 'offline';
1700
+ if (pinnedStatus === 'working' && s.status !== 'working') return false;
1701
+ if (pinnedStatus === 'idle' && !(isOnline && s.status !== 'working')) return false;
1702
+ if (pinnedStatus === 'offline' && isOnline) return false;
1703
+ if (pinnedGroup) {
1704
+ const gk = groupKeys[s.alias] ?? s.alias;
1705
+ if (gk !== pinnedGroup) return false;
1706
+ }
1707
+ if (pinnedVendor) {
1708
+ const v = vendorForModel(s.model);
1709
+ const initial = v.id === 'unknown' ? '?' : v.initial;
1710
+ if (initial !== pinnedVendor) return false;
1711
+ }
1712
+ if (edgeEndpoints && !edgeEndpoints.has(s.alias)) return false;
1713
+ return true;
1714
+ });
1715
+ const matchAliases = survivors.map(s => s.alias);
1716
+ const matchPreview = matchAliases.slice(0, 8).join(', ');
1717
+ const matchSuffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
1718
+ const tooltip = matchAliases.length > 0
1719
+ ? `${matchPreview}${matchSuffix} — nodes passing all ${pinDimCount} pinned filters`
1720
+ : `No nodes pass all ${pinDimCount} pinned filters (Esc to clear)`;
1721
+ return (
1722
+ <span
1723
+ data-pin-intersection
1724
+ data-pin-dim-count={pinDimCount}
1725
+ data-pin-intersection-count={matchAliases.length}
1726
+ data-pin-intersection-aliases={matchAliases.join(',')}
1727
+ className="hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in"
1728
+ title={tooltip}
1729
+ style={{
1730
+ background: isLight ? '#94a3b814' : '#94a3b81f',
1731
+ color: isLight ? '#475569' : '#9ca3af',
1732
+ borderColor: 'currentColor',
1733
+ }}
1734
+ >
1735
+ <span><span className="hidden sm:inline" data-pin-intersection-prefix>match: </span>{pinDimCount} pins<span className="opacity-70"> · {matchAliases.length}</span></span>
1736
+ </span>
1737
+ );
1738
+ })()}
1739
+ {vendorDist.length > 1 && (
1740
+ <span
1741
+ className="hidden sm:inline-flex items-center gap-2 px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20 font-mono"
1742
+ title="Hover to highlight; click to pin"
1743
+ >
1744
+ {vendorDist.map(v => {
1745
+ const isPinned = pinnedVendor === v.initial;
1746
+ // R88: click toggles a sticky filter the same way R60
1747
+ // pressure-bar segments toggle pinnedStatus. Visual
1748
+ // mirror = inset boxShadow using the vendor's own
1749
+ // colour, so each pinned letter sings in its own hue
1750
+ // (Anthropic green / OpenAI cyan / 书 blue / ?).
1751
+ // R101: tooltip lists the aliases that use this vendor —
1752
+ // completes the info-density triple started by R97 pills,
1753
+ // R98 node titles, R99 group-label titles. Anywhere the
1754
+ // UI shows "A:3" should hover-explain which 3.
1755
+ const aliases = [...onlineNodes, ...offlineNodes]
1756
+ .filter(s => {
1757
+ const vid = vendorForModel(s.model);
1758
+ return (vid.id === 'unknown' ? '?' : vid.initial) === v.initial;
1759
+ })
1760
+ .map(s => s.alias);
1761
+ const preview = aliases.slice(0, 8).join(', ');
1762
+ const suffix = aliases.length > 8 ? ` + ${aliases.length - 8} more` : '';
1763
+ const tooltip = isPinned
1764
+ ? `${preview}${suffix} — click again or Esc to clear`
1765
+ : `${preview}${suffix} — click to pin`;
1766
+ return (
1767
+ <span
1768
+ key={v.initial}
1769
+ role="button"
1770
+ tabIndex={0}
1771
+ aria-pressed={isPinned}
1772
+ className="inline-flex items-baseline gap-0.5 px-1 rounded"
1773
+ data-vendor-letter={v.initial}
1774
+ data-vendor-pinned={isPinned ? 'true' : 'false'}
1775
+ data-vendor-aliases={aliases.join(',')}
1776
+ title={tooltip}
1777
+ style={{
1778
+ cursor: 'pointer',
1779
+ boxShadow: isPinned
1780
+ ? `inset 0 0 0 1px ${v.color}, inset 0 0 0 2px rgba(255,255,255,0.45)`
1781
+ : undefined,
1782
+ }}
1783
+ onMouseEnter={() => setHoveredVendor(v.initial)}
1784
+ onMouseLeave={() => setHoveredVendor(prev => prev === v.initial ? null : prev)}
1785
+ onClick={() => setPinnedVendor(prev => prev === v.initial ? null : v.initial)}
1786
+ onKeyDown={(e) => {
1787
+ if (e.key === 'Enter' || e.key === ' ') {
1788
+ e.preventDefault();
1789
+ setPinnedVendor(prev => prev === v.initial ? null : v.initial);
1790
+ }
1791
+ }}
1792
+ >
1793
+ <span style={{ color: v.color }}>{v.initial}</span>
1794
+ <span className="text-gray-500">:{v.count}</span>
1795
+ </span>
1796
+ );
1797
+ })}
1798
+ </span>
1799
+ )}
1800
+ {/* Round 42 / Loop: extend active-links chip with the timestamp
1801
+ of the most-recent flow event. Tells the operator at a glance
1802
+ whether the topology is currently humming (last 30s) or has
1803
+ been quiet for a while — the visual flow particles and
1804
+ edge brightness only show that there IS traffic, not when
1805
+ the last one was. Reuses Round 38's relativeAgo. */}
1806
+ {(() => {
1807
+ const recent = flowLinks.reduce<number | null>((acc, l) => {
1808
+ if (!l.last_at) return acc;
1809
+ const t = parseHubTime(l.last_at);
1810
+ if (t === null) return acc;
1811
+ return acc === null || t > acc ? t : acc;
1812
+ }, null);
1813
+ const rel = recent !== null ? relativeAgo(new Date(recent).toISOString()) : null;
1814
+ // R114: tooltip lists the actual flows. Closes the
1815
+ // info-density sweep on the last chip-row hover surface
1816
+ // (R97-R113 covered everything else). Format:
1817
+ // "alpha→beta (3), gamma→delta (1) — hover brightens all"
1818
+ // Truncates at 6 flows with "+N more" so a busy fleet
1819
+ // doesn't paint a tall tooltip; the recent-signal panel
1820
+ // already shows the top 3 in detail.
1821
+ const flowList = flowLinks
1822
+ .slice(0, 6)
1823
+ .map(l => `${l.from}→${l.to} (${l.count})`)
1824
+ .join(', ');
1825
+ const flowSuffix = flowLinks.length > 6 ? ` + ${flowLinks.length - 6} more` : '';
1826
+ const tooltip = flowLinks.length === 0
1827
+ ? undefined
1828
+ : `${flowList}${flowSuffix} — hover brightens all`;
1829
+ return (
1830
+ <span
1831
+ className="hidden sm:inline px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20"
1832
+ data-active-links-chip
1833
+ data-active-links-flow-count={flowLinks.length}
1834
+ title={tooltip}
1835
+ style={{ cursor: flowLinks.length > 0 ? 'pointer' : undefined }}
1836
+ onMouseEnter={() => { if (flowLinks.length > 0) setHoveredActiveLinks(true); }}
1837
+ onMouseLeave={() => setHoveredActiveLinks(false)}
1838
+ >
1839
+ {flowLinks.length} active link{flowLinks.length === 1 ? '' : 's'}
1840
+ {rel ? <span className="text-gray-500"> · last {rel}</span> : null}
1841
+ </span>
1842
+ );
1843
+ })()}
1844
+ <FreshnessChip sessions={sessions} />
794
1845
  </div>
795
1846
  </div>
796
1847
 
@@ -810,7 +1861,18 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
810
1861
  onPointerMove={onPointerMove}
811
1862
  onPointerUp={onPointerUp}
812
1863
  onPointerLeave={onPointerUp}
813
- style={{ cursor: 'grab', touchAction: 'none' }}
1864
+ // Round 41 / Loop: the reset-button title (R22) and the Help
1865
+ // overlay (R25) both advertise "double-click the canvas to
1866
+ // reset", but the handler was never actually wired. The text
1867
+ // was lying. Wire it here, guarded so dbl-clicking a node
1868
+ // (which would also trigger the SVG-level handler via event
1869
+ // bubbling) doesn't unexpectedly reset the view on the user.
1870
+ onDoubleClick={(e) => {
1871
+ const t = e.target as Element | null;
1872
+ if (t?.closest('g[data-node]')) return;
1873
+ resetView();
1874
+ }}
1875
+ style={{ cursor: isPanning ? 'grabbing' : 'grab', touchAction: 'none' }}
814
1876
  >
815
1877
  <defs>
816
1878
  <linearGradient id="topo-panel" x1="0" x2="1" y1="0" y2="1">
@@ -832,9 +1894,37 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
832
1894
  </feMerge>
833
1895
  </filter>
834
1896
  )}
835
- <marker id="topo-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
836
- <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
837
- </marker>
1897
+ {/* Round 16 / Loop: 3-tier flow-link arrow markers.
1898
+ The single marker had `markerUnits` defaulting to
1899
+ `strokeWidth`, so heavy edges (stroke=7) rendered 35-user-
1900
+ unit arrowheads — visually dominant, the head outweighed
1901
+ the line. Switching to `userSpaceOnUse` decouples arrow
1902
+ size from stroke; binning by count gives a clearer
1903
+ hierarchy than continuous linear scaling:
1904
+ s (count 1-2) → 12 user units
1905
+ m (count 3-4) → 16 user units (alias for `topo-arrow`)
1906
+ l (count 5+) → 22 user units
1907
+ `topo-arrow` stays bound to the medium tier so the legend
1908
+ swatch (line ~1500) renders without change. */}
1909
+ {[
1910
+ { id: 'topo-arrow-s', size: 12 },
1911
+ { id: 'topo-arrow', size: 16 },
1912
+ { id: 'topo-arrow-l', size: 22 },
1913
+ ].map(m => (
1914
+ <marker
1915
+ key={m.id}
1916
+ id={m.id}
1917
+ viewBox="0 0 10 10"
1918
+ refX="8"
1919
+ refY="5"
1920
+ markerWidth={m.size}
1921
+ markerHeight={m.size}
1922
+ markerUnits="userSpaceOnUse"
1923
+ orient="auto-start-reverse"
1924
+ >
1925
+ <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
1926
+ </marker>
1927
+ ))}
838
1928
  {/* Round 45: radial radar sweep gradient — bright at center
839
1929
  (where it meets the hub) fading to leading edge. */}
840
1930
  <radialGradient id="topo-sweep" cx="0%" cy="50%" r="100%">
@@ -854,13 +1944,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
854
1944
  grid mode drops it so the concentric rings don't sit behind a
855
1945
  rectangular grid. */}
856
1946
  {layout === 'ring' && (<>
857
- <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" />
1947
+ {/* R52: radar bg is pure decoration — drop its pointer events so
1948
+ the hub <g> under it (and any future inner-disk affordances)
1949
+ can receive clicks. Previously the r=330 disc intercepted
1950
+ the hub click outright. */}
1951
+ <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" style={{ pointerEvents: 'none' }} />
858
1952
 
859
1953
  {/* Round 45: subtle star field — 24 deterministic dots scattered
860
1954
  across the canvas give the radar bg some depth. Skipped on
861
1955
  light theme so the white surface stays clean. */}
862
1956
  {!isLight && (
863
- <g opacity="0.5">
1957
+ <g opacity="0.5" style={{ pointerEvents: 'none' }}>
864
1958
  {Array.from({ length: 28 }).map((_, i) => {
865
1959
  // Deterministic pseudo-random scatter so positions are
866
1960
  // stable between renders (no JS hydration mismatch).
@@ -882,6 +1976,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
882
1976
  style={{
883
1977
  transformOrigin: `${cx}px ${cy}px`,
884
1978
  transformBox: 'view-box',
1979
+ pointerEvents: 'none',
885
1980
  }}
886
1981
  className="anet-topo-sweep"
887
1982
  opacity={isLight ? 0.7 : 1}
@@ -892,11 +1987,82 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
892
1987
  />
893
1988
  </g>
894
1989
 
895
- {/* radar rings */}
1990
+ {/* radar rings — pure decoration at fixed radii, independent of
1991
+ node positions so the radar aesthetic is preserved across tier
1992
+ changes. */}
896
1993
  {[90, 170, 250, 330].map(radius => (
897
1994
  <circle key={radius} cx={cx} cy={cy} r={radius} fill="none" stroke={pal.ringStroke} strokeWidth="1" opacity={isLight ? 0.6 : 0.35} />
898
1995
  ))}
899
1996
 
1997
+ {/* Round 54 / Loop: tier-radius guide rings. The radar rings above
1998
+ are decorative and don't match the actual tier radii nodes sit
1999
+ on (single 220 / dual 175,260 / triple 145,215,285). Drawing
2000
+ a faint dashed ring at each ACTIVE tier radius lets the eye
2001
+ anchor "this is the inner / outer ring" without inferring from
2002
+ node spacing. Picked based on online node count so only the
2003
+ tiers currently in use draw — empty tiers stay quiet. pointer-
2004
+ events:none so they never intercept hub or node clicks. The
2005
+ 0.7 stroke + dashed pattern reads as guide, not feature. */}
2006
+ {(() => {
2007
+ const tierRadii = onlineNodes.length > onlineTripleThreshold
2008
+ ? [onlineTripleInnerR, onlineTripleMidR, onlineTripleOuterR]
2009
+ : onlineNodes.length > onlineTierThreshold
2010
+ ? [onlineInnerRadius, onlineOuterRadius]
2011
+ : onlineNodes.length > 0
2012
+ ? [onlineRadius]
2013
+ : [];
2014
+ // Round 92 / Loop: tier-ring occupancy. R54 drew the guide
2015
+ // rings at fixed opacity regardless of how many nodes lived
2016
+ // on each tier. With pinned filters dimming most nodes to
2017
+ // 0.28, the ring at a deserted tier looked identical to a
2018
+ // crowded one — wasting a free piece of canvas. Count
2019
+ // online nodes whose hub-distance falls within ±15 px of
2020
+ // each ring (15 px = half the inter-tier gap, so each
2021
+ // node is assigned to exactly one ring). Empty tier → skip
2022
+ // entirely. Crowded tier → stronger opacity, says "look
2023
+ // here". Buckets so the ladder feels intentional, not
2024
+ // jittery as one node migrates between tiers.
2025
+ const occupancyOf = (r: number) => onlineNodes.reduce((acc, s) => {
2026
+ const p = nodePositions[s.alias];
2027
+ if (!p) return acc;
2028
+ const d = Math.hypot(p.x - cx, p.y - cy);
2029
+ return Math.abs(d - r) < 15 ? acc + 1 : acc;
2030
+ }, 0);
2031
+ // Round 93 / Loop: when any pin is active, tint the tier
2032
+ // rings to the legend accent so the spatial guide visually
2033
+ // shares the canvas's "filtered mode" colour. The chip
2034
+ // row already says WHICH filter is on (pills + letter
2035
+ // mirror); rings answering "we're filtered" reinforces
2036
+ // the state when the eye is on the canvas, not the chip
2037
+ // row. Composes cleanly with R92 occupancy — same opacity
2038
+ // bucket logic; just the stroke colour swaps.
2039
+ const anyPin = !!(pinnedStatus || pinnedGroup || pinnedVendor);
2040
+ const tierStroke = anyPin ? pal.legendAccent : pal.ringStroke;
2041
+ return tierRadii.map(r => {
2042
+ const n = occupancyOf(r);
2043
+ if (n === 0) return null;
2044
+ const bucket = n <= 2 ? 0 : n <= 6 ? 1 : 2;
2045
+ const opLight = [0.24, 0.36, 0.50][bucket];
2046
+ const opDark = [0.32, 0.46, 0.62][bucket];
2047
+ return (
2048
+ <circle
2049
+ key={`tier-${r}`}
2050
+ cx={cx} cy={cy} r={r}
2051
+ fill="none"
2052
+ stroke={tierStroke}
2053
+ strokeWidth="0.7"
2054
+ strokeDasharray="2 8"
2055
+ opacity={isLight ? opLight : opDark}
2056
+ style={{ pointerEvents: 'none', transition: 'stroke 200ms ease-out' }}
2057
+ data-tier-ring={r}
2058
+ data-tier-occupancy={n}
2059
+ data-tier-bucket={bucket}
2060
+ data-tier-tinted={anyPin ? 'true' : 'false'}
2061
+ />
2062
+ );
2063
+ });
2064
+ })()}
2065
+
900
2066
  {/* Round 50: 4 small particles slowly orbiting the outer ring
901
2067
  (r=330). Each starts at a different angle (offset 0/0.25/0.5/0.75
902
2068
  of the cycle) so they're evenly spaced. 16s per revolution is
@@ -966,32 +2132,177 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
966
2132
  only — groupBoxes is empty in ring mode. Rendered behind the
967
2133
  flow links + nodes; pointer-events off so they never intercept
968
2134
  a node click. Restrained dashed container + group-name label. */}
969
- {groupBoxes.map(box => (
970
- <g key={`grp-${box.key}`} data-group={box.key} style={{ pointerEvents: 'none' }}>
971
- <rect
972
- x={box.x}
973
- y={box.y}
974
- width={box.w}
975
- height={box.h}
976
- rx="14"
977
- fill={isLight ? '#0f172a' : '#a5b4fc'}
978
- fillOpacity={isLight ? 0.025 : 0.045}
979
- stroke={pal.ringStroke}
980
- strokeWidth="1.5"
981
- strokeDasharray="6 6"
982
- />
983
- <text
984
- x={box.x + 12}
985
- y={box.y + 14}
986
- fill={pal.legendText}
987
- fontSize="13"
988
- fontFamily="monospace"
989
- fontWeight="700"
2135
+ {groupBoxes.map(box => {
2136
+ const isHovered = activeGroup === box.key;
2137
+ // R68: distinguish "locked by click" from "currently hovered".
2138
+ // R63 made pinned and hovered identical (both hit isHovered
2139
+ // via activeGroup). A user with one team pinned should see at
2140
+ // a glance which is the locked one even while their cursor
2141
+ // sweeps elsewhere. isPinned reads pinnedGroup directly
2142
+ // (NOT activeGroup) so the visual is specific to the sticky
2143
+ // state transient hover keeps the R63 isHovered styling.
2144
+ const isPinned = pinnedGroup === box.key;
2145
+ // Round 18 / Loop: group-box hover linkage. The Round 8 fade
2146
+ // already dropped OUT-of-focus groups to 0.28, but the IN-focus
2147
+ // group sat at its baseline appearance — no positive emphasis.
2148
+ // Hovering now upgrades the box to an "accent" treatment:
2149
+ // solid stroke (not dashed), thicker, accent-coloured; brighter
2150
+ // text and slightly stronger fill. Label and box read as one
2151
+ // selected unit. Geometry unchanged → overlap test untouched.
2152
+ return (
2153
+ <g
2154
+ key={`grp-${box.key}`}
2155
+ data-group={box.key}
2156
+ className="transition-opacity"
2157
+ // R63: drop the blanket pointerEvents:'none' that
2158
+ // previously sat here. Chrome's SVG impl doesn't let a
2159
+ // child override a parent's `none` even though the spec
2160
+ // says it should — moving the property onto just the
2161
+ // rect (where it's needed so nodes underneath stay
2162
+ // clickable) lets the label text receive its own click.
2163
+ style={{ opacity: !activeGroup || isHovered ? 1 : 0.28 }}
990
2164
  >
991
- {box.key}
992
- </text>
993
- </g>
994
- ))}
2165
+ <rect
2166
+ x={box.x}
2167
+ y={box.y}
2168
+ width={box.w}
2169
+ height={box.h}
2170
+ rx="14"
2171
+ fill={isLight ? '#0f172a' : '#a5b4fc'}
2172
+ // R68: 3-tier opacity + stroke ladder.
2173
+ // pinned → fill 0.08 / 0.13, stroke 3 px (locked)
2174
+ // hovered → fill 0.05 / 0.09, stroke 2 px (inspecting)
2175
+ // idle → fill 0.025 / 0.045, stroke 1.5 px dashed
2176
+ fillOpacity={isPinned ? (isLight ? 0.08 : 0.13)
2177
+ : isHovered ? (isLight ? 0.05 : 0.09)
2178
+ : (isLight ? 0.025 : 0.045)}
2179
+ stroke={(isPinned || isHovered) ? pal.legendAccent : pal.ringStroke}
2180
+ strokeWidth={isPinned ? 3 : isHovered ? 2 : 1.5}
2181
+ strokeDasharray={(isPinned || isHovered) ? 'none' : '6 6'}
2182
+ data-group-box-pinned={isPinned ? 'true' : 'false'}
2183
+ // R85: ambient "marching ants" drift on the perimeter
2184
+ // when this group has at least one working member, and
2185
+ // neither pin nor hover is active (those treatments
2186
+ // already shout for attention via solid stroke). 12s
2187
+ // cycle reads as ambient — the eye parses "live work
2188
+ // here" without registering the box as animating.
2189
+ data-group-box-live={!isPinned && !isHovered && box.statuses.working > 0 ? 'true' : 'false'}
2190
+ className={!isPinned && !isHovered && box.statuses.working > 0 ? 'anet-topo-groupbox-live' : undefined}
2191
+ style={{ transition: 'stroke 200ms ease-out, stroke-width 200ms ease-out, fill-opacity 200ms ease-out', pointerEvents: 'none' }}
2192
+ />
2193
+ {/* R63: wrap label in a clickable <g> with an invisible
2194
+ rect hitbox. The text alone wasn't getting hit-tested
2195
+ reliably — the SVG-wide topo-panel <rect> intercepts
2196
+ at the label's screen position when the label sits at
2197
+ a high viewBox-y (it lands below where the compositor
2198
+ expects the text to paint on top, same gotcha as the
2199
+ recent-signal panel rows in R56). Hitbox rect + the
2200
+ parent <g> taking the click + onPointerDown stop-
2201
+ propagation match the R55/R56/R61 pattern. */}
2202
+ <g
2203
+ role="button"
2204
+ tabIndex={0}
2205
+ aria-pressed={pinnedGroup === box.key}
2206
+ data-group-label-hit={box.key}
2207
+ style={{ pointerEvents: 'all', cursor: 'pointer' }}
2208
+ onPointerDown={(e) => e.stopPropagation()}
2209
+ onClick={() => setPinnedGroup(prev => prev === box.key ? null : box.key)}
2210
+ // R86: hover the label → transient group focus. Releases
2211
+ // on leave; activeGroup = hoveredGroup ?? pinnedGroup
2212
+ // formula already handles transient-over-pin so a
2213
+ // user can spot-compare teams without losing their
2214
+ // pinned one. Closes the same R83-style hover/click
2215
+ // gap (segments) — now group labels carry it too.
2216
+ onPointerEnter={() => setHoveredGroupLabel(box.key)}
2217
+ onPointerLeave={() => setHoveredGroupLabel(prev => prev === box.key ? null : prev)}
2218
+ >
2219
+ {/* R99: SVG <title> tooltip listing group members +
2220
+ status breakdown. Same info-density spirit as
2221
+ R97 pill tooltips + R98 node tooltips — anywhere
2222
+ a UI element says "alpha · 3" should hover-
2223
+ explain WHICH 3. Truncates at 8 aliases with a
2224
+ "+N more" suffix so a 20-member band doesn't
2225
+ paint a 22-line tooltip. */}
2226
+ {(() => {
2227
+ const members = Object.entries(groupKeys)
2228
+ .filter(([, key]) => key === box.key)
2229
+ .map(([alias]) => alias);
2230
+ const memberPreview = members.slice(0, 8).join(', ');
2231
+ const suffix = members.length > 8 ? ` + ${members.length - 8} more` : '';
2232
+ const statusSummary = [
2233
+ box.statuses.working > 0 ? `${box.statuses.working} working` : null,
2234
+ box.statuses.idle > 0 ? `${box.statuses.idle} idle` : null,
2235
+ box.statuses.offline > 0 ? `${box.statuses.offline} offline` : null,
2236
+ ].filter(Boolean).join(' · ');
2237
+ return (
2238
+ <title>{[
2239
+ `${box.key} (${members.length} member${members.length === 1 ? '' : 's'})`,
2240
+ statusSummary || null,
2241
+ `${memberPreview}${suffix}`,
2242
+ pinnedGroup === box.key ? 'click to release pin' : 'click to pin this group',
2243
+ ].filter(Boolean).join('\n')}</title>
2244
+ );
2245
+ })()}
2246
+ <rect
2247
+ x={box.x + 6}
2248
+ y={box.y + 2}
2249
+ width={Math.min(box.w - 12, 240)}
2250
+ height={20}
2251
+ rx="4"
2252
+ /* R107 / Loop: list-item tint extends to the SVG
2253
+ group labels — same idiom R104 added to recent-
2254
+ signal rows and R105 to the legend rows. The
2255
+ tint colour is pal.legendAccent (cyan) since
2256
+ groups don't carry an inherent swatch the way
2257
+ legend rows do; this matches R68's group-box
2258
+ isPinned/isHovered accent stroke for consistency.
2259
+ hover < pinned opacity so locked vs preview is
2260
+ discriminable at a glance. */
2261
+ fill={pinnedGroup === box.key || hoveredGroupLabel === box.key ? pal.legendAccent : 'transparent'}
2262
+ opacity={pinnedGroup === box.key ? (isLight ? 0.16 : 0.20)
2263
+ : hoveredGroupLabel === box.key ? (isLight ? 0.09 : 0.13)
2264
+ : 1}
2265
+ data-group-label-tinted={pinnedGroup === box.key ? 'pinned' : hoveredGroupLabel === box.key ? 'hover' : 'none'}
2266
+ style={{ transition: 'fill 150ms ease-out, opacity 150ms ease-out' }}
2267
+ />
2268
+ <text
2269
+ x={box.x + 12}
2270
+ y={box.y + 14}
2271
+ fill={isHovered ? pal.legendHeadline : pal.legendText}
2272
+ fontSize="13"
2273
+ fontFamily="monospace"
2274
+ fontWeight="700"
2275
+ style={{ transition: 'fill 200ms ease-out' }}
2276
+ data-group-label={box.key}
2277
+ >
2278
+ {box.key}
2279
+ {/* Round 19 / Loop: member-count chip. Inline tspan stays
2280
+ in the single <text> bbox the overlap test reads, so
2281
+ the node↔label guard still catches if the chip ever
2282
+ pushes the label far enough right to clip a node.
2283
+ Smaller + lighter weight reads as metadata, not name. */}
2284
+ <tspan dx="6" fill={pal.legendText} fontSize="11" fontWeight="400">· {box.count}</tspan>
2285
+ {/* Round 58 / Loop: status mix pip strip. Compact text-
2286
+ based chips (e.g. "2w 1i") so the strip stays inside
2287
+ the same <text> bbox the overlap-test reads — keeps
2288
+ the R27 label↔label and R19 node↔label guards intact.
2289
+ Each tier is colour-coded against the legend swatches
2290
+ and only renders when count > 0, so a healthy all-
2291
+ working group reads simply " · 2w". */}
2292
+ {box.statuses.working > 0 && (
2293
+ <tspan dx="8" fill={isLight ? '#059669' : '#22c55e'} fontSize="11" fontWeight="600">{box.statuses.working}w</tspan>
2294
+ )}
2295
+ {box.statuses.idle > 0 && (
2296
+ <tspan dx="4" fill={isLight ? '#0d9488' : '#2dd4bf'} fontSize="11" fontWeight="600">{box.statuses.idle}i</tspan>
2297
+ )}
2298
+ {box.statuses.offline > 0 && (
2299
+ <tspan dx="4" fill={isLight ? '#94a3b8' : '#6b7280'} fontSize="11" fontWeight="600">{box.statuses.offline}o</tspan>
2300
+ )}
2301
+ </text>
2302
+ </g>
2303
+ </g>
2304
+ );
2305
+ })}
995
2306
 
996
2307
  {/* directed message flows */}
997
2308
  {flowLinks.map((link, index) => {
@@ -999,21 +2310,121 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
999
2310
  const to = nodePositions[link.to];
1000
2311
  if (!from || !to) return null;
1001
2312
 
1002
- const lift = index % 2 === 0 ? 36 : -36;
2313
+ // Round 7 / Loop: lift now scales with distance so short links
2314
+ // aren't over-bent (long links keep the ~36px hump), and the
2315
+ // particle period shortens with link.count so busier edges
2316
+ // visibly pulse faster — instant info density on top of the
2317
+ // existing stroke-width-by-count chip.
2318
+ const dist = Math.hypot(to.x - from.x, to.y - from.y);
2319
+ const lift = (index % 2 === 0 ? 1 : -1) * Math.min(36, dist * 0.18);
1003
2320
  const path = curvePath(from, to, lift);
1004
2321
  const width = Math.min(2 + link.count, 7);
1005
- const duration = 2.2 + (index % 5) * 0.32;
1006
-
2322
+ const duration = Math.max(0.9, 2.6 / Math.sqrt(link.count));
2323
+ // Round 10 / Loop: freshness fade. An edge that fired ≤30s ago
2324
+ // stays at full intensity; over 5 minutes it decays to ~35%.
2325
+ // Surfaces "what's happening now" vs background chatter without
2326
+ // hiding old flow entirely (some context still useful). `now`
2327
+ // captured at useMemo-recompute time (every 5s message refresh)
2328
+ // — accuracy is within the poll interval, plenty.
2329
+ const ageMs = link.last_at ? Math.max(0, Date.now() - Date.parse(link.last_at)) : 0;
2330
+ const fresh = Math.max(0.35, 1 - ageMs / (5 * 60 * 1000));
2331
+ // Round 16 arrow-tier binning — keep `topo-arrow` as the
2332
+ // medium tier id so the legend swatch picks it up unchanged.
2333
+ const arrowId = link.count <= 2 ? 'topo-arrow-s'
2334
+ : link.count <= 4 ? 'topo-arrow'
2335
+ : 'topo-arrow-l';
2336
+
2337
+ // Round 39 / Loop: edge hover tooltip — surface the same
2338
+ // last_at + count info the freshness fade and arrow tier
2339
+ // already encode visually, in plain text. The stroke is the
2340
+ // hover target; SVG `<title>` honours newlines on every
2341
+ // browser the dashboard targets.
2342
+ const lastAt = relativeAgo(link.last_at);
2343
+ const tooltip = `${link.from} → ${link.to}\n${link.count} message${link.count === 1 ? '' : 's'}${lastAt ? ` · last ${lastAt}` : ''}`;
2344
+ // Round 40 / Loop: edges follow node hover — when an alias is
2345
+ // hovered, every edge touching it brightens, the rest fade.
2346
+ // Pairs with the Round 8 group-focus fade on nodes: hover a
2347
+ // node to find "who is this agent talking to" at a glance.
2348
+ // No hover → multiplier is 1.0 (current behaviour preserved).
2349
+ // Round 50 / Loop: edge-on-self priority. When the user hovers
2350
+ // a flow edge directly (R48 widened the hitbox so this is now
2351
+ // a precise gesture), THAT edge gets the strongest boost (2.0)
2352
+ // and every other edge dims to 0.35. Node-hover (R40) keeps
2353
+ // its own ladder. Edge-hover and node-hover are mutually
2354
+ // exclusive in practice — the cursor is over one or the
2355
+ // other — but the order below makes edge-hover win if both
2356
+ // ever read truthy at the same React tick.
2357
+ // Round 53 / Loop: in-group edges follow team focus. R40
2358
+ // brightened only edges touching the exact hovered alias —
2359
+ // but with R8/R18 prefix-clustering, a user hovering one
2360
+ // member is asking about the team. So when BOTH endpoints of
2361
+ // an edge share the hoveredGroup (and neither is the exact
2362
+ // hovered alias — that gets the stronger 1.7×), the edge
2363
+ // boosts to 1.3×. Edges leaving the team (one endpoint in,
2364
+ // one out) still dim to 0.35× per R40 since they read as
2365
+ // background to the focus. Singletons fall through unchanged
2366
+ // (their group key is the alias itself, so bothInHoveredGroup
2367
+ // is impossible for a non-self-edge).
2368
+ // R116: composes hover ?? pin — a pinned edge stays "hot" after the cursor leaves.
2369
+ const isHoveredEdge = activeEdgeKey === link.key;
2370
+ const fromGroup = groupKeys[link.from] ?? link.from;
2371
+ const toGroup = groupKeys[link.to] ?? link.to;
2372
+ const bothInHoveredGroup = !!activeGroup && fromGroup === activeGroup && toGroup === activeGroup;
2373
+ // R63: also gate "no filter" on activeGroup so pinnedGroup
2374
+ // alone activates the in-group 1.3× boost + non-group dim.
2375
+ // When neither a node nor a group is in focus, mul is 1.
2376
+ // R77: when the user hovers the "N active links" chip, the
2377
+ // baseline 1× becomes 1.5× — every flow brightens at once.
2378
+ // Sits inside the no-other-hover branch so it doesn't fight
2379
+ // the edge-hover (2.0×) or node-hover (1.7×) priorities.
2380
+ const edgeOpacityMul = isHoveredEdge
2381
+ ? 2.0
2382
+ : activeEdgeKey
2383
+ ? 0.35
2384
+ : !hoveredAlias && !activeGroup
2385
+ ? (hoveredActiveLinks ? 1.5 : 1)
2386
+ : (link.from === hoveredAlias || link.to === hoveredAlias)
2387
+ ? 1.7
2388
+ : bothInHoveredGroup
2389
+ ? 1.3
2390
+ : 0.35;
2391
+ // Round 50: the hovered edge also visibly thickens so the eye
2392
+ // tracks it even at low message counts (width was 3 → 4.5 on
2393
+ // hover). 1.4× is enough to read as "lifted" without
2394
+ // breaching the 16-px hitbox bound.
2395
+ const renderWidth = isHoveredEdge ? Math.min(width * 1.4, 10) : width;
1007
2396
  return (
1008
2397
  <g key={link.key}>
2398
+ {/* Round 48 / Loop: invisible hover hitbox — visible flow
2399
+ path is 3-7 px wide and damn hard to hover precisely.
2400
+ Stack a transparent 16-px-wide stroke behind it so the
2401
+ cursor only needs to be ~8 px from the line for the
2402
+ tooltip to fire. Native <title> moves here; the
2403
+ visible path no longer needs it. pointer-events on
2404
+ the visible path drop to "none" since the hitbox
2405
+ owns the hover surface. */}
2406
+ <path
2407
+ d={path}
2408
+ fill="none"
2409
+ stroke="transparent"
2410
+ strokeWidth={Math.max(width + 10, 16)}
2411
+ style={{ pointerEvents: 'stroke' }}
2412
+ data-edge-hitbox
2413
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
2414
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
2415
+ >
2416
+ <title>{tooltip}</title>
2417
+ </path>
1009
2418
  <path
1010
2419
  d={path}
1011
2420
  fill="none"
1012
2421
  stroke={pal.flowEdge}
1013
- strokeWidth={width}
1014
- opacity={isLight ? 0.22 : 0.28}
2422
+ strokeWidth={renderWidth}
2423
+ opacity={Math.min(1, (isLight ? 0.22 : 0.28) * fresh * edgeOpacityMul)}
1015
2424
  filter={isLight ? undefined : 'url(#topo-glow)'}
1016
- markerEnd="url(#topo-arrow)"
2425
+ markerEnd={`url(#${arrowId})`}
2426
+ className="transition-opacity duration-300"
2427
+ style={{ pointerEvents: 'none' }}
1017
2428
  />
1018
2429
  <path
1019
2430
  id={`flow-path-${index}`}
@@ -1022,45 +2433,284 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1022
2433
  stroke={pal.flowPath}
1023
2434
  strokeWidth="1"
1024
2435
  strokeDasharray="2 12"
1025
- opacity={isLight ? 0.4 : 0.75}
2436
+ opacity={Math.min(1, (isLight ? 0.4 : 0.75) * fresh * edgeOpacityMul)}
2437
+ className="transition-opacity duration-300"
1026
2438
  />
1027
- <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'}>
1028
- <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
1029
- </circle>
2439
+ {!reducedMotion && (
2440
+ /* Round 103 / Loop: phase-stagger the particles so
2441
+ concurrent edges don't pulse in lockstep. SMIL
2442
+ `begin` accepts negative offsets to shift the cycle
2443
+ backwards in time, which means the particle starts
2444
+ mid-flight on first paint — no visible "all
2445
+ particles spawn from source simultaneously" tell.
2446
+ `(index * 0.37) % duration` gives a deterministic,
2447
+ well-distributed offset (the golden-ratio-ish 0.37
2448
+ fraction prevents lining up when N is a small
2449
+ multiple). Edge order is stable (sorted by recent
2450
+ activity), so the offsets feel calm rather than
2451
+ reshuffling each refresh. */
2452
+ <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'} opacity={Math.min(1, fresh * edgeOpacityMul)}>
2453
+ <animateMotion
2454
+ dur={`${duration}s`}
2455
+ begin={`-${((index * 0.37) % duration).toFixed(3)}s`}
2456
+ repeatCount="indefinite"
2457
+ path={path}
2458
+ />
2459
+ </circle>
2460
+ )}
2461
+ {/* Round 75 / Loop: arrival ping at the destination. The
2462
+ particle currently fades into the arrow marker
2463
+ silently — adding a small radiating ring synchronised
2464
+ to the particle's period turns message delivery into
2465
+ a visible event. begin = -dur*0.92 offsets the
2466
+ animation so the ring expands NEAR the end of each
2467
+ cycle (≈when the particle arrives). Gated by
2468
+ reducedMotion and on fresh > 0.5 — stale edges that
2469
+ haven't fired in minutes don't need the eye-grab.
2470
+ data-arrival-ping for testability. */}
2471
+ {!reducedMotion && fresh > 0.5 && (
2472
+ <circle
2473
+ cx={to.x}
2474
+ cy={to.y}
2475
+ r="0"
2476
+ fill="none"
2477
+ stroke={pal.flowEdge}
2478
+ strokeWidth="1.5"
2479
+ opacity="0"
2480
+ style={{ pointerEvents: 'none' }}
2481
+ data-arrival-ping={link.key}
2482
+ >
2483
+ <animate attributeName="r" values="0;14;22" dur={`${duration}s`} begin={`-${(duration * 0.92).toFixed(2)}s`} repeatCount="indefinite" />
2484
+ <animate attributeName="opacity" values="0;0.55;0" dur={`${duration}s`} begin={`-${(duration * 0.92).toFixed(2)}s`} repeatCount="indefinite" />
2485
+ </circle>
2486
+ )}
2487
+ {/* Round 76 / Loop: source dispatch pulse — mirror to the
2488
+ R75 arrival ping. begin = 0s (start of cycle) so the
2489
+ ring expands as the particle LEAVES the source. Only
2490
+ fires for high-traffic edges (link.count >= 3) — on
2491
+ quiet conversations the canvas should stay calm; on
2492
+ busy senders the pulse plus arrival ping bookend
2493
+ every message in flight, making the topology feel
2494
+ alive. Same fresh/reducedMotion gates as R75. Slightly
2495
+ smaller radius (0→12→18 vs R75's 0→14→22) so the
2496
+ source reads as "smaller event than arrival" — the
2497
+ destination is the meaningful endpoint. */}
2498
+ {!reducedMotion && fresh > 0.5 && link.count >= 3 && (
2499
+ <circle
2500
+ cx={from.x}
2501
+ cy={from.y}
2502
+ r="0"
2503
+ fill="none"
2504
+ stroke={pal.flowEdge}
2505
+ strokeWidth="1.5"
2506
+ opacity="0"
2507
+ style={{ pointerEvents: 'none' }}
2508
+ data-dispatch-pulse={link.key}
2509
+ >
2510
+ <animate attributeName="r" values="0;12;18" dur={`${duration}s`} begin="0s" repeatCount="indefinite" />
2511
+ <animate attributeName="opacity" values="0;0.45;0" dur={`${duration}s`} begin="0s" repeatCount="indefinite" />
2512
+ </circle>
2513
+ )}
2514
+ {/* Round 100 / Loop: midpoint count badge for high-
2515
+ traffic edges. Width already gauges intensity but
2516
+ "5 vs 12" is hard to read off stroke alone. Render
2517
+ a compact pill at the bezier midpoint (t=0.5 →
2518
+ midpoint + perpendicular × lift/2) showing the
2519
+ integer count. Threshold link.count >= 3 keeps
2520
+ the canvas quiet for sparse traffic. Bezier
2521
+ midpoint math: for a quadratic curve M A Q C B,
2522
+ the t=0.5 point is (A + 2C + B) / 4, which
2523
+ simplifies to (midAB) + 0.5 × (control - midAB)
2524
+ = midAB + perpendicular × lift/2. */}
2525
+ {link.count >= 3 && (() => {
2526
+ const midX = (from.x + to.x) / 2;
2527
+ const midY = (from.y + to.y) / 2;
2528
+ const dx = to.x - from.x;
2529
+ const dy = to.y - from.y;
2530
+ const len = Math.hypot(dx, dy) || 1;
2531
+ const badgeX = midX + (-dy / len) * lift * 0.5;
2532
+ const badgeY = midY + ( dx / len) * lift * 0.5;
2533
+ const badgeOpacity = Math.min(1, fresh * edgeOpacityMul);
2534
+ /* R121: the badge becomes a canvas-side click-to-pin
2535
+ affordance. R100 introduced it as a passive count
2536
+ display; R116 added pinnedEdgeKey. Joining them
2537
+ lets users pin a flow directly from the canvas
2538
+ without crossing to the recent-signal panel.
2539
+ pointerEvents move from 'none' to 'all' on the
2540
+ wrapper <g>; the underlying R48 edge hitbox is
2541
+ wider (16 px) than the 18-px badge diameter so
2542
+ clicks landing on either still route correctly
2543
+ — the badge consumes its small footprint, the
2544
+ hitbox owns the rest. stopPropagation on
2545
+ pointerdown so the SVG pan capture doesn't
2546
+ redirect the click. */
2547
+ const isPinned = pinnedEdgeKey === link.key;
2548
+ return (
2549
+ <g
2550
+ data-edge-count-badge={link.key}
2551
+ data-edge-count-badge-pinned={isPinned ? 'true' : 'false'}
2552
+ role="button"
2553
+ tabIndex={0}
2554
+ aria-pressed={isPinned}
2555
+ style={{ pointerEvents: 'all', cursor: 'pointer' }}
2556
+ opacity={badgeOpacity}
2557
+ onPointerDown={(e) => e.stopPropagation()}
2558
+ // R122: badge hover propagates to hoveredEdgeKey so
2559
+ // moving the cursor onto the badge lights the
2560
+ // same endpoint rings + edge brighten as hovering
2561
+ // the line. R121 only wired click; the badge sat
2562
+ // visually separate from the line on hover,
2563
+ // which felt like two surfaces rather than one.
2564
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
2565
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
2566
+ onClick={(e) => {
2567
+ e.stopPropagation();
2568
+ setPinnedEdgeKey(prev => prev === link.key ? null : link.key);
2569
+ }}
2570
+ onKeyDown={(e) => {
2571
+ if (e.key === 'Enter' || e.key === ' ') {
2572
+ e.preventDefault();
2573
+ setPinnedEdgeKey(prev => prev === link.key ? null : link.key);
2574
+ }
2575
+ }}
2576
+ >
2577
+ <title>{isPinned
2578
+ ? `${link.from} → ${link.to} (${link.count}) — click to release pin`
2579
+ : `${link.from} → ${link.to} (${link.count}) — click to pin`}</title>
2580
+ <circle
2581
+ cx={badgeX} cy={badgeY} r="9"
2582
+ fill={pal.legendBox.fill}
2583
+ stroke={isPinned ? pal.legendHeadline : pal.flowEdge}
2584
+ strokeWidth={isPinned ? 2 : 1}
2585
+ opacity={isLight ? 0.95 : 0.82}
2586
+ />
2587
+ <text
2588
+ x={badgeX} y={badgeY + 3}
2589
+ textAnchor="middle"
2590
+ fill={pal.legendHeadline}
2591
+ fontSize="10"
2592
+ fontFamily="monospace"
2593
+ fontWeight="700"
2594
+ style={{ pointerEvents: 'none' }}
2595
+ >{link.count}</text>
2596
+ </g>
2597
+ );
2598
+ })()}
1030
2599
  </g>
1031
2600
  );
1032
2601
  })}
1033
2602
 
1034
- {/* center hub — round 39: enlarged from r=6 (12px) to r=10 (20px)
1035
- core so the "control plane" reads as the network's anchor,
1036
- not a stray particle. Static halo ring at r=18 grounds it
1037
- visually; two outward pulses keep the "signal source" idea
1038
- from r17. Light theme gets a paler core to avoid hot-spotting
1039
- the bg. */}
1040
- {layout === 'ring' && (<g>
1041
- {/* static grounding halo sits underneath, low opacity */}
1042
- <circle
1043
- cx={cx} cy={cy} r="18"
1044
- fill={isLight ? '#d1fae5' : '#10b981'}
1045
- opacity={isLight ? 0.35 : 0.10}
1046
- />
1047
- {/* outer pulse 1 */}
1048
- <circle cx={cx} cy={cy} r="10" fill="none" stroke="#10b981" strokeWidth="1.5">
1049
- <animate attributeName="r" values="10;38;10" dur="2.4s" repeatCount="indefinite" />
1050
- <animate attributeName="opacity" values="0.55;0;0.55" dur="2.4s" repeatCount="indefinite" />
1051
- </circle>
1052
- {/* outer pulse 2 (delayed 1.2s) */}
1053
- <circle cx={cx} cy={cy} r="10" fill="none" stroke="#10b981" strokeWidth="1.5" opacity="0">
1054
- <animate attributeName="r" values="10;38;10" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
1055
- <animate attributeName="opacity" values="0.45;0;0.45" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
1056
- </circle>
2603
+ {/* center hub — round 39 sized + round 13 restraint.
2604
+ The hub is the control-plane anchor. r39 gave it two outward
2605
+ pulses (r 10→38, 2.4s, double-phase) which read as "loudest
2606
+ node in the room" wrong semantics: anchors don't emit,
2607
+ they hold. r13 swaps the kinetic pulse for a slow opacity
2608
+ breath on the static halo (4s, ±15% from base), so the hub
2609
+ stays alive without throwing kinetic energy outward. The
2610
+ dual-circle "lit lamp" core is unchanged. */}
2611
+ {layout === 'ring' && (<g
2612
+ data-topo-hub
2613
+ data-topo-hub-hovered={hoveredHub ? 'true' : 'false'}
2614
+ style={{ cursor: 'pointer' }}
2615
+ // Stop pointerdown from reaching the SVG pan handler — same
2616
+ // reason as the node <g>: a captured pointer makes Chromium
2617
+ // fire the follow-up click on the SVG, not this group.
2618
+ onPointerDown={(e) => e.stopPropagation()}
2619
+ onMouseEnter={() => setHoveredHub(true)}
2620
+ onMouseLeave={() => setHoveredHub(false)}
2621
+ // Round 52 / Loop: hub is the visual anchor but had no click
2622
+ // action users discovered fit-to-content only via the `f`
2623
+ // key or the chrome button. Wire the hub to fitView() so the
2624
+ // most prominent element in the canvas is also the "re-
2625
+ // center" affordance. A click-ripple keyed by ts confirms
2626
+ // the gesture; the hub <title> now ends with a hint.
2627
+ onClick={() => {
2628
+ fitView();
2629
+ setClickRipple({ ts: Date.now(), x: cx, y: cy, r0: 18, color: isLight ? '#059669' : '#10b981' });
2630
+ setTimeout(() => setClickRipple(prev => prev && prev.x === cx && prev.y === cy ? null : prev), 600);
2631
+ }}
2632
+ >
2633
+ {/* Round 43 / Loop: hub `<title>` summary — hovering the
2634
+ central glow now answers "what is this?" with a one-line
2635
+ fleet snapshot. Duplicates the header chips by design;
2636
+ hovering the most visually prominent element is the
2637
+ most natural impulse, so satisfy it where the cursor
2638
+ already is. Falls clean: omits sub-clauses when their
2639
+ counts are zero. R52 appends a "click to fit view" hint.
2640
+ */}
2641
+ <title>{(() => {
2642
+ const total = sessions.length;
2643
+ const parts = [`Network hub`, `${total} session${total === 1 ? '' : 's'}`];
2644
+ if (onlineNodes.length > 0) parts.push(`${onlineNodes.length} online`);
2645
+ if (workingCount > 0) parts.push(`${workingCount} working`);
2646
+ if (flowLinks.length > 0) parts.push(`${flowLinks.length} active link${flowLinks.length === 1 ? '' : 's'}`);
2647
+ return parts.join(' · ') + '\nclick to fit view';
2648
+ })()}</title>
2649
+ {/* grounding halo — breathes in opacity, no expansion.
2650
+ R84: breath amplitude + tempo reflect workingCount. An
2651
+ idle fleet keeps the original gentle 0.32→0.52 (light) /
2652
+ 0.08→0.16 (dark) cycle over 4s — "heart at rest". As
2653
+ working sessions accumulate, the peak climbs and the
2654
+ period shortens, capped at 2.4s so it never feels
2655
+ manic. Quiet fleets see zero change; busy fleets feel
2656
+ the canvas working. */}
2657
+ {(() => {
2658
+ // Bucket workingCount to keep the visual feedback discrete
2659
+ // rather than continuous: 0 / 1-2 / 3-5 / 6+. Three buckets
2660
+ // are enough — finer gradations are imperceptible.
2661
+ const busy = workingCount === 0 ? 0
2662
+ : workingCount <= 2 ? 1
2663
+ : workingCount <= 5 ? 2
2664
+ : 3;
2665
+ const peakLight = [0.52, 0.58, 0.65, 0.72][busy];
2666
+ const peakDark = [0.16, 0.20, 0.26, 0.32][busy];
2667
+ const troughLight = 0.32;
2668
+ const troughDark = 0.08;
2669
+ const dur = [4.0, 3.2, 2.7, 2.4][busy];
2670
+ const valuesLight = `${troughLight};${peakLight};${troughLight}`;
2671
+ const valuesDark = `${troughDark};${peakDark};${troughDark}`;
2672
+ return (
2673
+ <circle
2674
+ cx={cx} cy={cy} r="18"
2675
+ fill={isLight ? '#d1fae5' : '#10b981'}
2676
+ opacity={isLight ? 0.42 : 0.12}
2677
+ data-hub-busyness={busy}
2678
+ >
2679
+ {!reducedMotion && (
2680
+ <animate
2681
+ attributeName="opacity"
2682
+ values={isLight ? valuesLight : valuesDark}
2683
+ dur={`${dur}s`}
2684
+ repeatCount="indefinite"
2685
+ />
2686
+ )}
2687
+ </circle>
2688
+ );
2689
+ })()}
1057
2690
  {/* core — 20px diameter, larger inner highlight reads as a "lit lamp" */}
1058
2691
  <circle cx={cx} cy={cy} r="10" fill={isLight ? '#059669' : '#10b981'} />
1059
2692
  <circle cx={cx} cy={cy} r="5" fill="#d1fae5" opacity="0.9" />
2693
+ {/* R115 / Loop: hover hint ring. Stroke-only circle at r=14
2694
+ that fades in when the hub is hovered — the same idea
2695
+ R44 used for node avatars (group-hover stroke). r=14
2696
+ sits comfortably outside the r=10 core and INSIDE the
2697
+ r=18 grounding halo, so the hover indicator is fully
2698
+ contained within the existing hub footprint (no bbox
2699
+ growth, overlap test unchanged). pointerEvents:none so
2700
+ the hint can't intercept the click that produced it. */}
2701
+ <circle
2702
+ cx={cx} cy={cy} r="14"
2703
+ fill="none"
2704
+ stroke={isLight ? '#059669' : '#10b981'}
2705
+ strokeWidth="1.5"
2706
+ opacity={hoveredHub ? (isLight ? 0.85 : 0.7) : 0}
2707
+ data-topo-hub-hover-ring
2708
+ style={{ pointerEvents: 'none', transition: 'opacity 180ms ease-out' }}
2709
+ />
1060
2710
  </g>)}
1061
2711
 
1062
2712
  {/* agent nodes */}
1063
- {[...onlineNodes, ...offlineNodes].map(session => {
2713
+ {[...onlineNodes, ...offlineNodes].map((session, nodeIdx) => {
1064
2714
  const pos = nodePositions[session.alias];
1065
2715
  if (!pos) return null;
1066
2716
 
@@ -1080,11 +2730,30 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1080
2730
  // occlude an avatar), and the full card appears only for the
1081
2731
  // hovered node or once zoomed in past 1.4×.
1082
2732
  const showFullLabel = !denseLayout || hoveredAlias === session.alias || view.zoom >= 1.4;
2733
+ // Round 8: when a node in another group is hovered, fade this
2734
+ // one. Same-group nodes (incl. singletons matching the hovered
2735
+ // alias) stay full. Pure visual focus, geometry unchanged.
2736
+ const inFocus = !activeGroup || (groupKeys[session.alias] ?? session.alias) === activeGroup;
2737
+ // R72: in ring layout, classify the node into a tier by its
2738
+ // distance from hub centre so the first-paint stagger can
2739
+ // emanate inner → outer instead of running clockwise. Grid
2740
+ // layout doesn't have a radial structure; it keeps R9's pure
2741
+ // nodeIdx stagger. Thresholds bracket the actual tier radii
2742
+ // (single 220, dual 175/260, triple 145/215/285, offline 325+).
2743
+ let tierIdx = 0;
2744
+ if (layout === 'ring') {
2745
+ const p = nodePositions[session.alias];
2746
+ if (p) {
2747
+ const d = Math.hypot(p.x - cx, p.y - cy);
2748
+ tierIdx = d < 195 ? 0 : d < 270 ? 1 : d < 310 ? 2 : 3;
2749
+ }
2750
+ }
1083
2751
 
1084
2752
  return (
1085
2753
  <g
1086
2754
  key={session.alias}
1087
2755
  data-node={session.alias}
2756
+ data-tier-idx={layout === 'ring' ? tierIdx : -1}
1088
2757
  // Round 3 / Loop: `anet-fade-in` runs once when the <g>
1089
2758
  // mounts — a new session entering the fleet (or the topology
1090
2759
  // first rendering) eases in instead of popping. Re-renders of
@@ -1092,7 +2761,87 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1092
2761
  // via the alias key), so status changes don't flicker. The
1093
2762
  // global prefers-reduced-motion sweep already neutralises it.
1094
2763
  className="group transition-opacity anet-fade-in"
1095
- style={{ cursor: 'pointer' }}
2764
+ style={{
2765
+ cursor: 'pointer',
2766
+ // Round 17 / Loop: offline nodes drop to 0.6 at rest so
2767
+ // online nodes pop without losing the offline-as-ghost
2768
+ // information. The dashed stroke + smaller radius already
2769
+ // say "offline"; dimming the whole group strengthens the
2770
+ // online-vs-offline hierarchy at first glance. Exempt the
2771
+ // chat-focused node — if the user explicitly opened a
2772
+ // popover targeting an offline alias, that node stays
2773
+ // full-brightness so the focus ring + popover read as one
2774
+ // selected thing rather than a dimmed selection. Group-
2775
+ // hover fade (Round 8) still wins when inFocus is false.
2776
+ // Round 49 / Loop: edge-hover endpoint highlight composes
2777
+ // OVER the inFocus/online formula — a non-endpoint node
2778
+ // dims to 0.28 (just below the inFocus 0.32 to read as a
2779
+ // stronger "not relevant" signal), endpoints keep their
2780
+ // base opacity. chatAlias still wins to keep the focus
2781
+ // ring legible if the user clicked through.
2782
+ // Round 55 / Loop: legend-status hover composes on the
2783
+ // SAME level as edge-hover-endpoint. Non-matching nodes
2784
+ // dim to 0.28; matching nodes stay at their base.
2785
+ // activeStatus matches: working = status==='working',
2786
+ // idle = online but not working, offline = !isOnline.
2787
+ // Round 60 / Loop: activeStatus = hoveredStatus ?? pinnedStatus
2788
+ // so the pressure-bar segment pins (R60) and the legend
2789
+ // row hover (R55) feed the same branch.
2790
+ // Round 80 / Loop: vendor-letter hover composes ABOVE the
2791
+ // activeStatus branch so hovering the vendor chip's `C`
2792
+ // dims everything except C-vendor nodes. Same dim value
2793
+ // (0.28) as edge-endpoint and status filters — visually
2794
+ // consistent. Vendor lookup uses the same vendorForModel
2795
+ // helper the avatar render uses, keyed by initial so the
2796
+ // chip and the avatar always agree on grouping.
2797
+ opacity: hoveredEdgeEndpoints && !hoveredEdgeEndpoints.has(session.alias) && chatAlias !== session.alias
2798
+ ? 0.28
2799
+ : activeVendor && chatAlias !== session.alias && (() => {
2800
+ const v = vendorForModel(session.model);
2801
+ const initial = v.id === 'unknown' ? '?' : v.initial;
2802
+ return initial !== activeVendor;
2803
+ })()
2804
+ ? 0.28
2805
+ : activeStatus && chatAlias !== session.alias && !(
2806
+ activeStatus === 'working' ? session.status === 'working'
2807
+ : activeStatus === 'idle' ? (isOnline && session.status !== 'working')
2808
+ : /* offline */ !isOnline
2809
+ )
2810
+ ? 0.28
2811
+ : !inFocus
2812
+ ? 0.32
2813
+ : chatAlias === session.alias
2814
+ ? 1
2815
+ : isOnline ? 1 : 0.6,
2816
+ // Round 9 / Loop: stagger the anet-fade-in so the topology
2817
+ // reveals as a wave on first paint instead of one big pop.
2818
+ // Cap at 24 indices (≈600ms tail) so 50-node fleets still
2819
+ // finish revealing within a beat. CSS animation-delay only
2820
+ // applies during the keyframe — re-renders without a new
2821
+ // mount (same alias key) don't replay, so status changes
2822
+ // never trigger the stagger again.
2823
+ // Round 72 / Loop: in ring layout, stagger by tier radius
2824
+ // so the topology emanates from the hub outward — inner
2825
+ // tier at 0ms, middle at ~180ms, outer at ~360ms, offline
2826
+ // at ~540ms. A small within-tier offset (nodeIdx % 6 * 25)
2827
+ // adds variety so each ring rotates in instead of all-at-
2828
+ // once popping. Grid keeps R9's pure nodeIdx stagger.
2829
+ animationDelay: layout === 'ring'
2830
+ ? `${tierIdx * 180 + (nodeIdx % 6) * 25}ms`
2831
+ : `${Math.min(nodeIdx, 24) * 25}ms`,
2832
+ // Round 51 / Loop: hover micro-lift. The label already
2833
+ // lifts on group-hover (R26). The node body — circle,
2834
+ // avatar, status ring — stays planted, so the gesture
2835
+ // reads "label moves, body doesn't". Now the whole <g>
2836
+ // translates -2px when hovered: avatar + ring + label
2837
+ // move as one unit. 2 px is well inside the inter-row
2838
+ // gap (cellH headroom ≥22), and CSS transform on SVG
2839
+ // <g> never affects the overlap-test geometry (the
2840
+ // test never simulates hover). useReducedMotion drops
2841
+ // the lift to 0 for prefers-reduced-motion users.
2842
+ transform: !reducedMotion && hoveredAlias === session.alias ? 'translateY(-2px)' : undefined,
2843
+ transition: 'transform 180ms cubic-bezier(0.4,0,0.2,1)',
2844
+ }}
1096
2845
  // Stop the pointerdown from reaching the SVG pan handler: the
1097
2846
  // SVG calls setPointerCapture, and a captured pointer makes
1098
2847
  // Chromium fire the follow-up `click` on the SVG instead of
@@ -1100,14 +2849,71 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1100
2849
  // Side effect (intended): you pan from empty canvas, not by
1101
2850
  // grabbing a node.
1102
2851
  onPointerDown={(e) => e.stopPropagation()}
1103
- onPointerEnter={() => denseLayout && setHoveredAlias(session.alias)}
1104
- onPointerLeave={() => denseLayout && setHoveredAlias(prev => (prev === session.alias ? null : prev))}
1105
- onClick={() => setChatAlias(session.alias)}
2852
+ // Track hover globally (not just under denseLayout) so the
2853
+ // Round 8 group-focus fade works at any fleet size.
2854
+ onPointerEnter={() => setHoveredAlias(session.alias)}
2855
+ onPointerLeave={() => setHoveredAlias(prev => (prev === session.alias ? null : prev))}
2856
+ onClick={() => {
2857
+ setChatAlias(session.alias);
2858
+ // Round 14 ripple — capture position, radius and status
2859
+ // colour at click time so the one-shot circle is self-
2860
+ // contained (no re-render-time recomputation needed).
2861
+ setClickRipple({
2862
+ ts: Date.now(),
2863
+ x: pos.x, y: pos.y, r0: radius,
2864
+ color: status.primary,
2865
+ });
2866
+ setTimeout(() => setClickRipple(prev =>
2867
+ prev && Date.now() - prev.ts >= 590 ? null : prev), 600);
2868
+ }}
1106
2869
  >
1107
2870
  {/* Issue #96: native hover tooltip — "Vendor · model · Runtime".
1108
2871
  Falls back to just the alias when the node reports no
1109
- model/runtime. */}
1110
- <title>{identityLine(session.model, session.runtime) || session.alias}</title>
2872
+ model/runtime.
2873
+ Round 33 / Loop: cwd line answers "what is this agent on?".
2874
+ Round 34 / Loop: for offline nodes, append "last seen: 6m ago"
2875
+ so the operator knows whether to wait or chase. Online nodes
2876
+ skip the line (Round 27's 1 h ghost age-out means anything
2877
+ still online has heartbeated recently — the line would just
2878
+ be noise). Accepts both ISO ("…T06:00:28Z") and SQL-style
2879
+ ("… 06:00:28" assumed UTC) formats. */}
2880
+ {(() => {
2881
+ // Round 35 / Loop: TZ-safe parsing via the shared lib helper
2882
+ // — same parseHubTime is used by isGhost above (Round 38
2883
+ // factored it to app/lib/time.ts so both paths interpret SQL
2884
+ // bare timestamps as UTC on every browser).
2885
+ const lastSeen = !isOnline && session.last_seen_at ? relativeAgo(session.last_seen_at) : null;
2886
+ // Round 98 / Loop: enrich the node tooltip with status,
2887
+ // group membership, and inbound/outbound flow counts —
2888
+ // same info-density spirit as R97 active-filter pill
2889
+ // tooltips. Hovering any node now answers "what is this
2890
+ // and how does it sit in the topology" without forcing
2891
+ // a click into the chat popover. Group line only shows
2892
+ // when the alias is part of a multi-member band (R106
2893
+ // prefix grouping); singletons skip it. Flow line only
2894
+ // when at least one direction has count > 0.
2895
+ const groupKey = groupKeys[session.alias];
2896
+ const groupMembers = groupKey
2897
+ ? Object.values(groupKeys).filter(k => k === groupKey).length
2898
+ : 1;
2899
+ const groupLine = groupMembers > 1 ? `group: ${groupKey} · ${groupMembers}` : null;
2900
+ let flowIn = 0, flowOut = 0;
2901
+ for (const fl of flowLinks) {
2902
+ if (fl.from === session.alias) flowOut += fl.count;
2903
+ if (fl.to === session.alias) flowIn += fl.count;
2904
+ }
2905
+ const flowLine = (flowIn + flowOut) > 0 ? `flows: ${flowIn} in / ${flowOut} out` : null;
2906
+ return (
2907
+ <title>{[
2908
+ `${session.alias} · ${session.status}`,
2909
+ identityLine(session.model, session.runtime),
2910
+ groupLine,
2911
+ session.project_dir ? `cwd: ${session.project_dir}` : null,
2912
+ lastSeen ? `last seen: ${lastSeen}` : null,
2913
+ flowLine,
2914
+ ].filter(Boolean).join('\n')}</title>
2915
+ );
2916
+ })()}
1111
2917
  {/* Round 2 / Loop: hover ring — a thin outer stroke that fades
1112
2918
  in when the cursor enters the node, signalling clickability
1113
2919
  (real-user feedback for the chat-popover open). Pure CSS via
@@ -1127,7 +2933,47 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1127
2933
  className="opacity-0 group-hover:opacity-70 transition-opacity duration-150"
1128
2934
  style={{ pointerEvents: 'none' }}
1129
2935
  />
1130
- {isActive && (
2936
+ {/* Round 11 / Loop: chat-focus ring — when the ChatPopover is
2937
+ open targeting this node, anchor a persistent ring around
2938
+ it so the floating popover visibly links back to its source
2939
+ node. Static (not pulsing) so it reads as "selected state"
2940
+ rather than "this node is active". strokeWidth=2.5 stays
2941
+ clear of the overlap-test selectors (1.5 / 3). Sits just
2942
+ outside the halo radius+8 so it never overlaps a neighbour
2943
+ (halos already pack flush in dense grids). */}
2944
+ {chatAlias === session.alias && (
2945
+ /* R51 chat-target ring. R120 / Loop: gentle SMIL
2946
+ breath on the ring's opacity (±0.1 over 3s) when
2947
+ chat is open + !reducedMotion. Says "active session
2948
+ here" continuously without animation noise — the
2949
+ ring only appears for one node at a time (the
2950
+ chatAlias), so it never competes with R84 hub
2951
+ busyness or R112 working halo for attention. */
2952
+ <circle
2953
+ cx={pos.x}
2954
+ cy={pos.y}
2955
+ r={radius + 14}
2956
+ fill="none"
2957
+ stroke={status.primary}
2958
+ strokeWidth="2.5"
2959
+ opacity={isLight ? 0.85 : 0.95}
2960
+ filter={!isLight ? 'url(#topo-glow)' : undefined}
2961
+ className="transition-opacity duration-200"
2962
+ style={{ pointerEvents: 'none' }}
2963
+ data-chat-target-ring
2964
+ data-chat-target-breath={!reducedMotion ? 'on' : 'off'}
2965
+ >
2966
+ {!reducedMotion && (
2967
+ <animate
2968
+ attributeName="opacity"
2969
+ values={isLight ? '0.72;0.95;0.72' : '0.82;1;0.82'}
2970
+ dur="3s"
2971
+ repeatCount="indefinite"
2972
+ />
2973
+ )}
2974
+ </circle>
2975
+ )}
2976
+ {isActive && !reducedMotion && (
1131
2977
  <circle cx={pos.x} cy={pos.y} r={radius + 14} fill={status.primary} opacity={isLight ? 0.08 : 0.12}>
1132
2978
  <animate attributeName="r" values={`${radius + 8};${radius + 22};${radius + 8}`} dur="2.4s" repeatCount="indefinite" />
1133
2979
  <animate attributeName="opacity" values={isLight ? '0.12;0.02;0.12' : '0.18;0.04;0.18'} dur="2.4s" repeatCount="indefinite" />
@@ -1135,7 +2981,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1135
2981
  )}
1136
2982
  {/* Round 4 / Loop: transition-[fill,stroke,opacity] smooths
1137
2983
  status colour changes so idle↔working↔offline doesn't snap
1138
- — task replies / node-rename / SSE updates ease in. */}
2984
+ — task replies / node-rename / SSE updates ease in.
2985
+ Round 112 / Loop: working nodes get a subtle halo breath
2986
+ (±0.12 opacity at 3s cycle) so the eye can find "what's
2987
+ busy" at a glance without scanning chips. Idle + offline
2988
+ halos stay flat — they don't need to demand attention.
2989
+ R84 hub-center breath stays the loudest "fleet busyness"
2990
+ signal; this one is quieter, per-node. SMIL `<animate>`
2991
+ inside the circle, gated by reducedMotion. */}
1139
2992
  <circle
1140
2993
  cx={pos.x}
1141
2994
  cy={pos.y}
@@ -1143,7 +2996,39 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1143
2996
  fill={status.halo}
1144
2997
  opacity={isOnline ? (isLight ? 0.85 : 0.65) : (isLight ? 0.4 : 0.25)}
1145
2998
  className="transition-[fill,opacity] duration-300 ease-out"
1146
- />
2999
+ data-node-halo-breath={!reducedMotion && session.status === 'working' ? 'on' : 'off'}
3000
+ >
3001
+ {!reducedMotion && session.status === 'working' && (
3002
+ <animate
3003
+ attributeName="opacity"
3004
+ values={isLight ? '0.73;0.92;0.73' : '0.53;0.78;0.53'}
3005
+ dur="3s"
3006
+ repeatCount="indefinite"
3007
+ />
3008
+ )}
3009
+ </circle>
3010
+ {/* Round 111 / Loop: edge-endpoint emphasis ring. R49
3011
+ already keeps endpoint nodes at opacity 1 while
3012
+ others dim when an edge is hovered, but the
3013
+ endpoints had no POSITIVE indicator — they just
3014
+ "stayed bright". An accent stroke at r=radius+7
3015
+ (just inside the halo's r=radius+8 bbox so we
3016
+ don't grow the overlap footprint) clearly says
3017
+ "these are the two participants in this flow".
3018
+ pointerEvents:none so the node hitbox stays alive. */}
3019
+ {hoveredEdgeEndpoints && hoveredEdgeEndpoints.has(session.alias) && (
3020
+ <circle
3021
+ cx={pos.x}
3022
+ cy={pos.y}
3023
+ r={radius + 7}
3024
+ fill="none"
3025
+ stroke={pal.flowEdge}
3026
+ strokeWidth={1.5}
3027
+ opacity={isLight ? 0.9 : 0.85}
3028
+ data-edge-endpoint-ring
3029
+ style={{ pointerEvents: 'none', transition: 'opacity 180ms ease-out' }}
3030
+ />
3031
+ )}
1147
3032
  <circle
1148
3033
  cx={pos.x}
1149
3034
  cy={pos.y}
@@ -1240,52 +3125,115 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1240
3125
  </g>
1241
3126
  );
1242
3127
  })()}
1243
- {session.status === 'working' && (
1244
- // working pulse small dot just inside the status ring,
1245
- // above the avatar text. Was a 18px horizontal bar at
1246
- // y+11 which now collides with the avatar; the dot reads
1247
- // as the same "active" cue without occluding the initial.
1248
- <circle cx={pos.x} cy={pos.y - (radius - 6)} r="2.5" fill={pal.flowParticle}>
1249
- <animate attributeName="opacity" values="1;0.25;1" dur="1.1s" repeatCount="indefinite" />
1250
- </circle>
1251
- )}
3128
+ {session.status === 'working' && (() => {
3129
+ // Round 24 / Loop: pulse rate traffic. The dot's dur
3130
+ // was a flat 1.1s every working node looked equally
3131
+ // busy. Mapping it to sse:N lets the eye land on what's
3132
+ // actually hot. 3 discrete tiers read cleaner than
3133
+ // continuous easing at a glance; thresholds picked so
3134
+ // typical 1-2-SSE Claude/Codex sessions stay in the
3135
+ // "active" tier and only multi-pane orchestrators pop
3136
+ // into the "busy" tier. Geometry unchanged (r=2.5).
3137
+ const sse = sseCountFor ?? 0;
3138
+ const dur = sse >= 4 ? '0.7s' : sse >= 2 ? '0.9s' : '1.2s';
3139
+ return (
3140
+ <circle cx={pos.x} cy={pos.y - (radius - 6)} r="2.5" fill={pal.flowParticle} data-pulse-dur={dur} opacity={reducedMotion ? 0.6 : undefined}>
3141
+ {!reducedMotion && (
3142
+ <animate attributeName="opacity" values="1;0.25;1" dur={dur} repeatCount="indefinite" />
3143
+ )}
3144
+ </circle>
3145
+ );
3146
+ })()}
1252
3147
 
1253
3148
  {/* Round 98 (issue #61): label rect 124px → 100px.
1254
3149
  Round 109/110 (Vincent P0): full opaque card below the
1255
3150
  density threshold / on hover / when zoomed; otherwise a
1256
3151
  lightweight plain-text alias that keeps every node
1257
- labelled without an opaque box covering its neighbours. */}
1258
- {showFullLabel ? (
1259
- <g transform={`translate(${pos.x}, ${pos.y + radius + 22})`} style={{ pointerEvents: 'none' }}>
1260
- <rect x="-50" y="-14" width="100" height="42" rx="6" fill={pal.labelBox.fill} stroke={pal.labelBox.stroke} opacity={isLight ? 1 : 0.94} />
1261
- <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize="12" fontFamily="monospace" fontWeight="700">
1262
- {truncate(session.alias, 12)}
1263
- </text>
1264
- <text x="0" y="17" textAnchor="middle" fill={status.primary} fontSize="9" fontFamily="monospace">
1265
- {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
3152
+ labelled without an opaque box covering its neighbours.
3153
+ Round 15 / Loop: when nodeScale=S the node shrinks 30%
3154
+ but the label card was staying full-size, so the label
3155
+ visually outweighed the small node. Tighten the card
3156
+ frame, alias / sub fontSize, drop-offset and truncate
3157
+ length specifically for S; M and L keep their existing
3158
+ sizes (M ≈ L for labels by design — the S user is the
3159
+ one who explicitly asked for a denser view). */}
3160
+ {(() => {
3161
+ const isSmall = nodeScale < 0.8;
3162
+ const cardW = isSmall ? 88 : 100;
3163
+ const cardH = isSmall ? 36 : 42;
3164
+ const cardTopY = isSmall ? -12 : -14;
3165
+ const aliasFs = isSmall ? 11 : 12;
3166
+ const subFs = isSmall ? 8 : 9;
3167
+ const subY = isSmall ? 15 : 17;
3168
+ const dropY = isSmall ? 18 : 22;
3169
+ const fullMax = isSmall ? 11 : 12;
3170
+ const denseFs = isSmall ? 9 : 10;
3171
+ const denseDrop = isSmall ? 12 : 14;
3172
+ // Round 26 / Loop: micro-lift the label group on hover —
3173
+ // 1.5 px upward, 200 ms ease. Pairs with the Round 18
3174
+ // group-box hover-accent treatment to give the same
3175
+ // "this is the focused element" feedback at the per-
3176
+ // node level. CSS transform stacks onto the SVG
3177
+ // positioning transform attribute (SVG 2 cascade);
3178
+ // bbox is unchanged at rest, so the overlap-test gate
3179
+ // continues to see the geometric layout it expects.
3180
+ return showFullLabel ? (
3181
+ <g transform={`translate(${pos.x}, ${pos.y + radius + dropY})`} style={{ pointerEvents: 'none' }}
3182
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]">
3183
+ <rect x={-cardW / 2} y={cardTopY} width={cardW} height={cardH} rx="6" fill={pal.labelBox.fill} stroke={pal.labelBox.stroke} opacity={isLight ? 1 : 0.94} />
3184
+ <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize={aliasFs} fontFamily="monospace" fontWeight="700">
3185
+ {truncate(session.alias, fullMax)}
3186
+ </text>
3187
+ <text x="0" y={subY} textAnchor="middle" fill={status.primary} fontSize={subFs} fontFamily="monospace">
3188
+ {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
3189
+ </text>
3190
+ </g>
3191
+ ) : (
3192
+ <text
3193
+ x={pos.x}
3194
+ y={pos.y + radius + denseDrop}
3195
+ textAnchor="middle"
3196
+ fill={status.text}
3197
+ fontSize={denseFs}
3198
+ fontFamily="monospace"
3199
+ fontWeight="700"
3200
+ opacity={0.9}
3201
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]"
3202
+ style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
3203
+ stroke={pal.containerBg}
3204
+ strokeWidth="3"
3205
+ >
3206
+ {truncate(session.alias, isSmall ? 9 : 10)}
1266
3207
  </text>
1267
- </g>
1268
- ) : (
1269
- <text
1270
- x={pos.x}
1271
- y={pos.y + radius + 14}
1272
- textAnchor="middle"
1273
- fill={status.text}
1274
- fontSize="10"
1275
- fontFamily="monospace"
1276
- fontWeight="700"
1277
- opacity={0.9}
1278
- style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
1279
- stroke={pal.containerBg}
1280
- strokeWidth="3"
1281
- >
1282
- {truncate(session.alias, 10)}
1283
- </text>
1284
- )}
3208
+ );
3209
+ })()}
1285
3210
  </g>
1286
3211
  );
1287
3212
  })}
1288
3213
 
3214
+ {/* Round 14 / Loop: click ripple — one-shot expanding ring from
3215
+ the clicked node, ~500ms, status-coloured. Sits inside the
3216
+ zoom/pan <g> so it scales / pans with the topology. Keyed by
3217
+ ts so a re-click on any node (same or different) remounts the
3218
+ <circle> and the SMIL <animate> elements replay from t=0.
3219
+ strokeWidth=2 doesn't match the overlap-test selectors. */}
3220
+ {clickRipple && !reducedMotion && (
3221
+ <circle
3222
+ key={clickRipple.ts}
3223
+ cx={clickRipple.x}
3224
+ cy={clickRipple.y}
3225
+ r={clickRipple.r0 + 4}
3226
+ fill="none"
3227
+ stroke={clickRipple.color}
3228
+ strokeWidth="2"
3229
+ opacity="0"
3230
+ style={{ pointerEvents: 'none' }}
3231
+ >
3232
+ <animate attributeName="r" values={`${clickRipple.r0 + 4};${clickRipple.r0 + 30}`} dur="0.5s" fill="freeze" />
3233
+ <animate attributeName="opacity" values="0.7;0" dur="0.5s" fill="freeze" />
3234
+ </circle>
3235
+ )}
3236
+
1289
3237
  </g>
1290
3238
 
1291
3239
  {/* #112: overlay panels (recent-signal + legend) render OUTSIDE the
@@ -1296,30 +3244,336 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1296
3244
  corner triangles, so the panels never overlap a node, in ring
1297
3245
  OR grid layout (Vincent 4727 zero-overlap criterion). */}
1298
3246
  {/* latest flow labels */}
3247
+ {/* Round 57 / Loop: drop-shadow on the panel rects gives them
3248
+ card-like elevation, especially on light theme where the
3249
+ near-white fill on a near-white canvas read as pasted-on.
3250
+ data-topo-panel-elevation tag so the test can verify both
3251
+ panels carry the filter. The filter is on the rect, not
3252
+ the parent <g>, so it doesn't shadow the rows + text inside
3253
+ — only the panel chrome lifts. */}
1299
3254
  <g transform="translate(16, 16)">
1300
- <rect x="0" y="0" width="230" height="84" rx="10" fill={pal.legendBox.fill} stroke={pal.legendBox.stroke} opacity={isLight ? 0.97 : 0.92} />
3255
+ <rect
3256
+ x="0" y="0" width="230" height="84" rx="10"
3257
+ fill={pal.legendBox.fill}
3258
+ stroke={pal.legendBox.stroke}
3259
+ opacity={isLight ? 0.97 : 0.92}
3260
+ style={{ filter: isLight ? 'drop-shadow(0 2px 6px rgba(15,23,42,0.08))' : 'drop-shadow(0 2px 6px rgba(0,0,0,0.45))' }}
3261
+ data-topo-panel-elevation="recent"
3262
+ />
1301
3263
  <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700">recent signal</text>
1302
- <text x="150" y="21" fill={pal.legendAccent} fontSize="10" fontFamily="monospace">{messages.length} msgs</text>
1303
- {flowLinks.slice(0, 3).map((link, index) => (
1304
- <text key={link.key} x="13" y={38 + index * 16} fill={pal.legendText} fontSize="9" fontFamily="monospace">
1305
- {truncate(link.from, 6)} {'->'} {truncate(link.to, 6)} / {link.count} / {truncate(link.content, 12)}
1306
- </text>
1307
- ))}
3264
+ {/* R96: header count now matches what the rows show. Pre-R96
3265
+ this read "X msgs" off the raw messages array, but the
3266
+ rows below render DEDUPED flowLinks so a fleet with 10
3267
+ messages aggregating to 3 pairs read "10 msgs" above
3268
+ only 3 rows. Misreads as "where are the other 7?".
3269
+ "X flows" mirrors flowLinks.length one-for-one. When
3270
+ flows < msgs the chip-row's "N active links · last 2s"
3271
+ already tells the operator about traffic volume — no
3272
+ duplicate metric needed here. */}
3273
+ <text x="150" y="21" fill={pal.legendAccent} fontSize="10" fontFamily="monospace" data-recent-panel-count>{flowLinks.length} flows</text>
3274
+ {/* Round 45 / Loop: empty state. The panel used to render
3275
+ "recent signal" + "0 msgs" with three blank slots below
3276
+ when no flow yet — read as "broken" rather than "quiet".
3277
+ A muted centred placeholder makes the empty state
3278
+ deliberate. Messages count CAN diverge from flowLinks
3279
+ count (raw count vs. deduped pairs), so the placeholder
3280
+ fires on flowLinks.length=0 specifically. */}
3281
+ {flowLinks.length === 0 ? (
3282
+ /* R45 placeholder. R110: add a sub-text hint so the
3283
+ empty state explains what it's empty OF — operators
3284
+ looking at "no flow yet" sometimes mistook it for
3285
+ a connection error. The sub-line reads as a quiet
3286
+ invitation, not an error. Two-line layout uses
3287
+ standard SVG <text>+<text> rather than tspan so the
3288
+ y-coordinates are explicit and the data-attr selector
3289
+ still finds the primary line. */
3290
+ <>
3291
+ <text x="115" y="54" textAnchor="middle" fill={pal.legendText} fontSize="10" fontFamily="monospace" fontStyle="italic" opacity={0.65} data-recent-signal-empty>
3292
+ no flow yet
3293
+ </text>
3294
+ <text x="115" y="68" textAnchor="middle" fill={pal.legendText} fontSize="8" fontFamily="monospace" opacity={0.45} data-recent-signal-empty-hint>
3295
+ send a message between agents
3296
+ </text>
3297
+ </>
3298
+ ) : (
3299
+ // Round 56 / Loop: each row is a navigator into the canvas.
3300
+ // Hover a row → set hoveredEdgeKey, which the existing R50
3301
+ // edge-focus + R49 endpoint-highlight ladders consume. The
3302
+ // matching flow edge brightens to 2× + thickens, its two
3303
+ // endpoint nodes stay full opacity, and every other edge +
3304
+ // non-endpoint node dims. Released → all restore. Wrapping
3305
+ // <g> + a transparent 218×14 hitbox so the cursor doesn't
3306
+ // have to land precisely on the truncated text.
3307
+ flowLinks.slice(0, 3).map((link, index) => {
3308
+ // Round 94 / Loop: per-row relative timestamp. The chip
3309
+ // row shows "last 2s" for the most recent flow overall,
3310
+ // but a user scanning the recent-signal panel had no way
3311
+ // to tell whether row 2 was 5s old or 5m old without
3312
+ // hovering nodes. Compact `2s` / `1m` glyph at the
3313
+ // right edge — same relativeAgo helper R42 uses for
3314
+ // the chip — pulls double duty as a recency anchor and
3315
+ // a sortedness hint (top row is freshest by construction).
3316
+ // Strip the " ago" suffix — at fontSize=8 in a 32-px
3317
+ // right-edge slot, every char counts. "30s ago" → "30s".
3318
+ const rawAt = link.last_at ? relativeAgo(link.last_at) : null;
3319
+ const lastAt = rawAt ? rawAt.replace(/\s+ago$/, '') : null;
3320
+ const isRowHovered = hoveredEdgeKey === link.key;
3321
+ const isRowPinned = pinnedEdgeKey === link.key;
3322
+ const isRowActive = isRowHovered || isRowPinned;
3323
+ return (
3324
+ <g
3325
+ key={link.key}
3326
+ data-recent-row={link.key}
3327
+ data-recent-row-hovered={isRowHovered ? 'true' : 'false'}
3328
+ data-recent-row-pinned={isRowPinned ? 'true' : 'false'}
3329
+ role="button"
3330
+ tabIndex={0}
3331
+ aria-pressed={isRowPinned}
3332
+ style={{ cursor: 'pointer' }}
3333
+ onPointerDown={(e) => e.stopPropagation()}
3334
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
3335
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
3336
+ // R116: click toggles pin. activeEdgeKey =
3337
+ // hoveredEdgeKey ?? pinnedEdgeKey so the matching
3338
+ // edge stays "hot" after mouseleave; click again
3339
+ // (or Esc) releases.
3340
+ onClick={() => setPinnedEdgeKey(prev => prev === link.key ? null : link.key)}
3341
+ onKeyDown={(e) => {
3342
+ if (e.key === 'Enter' || e.key === ' ') {
3343
+ e.preventDefault();
3344
+ setPinnedEdgeKey(prev => prev === link.key ? null : link.key);
3345
+ }
3346
+ }}
3347
+ >
3348
+ {/* R104: subtle row-background tint on hover. R56
3349
+ already brightens the matching edge on the canvas,
3350
+ but the panel row itself stayed flat — felt more
3351
+ like text-with-handlers than a navigable list.
3352
+ Filling the rect at hover with `pal.legendAccent`
3353
+ at low alpha gives the row visual feedback at the
3354
+ source surface, mirroring the list-item idiom from
3355
+ the chip-row pills. R116: pinned rows tint
3356
+ stronger than hovered ones so locked vs preview
3357
+ is discriminable. */}
3358
+ <rect
3359
+ x="6" y={38 + index * 16 - 10}
3360
+ width="218" height="14" rx="3"
3361
+ fill={isRowActive ? pal.legendAccent : 'transparent'}
3362
+ opacity={isRowPinned ? (isLight ? 0.18 : 0.22)
3363
+ : isRowHovered ? (isLight ? 0.10 : 0.14)
3364
+ : 1}
3365
+ style={{ transition: 'fill 150ms ease-out, opacity 150ms ease-out' }}
3366
+ />
3367
+ <text
3368
+ x="13" y={38 + index * 16}
3369
+ fill={isRowActive ? pal.legendHeadline : pal.legendText}
3370
+ fontSize="9"
3371
+ fontFamily="monospace"
3372
+ style={{ transition: 'fill 150ms ease-out' }}
3373
+ >
3374
+ {truncate(link.from, 6)} {'->'} {truncate(link.to, 6)} / {link.count} / {truncate(link.content, 8)}
3375
+ </text>
3376
+ {lastAt ? (
3377
+ <text
3378
+ x="217" y={38 + index * 16}
3379
+ textAnchor="end"
3380
+ fill={pal.legendText}
3381
+ fontSize="8"
3382
+ fontFamily="monospace"
3383
+ opacity={0.55}
3384
+ data-recent-row-ts={link.key}
3385
+ style={{ pointerEvents: 'none' }}
3386
+ >
3387
+ {lastAt}
3388
+ </text>
3389
+ ) : null}
3390
+ </g>
3391
+ );
3392
+ })
3393
+ )}
1308
3394
  </g>
1309
3395
 
1310
- {/* legend */}
3396
+ {/* legend — Round 55 / Loop: each status row is now a hover
3397
+ target. Pointer enter sets `hoveredStatus`; pointer leave
3398
+ clears it. Node opacity formula composes the match below.
3399
+ The row text brightens to legendHeadline while hovered as
3400
+ a small affordance hint. Geometry unchanged — the new
3401
+ <g> wrappers only carry pointer handlers. */}
1311
3402
  <g transform="translate(760, 16)">
1312
- <rect x="0" y="0" width="224" height="96" rx="10" fill={pal.legendBox.fill} stroke={pal.legendBox.stroke} opacity={isLight ? 0.97 : 0.92} />
1313
- <circle cx="16" cy="24" r="5.5" fill={isLight ? '#059669' : '#22c55e'} />
1314
- <text x="30" y="28" fill={pal.legendText} fontSize="11" fontFamily="monospace">working node</text>
1315
- <circle cx="16" cy="48" r="5.5" fill={isLight ? '#0d9488' : '#2dd4bf'} />
1316
- <text x="30" y="52" fill={pal.legendText} fontSize="11" fontFamily="monospace">online idle</text>
1317
- <circle cx="16" cy="72" r="5.5" fill={isLight ? '#94a3b8' : '#6b7280'} />
1318
- <text x="30" y="76" fill={pal.legendText} fontSize="11" fontFamily="monospace">offline / no SSE</text>
1319
- <path d="M140,72 Q164,48 196,72" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" />
3403
+ {/* R57: matching drop-shadow elevation to the legend panel.
3404
+ R106: panel height grew 96 104 to seat the new header
3405
+ line + 4 px row-shift below it (so the new header text
3406
+ doesn't overlap the row-1 hitbox region). */}
3407
+ <rect
3408
+ x="0" y="0" width="224" height="104" rx="10"
3409
+ fill={pal.legendBox.fill}
3410
+ stroke={pal.legendBox.stroke}
3411
+ opacity={isLight ? 0.97 : 0.92}
3412
+ style={{ filter: isLight ? 'drop-shadow(0 2px 6px rgba(15,23,42,0.08))' : 'drop-shadow(0 2px 6px rgba(0,0,0,0.45))' }}
3413
+ data-topo-panel-elevation="legend"
3414
+ />
3415
+ {/* R106 / Loop: panel header — symmetric with the recent-
3416
+ signal panel's "recent signal · N flows" (R96). Same
3417
+ font + position vocabulary so the two side panels feel
3418
+ paired. Title text at x=13 y=21; total fleet count
3419
+ right-aligned at x=215 y=21 in the accent colour. */}
3420
+ <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700">legend</text>
3421
+ <text x="215" y="21" textAnchor="end" fill={pal.legendAccent} fontSize="10" fontFamily="monospace" data-legend-panel-count>{sessions.length} node{sessions.length === 1 ? '' : 's'}</text>
3422
+ {(() => {
3423
+ const idleCount = onlineNodes.length - workingCount;
3424
+ // R106: rows shift +8 px (was y0=24, 48, 72 → 32, 56, 80)
3425
+ // to clear the new header row. R57 panel rect grew 96 →
3426
+ // 104 to seat them.
3427
+ const rows = [
3428
+ { key: 'working' as const, y0: 32, y1: 36, fill: isLight ? '#059669' : '#22c55e', label: 'working node', count: workingCount },
3429
+ { key: 'idle' as const, y0: 56, y1: 60, fill: isLight ? '#0d9488' : '#2dd4bf', label: 'online idle', count: idleCount },
3430
+ { key: 'offline' as const, y0: 80, y1: 84, fill: isLight ? '#94a3b8' : '#6b7280', label: 'offline / no SSE', count: offlineNodes.length },
3431
+ ];
3432
+ return rows;
3433
+ })().map(row => {
3434
+ // Round 61 / Loop: legend rows pin too — symmetric with the
3435
+ // R60 pressure-bar segments. R55 hover stays transient; the
3436
+ // new onClick toggles pinnedStatus so users can lock a
3437
+ // filter without holding the cursor still. Pinned row gets
3438
+ // an inset ring on the swatch (same vocab as R60).
3439
+ const isPinned = pinnedStatus === row.key;
3440
+ return (
3441
+ <g
3442
+ key={row.key}
3443
+ data-legend-status={row.key}
3444
+ role="button"
3445
+ tabIndex={0}
3446
+ aria-pressed={isPinned}
3447
+ style={{ cursor: 'pointer' }}
3448
+ // R61: stopPropagation on pointerdown so the SVG-level
3449
+ // pan handler (R103) doesn't setPointerCapture and
3450
+ // redirect the follow-up click away from this <g>.
3451
+ // Same trick the node <g> uses (and R52 hub).
3452
+ onPointerDown={(e) => e.stopPropagation()}
3453
+ onMouseEnter={() => setHoveredStatus(row.key)}
3454
+ onMouseLeave={() => setHoveredStatus(prev => prev === row.key ? null : prev)}
3455
+ onClick={() => setPinnedStatus(prev => prev === row.key ? null : row.key)}
3456
+ >
3457
+ {/* R55 hitbox covers the row so cursor doesn't need to
3458
+ be exactly on the 5-px swatch. R105 / Loop: the
3459
+ hitbox now also carries a subtle hover/pin tint —
3460
+ mirroring R104's recent-signal row treatment so
3461
+ both side panels share the list-item idiom. The
3462
+ tint borrows the ROW'S OWN swatch colour rather
3463
+ than legendAccent, so the user's eye associates
3464
+ the tinted row with its colour swatch instead of
3465
+ a generic accent. Pin is a stronger tint than
3466
+ hover; idle stays fully transparent. */}
3467
+ <rect
3468
+ x="6" y={row.y0 - 12}
3469
+ width="170" height="22" rx="3"
3470
+ fill={hoveredStatus === row.key || isPinned ? row.fill : 'transparent'}
3471
+ opacity={isPinned ? (isLight ? 0.14 : 0.18)
3472
+ : hoveredStatus === row.key ? (isLight ? 0.08 : 0.12)
3473
+ : 1}
3474
+ data-legend-row-tinted={isPinned ? 'pinned' : hoveredStatus === row.key ? 'hover' : 'none'}
3475
+ style={{ transition: 'fill 150ms ease-out, opacity 150ms ease-out' }}
3476
+ />
3477
+ <circle cx="16" cy={row.y0} r="5.5" fill={row.fill} />
3478
+ {/* R61 pinned-state ring — concentric stroke at r=8 in
3479
+ the row colour, draws OUTSIDE the swatch so it
3480
+ doesn't fight the fill colour the user is matching. */}
3481
+ {isPinned && (
3482
+ <circle cx="16" cy={row.y0} r="8" fill="none" stroke={row.fill} strokeWidth="1.5" />
3483
+ )}
3484
+ <text
3485
+ x="30" y={row.y1}
3486
+ fill={hoveredStatus === row.key || isPinned ? pal.legendHeadline : pal.legendText}
3487
+ fontSize="11"
3488
+ fontFamily="monospace"
3489
+ style={{ transition: 'fill 150ms ease-out' }}
3490
+ >{row.label}</text>
3491
+ {/* R95: live count anchored to the right edge of the
3492
+ panel (x=215, after the flow-arrow swatch). Same
3493
+ counts the chip-row shows ("3 working" etc.) but
3494
+ here next to the swatch the user is matching —
3495
+ saves crossing the canvas to the chip row for
3496
+ the number. text-anchor=end aligns the column
3497
+ visually like a table. pointerEvents:none so the
3498
+ count doesn't intercept the row hover hitbox. */}
3499
+ <text
3500
+ x="215" y={row.y1}
3501
+ textAnchor="end"
3502
+ fill={pal.legendText}
3503
+ fontSize="11"
3504
+ fontFamily="monospace"
3505
+ opacity={hoveredStatus === row.key || isPinned ? 0.95 : 0.65}
3506
+ data-legend-count={row.key}
3507
+ style={{ pointerEvents: 'none', transition: 'opacity 150ms ease-out' }}
3508
+ >{row.count}</text>
3509
+ </g>
3510
+ );
3511
+ })}
3512
+ {/* Flow-arrow swatch tracks the offline row — R106 shifted
3513
+ rows down by 8 px to make space for the panel header so
3514
+ this moves from y=72 to y=80. Drop its pointerEvents so
3515
+ the offline legend row stays hoverable (R55). It's
3516
+ decoration, no need to receive events. */}
3517
+ <path d="M140,80 Q164,56 196,80" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" style={{ pointerEvents: 'none' }} />
1320
3518
  </g>
1321
3519
  </svg>
1322
3520
 
3521
+ {/* Round 30 / Loop: minimap. Big fleets in fullscreen mode at high
3522
+ zoom let users lose their position — the minimap shows the
3523
+ whole topology miniaturised plus a viewport rectangle so the
3524
+ user always knows where they are. Click anywhere to recenter
3525
+ the canvas there. Only mounted when the view is non-default
3526
+ (zoomed or panned) since at 1× centered the minimap and the
3527
+ canvas show the same thing. HTML overlay so it stays fixed
3528
+ while the SVG transforms. */}
3529
+ {(() => {
3530
+ const isDefaultView = Math.abs(view.zoom - 1) < 0.01 && Math.abs(view.x) < 1 && Math.abs(view.y) < 1;
3531
+ if (isDefaultView || (onlineNodes.length + offlineNodes.length) === 0) return null;
3532
+ const MW = 120, MH = 82;
3533
+ const sx = MW / VIEWBOX_W, sy = MH / VIEWBOX_H;
3534
+ const rectX = (-view.x / view.zoom) * sx;
3535
+ const rectY = (-view.y / view.zoom) * sy;
3536
+ const rectW = (VIEWBOX_W / view.zoom) * sx;
3537
+ const rectH = (VIEWBOX_H / view.zoom) * sy;
3538
+ return (
3539
+ <div
3540
+ className="absolute right-3 rounded-md border shadow-lg shadow-black/30 overflow-hidden anet-fade-in"
3541
+ style={{ bottom: 56, background: pal.legendBox.fill, borderColor: pal.containerBorder, cursor: 'crosshair' }}
3542
+ role="img"
3543
+ aria-label="Topology minimap — click to recenter"
3544
+ title="Minimap · click to recenter"
3545
+ onClick={(e) => {
3546
+ const r = e.currentTarget.getBoundingClientRect();
3547
+ const fx = (e.clientX - r.left) / r.width;
3548
+ const fy = (e.clientY - r.top) / r.height;
3549
+ setView(prev => ({
3550
+ ...prev,
3551
+ x: VIEWBOX_W / 2 - fx * VIEWBOX_W * prev.zoom,
3552
+ y: VIEWBOX_H / 2 - fy * VIEWBOX_H * prev.zoom,
3553
+ }));
3554
+ }}
3555
+ data-topo-minimap
3556
+ >
3557
+ <svg width={MW} height={MH} viewBox={`0 0 ${MW} ${MH}`} style={{ display: 'block' }}>
3558
+ {[...onlineNodes, ...offlineNodes].map(s => {
3559
+ const p = nodePositions[s.alias];
3560
+ if (!p) return null;
3561
+ const sseN = (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
3562
+ const isOn = s.status !== 'offline' || !!sseN;
3563
+ const st = nodeStatus(s, isOn, isLight);
3564
+ return <circle key={s.alias} cx={p.x * sx} cy={p.y * sy} r={isOn ? 1.7 : 1.2} fill={st.primary} opacity={isOn ? 0.9 : 0.5} />;
3565
+ })}
3566
+ {/* viewport rectangle */}
3567
+ <rect
3568
+ x={Math.max(0, rectX)} y={Math.max(0, rectY)}
3569
+ width={Math.max(0, Math.min(MW - Math.max(0, rectX), rectW))}
3570
+ height={Math.max(0, Math.min(MH - Math.max(0, rectY), rectH))}
3571
+ fill="none" stroke={pal.legendAccent} strokeWidth="1" opacity="0.9"
3572
+ />
3573
+ </svg>
3574
+ </div>
3575
+ );
3576
+ })()}
1323
3577
  {/* Round 103 (issue #81): zoom / pan / fullscreen controls — HTML
1324
3578
  overlay so they stay fixed while the SVG content transforms.
1325
3579
  Round 104: Vincent 实测 — the reset action used to be hidden
@@ -1356,6 +3610,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1356
3610
  className="px-2 py-1 hover:bg-white/5 transition-colors"
1357
3611
  style={{ color: pal.legendText }}
1358
3612
  aria-label="Zoom out"
3613
+ title="Zoom out (−)"
1359
3614
  >
1360
3615
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden><path d="M5 12h14" /></svg>
1361
3616
  </button>
@@ -1371,6 +3626,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1371
3626
  className="px-2 py-1 hover:bg-white/5 transition-colors"
1372
3627
  style={{ color: pal.legendText }}
1373
3628
  aria-label="Zoom in"
3629
+ title="Zoom in (+)"
1374
3630
  >
1375
3631
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden><path d="M12 5v14M5 12h14" /></svg>
1376
3632
  </button>
@@ -1380,7 +3636,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1380
3636
  className="p-1.5 rounded-md border hover:bg-white/5 transition-colors"
1381
3637
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
1382
3638
  aria-label="Reset view"
1383
- title="Reset zoom + pan (or double-click the canvas)"
3639
+ title="Reset zoom + pan (0, or double-click the canvas)"
1384
3640
  >
1385
3641
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1386
3642
  <path d="M3 12a9 9 0 1 0 9-9 9 9 0 0 0-6.4 2.6L3 8" />