@sleep2agi/agent-network-dashboard 0.4.6-preview.16 → 0.4.6-preview.161

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 (365) 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/032~4k7il7hnk.css +2 -0
  182. package/.next/static/chunks/03o.h6kvmw4l_.js +1 -0
  183. package/.next/static/chunks/03~~oirxz7~vc.js +1 -0
  184. package/.next/static/chunks/05uk96gc~9mni.js +1 -0
  185. package/.next/static/chunks/06fy8p2g0jbz1.js +4 -0
  186. package/.next/static/chunks/08kf-.bgs10ze.js +1 -0
  187. package/.next/static/chunks/0ggj5ummw2a84.js +7 -0
  188. package/.next/static/chunks/0glmrdj9112tc.js +7 -0
  189. package/.next/static/chunks/0oehx47rte9ek.js +1 -0
  190. package/.next/static/chunks/0rxrez_cjn.eh.js +1 -0
  191. package/.next/static/chunks/0x8x.29hkju5a.js +1 -0
  192. package/.next/static/chunks/10z.kc0g0jj8p.js +1 -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 +126 -0
  202. package/app/components/ServersDrawer.tsx +64 -27
  203. package/app/components/TopoGraph.tsx +3102 -237
  204. package/app/components/utils.ts +8 -7
  205. package/app/globals.css +46 -5
  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-nav-test.mjs +117 -0
  213. package/scripts/topo-active-links-chip-test.mjs +73 -0
  214. package/scripts/topo-active-links-tooltip-test.mjs +79 -0
  215. package/scripts/topo-arrival-ping-test.mjs +87 -0
  216. package/scripts/topo-arrow-tier-test.mjs +61 -0
  217. package/scripts/topo-auto-fit-test.mjs +73 -0
  218. package/scripts/topo-chat-focus-ring-test.mjs +77 -0
  219. package/scripts/topo-chat-target-breath-test.mjs +84 -0
  220. package/scripts/topo-chip-pin-mirror-test.mjs +113 -0
  221. package/scripts/topo-chrome-a11y-test.mjs +107 -0
  222. package/scripts/topo-click-ripple-test.mjs +66 -0
  223. package/scripts/topo-cmdk-edge-clear-test.mjs +109 -0
  224. package/scripts/topo-cmdk-layout-view-test.mjs +108 -0
  225. package/scripts/topo-cmdk-pin-test.mjs +123 -0
  226. package/scripts/topo-cmdk-vendor-clear-test.mjs +133 -0
  227. package/scripts/topo-cmdk-vendor-pin-test.mjs +103 -0
  228. package/scripts/topo-cwd-tooltip-test.mjs +64 -0
  229. package/scripts/topo-dblclick-reset-test.mjs +74 -0
  230. package/scripts/topo-dispatch-pulse-test.mjs +83 -0
  231. package/scripts/topo-edge-badge-click-pin-test.mjs +94 -0
  232. package/scripts/topo-edge-badge-hot-test.mjs +115 -0
  233. package/scripts/topo-edge-badge-hover-test.mjs +85 -0
  234. package/scripts/topo-edge-count-badge-test.mjs +86 -0
  235. package/scripts/topo-edge-endpoint-highlight-test.mjs +111 -0
  236. package/scripts/topo-edge-focus-test.mjs +113 -0
  237. package/scripts/topo-edge-hover-test.mjs +93 -0
  238. package/scripts/topo-edge-pill-hot-test.mjs +129 -0
  239. package/scripts/topo-edge-pill-test.mjs +105 -0
  240. package/scripts/topo-edge-tooltip-test.mjs +70 -0
  241. package/scripts/topo-endpoint-ring-test.mjs +93 -0
  242. package/scripts/topo-esc-clears-pin-test.mjs +98 -0
  243. package/scripts/topo-filter-pill-aliases-test.mjs +121 -0
  244. package/scripts/topo-filter-pill-body-click-test.mjs +112 -0
  245. package/scripts/topo-filter-pill-count-test.mjs +110 -0
  246. package/scripts/topo-filter-pill-mobile-test.mjs +90 -0
  247. package/scripts/topo-fit-key-test.mjs +106 -0
  248. package/scripts/topo-freshness-chip-test.mjs +74 -0
  249. package/scripts/topo-group-box-pinned-state-test.mjs +95 -0
  250. package/scripts/topo-group-count-test.mjs +68 -0
  251. package/scripts/topo-group-hover-test.mjs +82 -0
  252. package/scripts/topo-group-label-a11y-test.mjs +113 -0
  253. package/scripts/topo-group-label-hover-test.mjs +119 -0
  254. package/scripts/topo-group-label-tint-test.mjs +99 -0
  255. package/scripts/topo-group-label-title-test.mjs +83 -0
  256. package/scripts/topo-group-pin-test.mjs +119 -0
  257. package/scripts/topo-group-status-pips-test.mjs +103 -0
  258. package/scripts/topo-groupbox-hover-lift-test.mjs +127 -0
  259. package/scripts/topo-groupbox-live-test.mjs +100 -0
  260. package/scripts/topo-groupbox-march-busyness-test.mjs +155 -0
  261. package/scripts/topo-groupbox-test.mjs +4 -2
  262. package/scripts/topo-help-overlay-r109-test.mjs +67 -0
  263. package/scripts/topo-help-overlay-r118-test.mjs +69 -0
  264. package/scripts/topo-help-overlay-r123-test.mjs +69 -0
  265. package/scripts/topo-help-overlay-r134-test.mjs +74 -0
  266. package/scripts/topo-help-overlay-r137-test.mjs +81 -0
  267. package/scripts/topo-help-overlay-r141-test.mjs +86 -0
  268. package/scripts/topo-help-overlay-r153-test.mjs +85 -0
  269. package/scripts/topo-help-overlay-r59-test.mjs +67 -0
  270. package/scripts/topo-help-overlay-r65-test.mjs +64 -0
  271. package/scripts/topo-help-overlay-r70-test.mjs +63 -0
  272. package/scripts/topo-help-overlay-r78-test.mjs +59 -0
  273. package/scripts/topo-help-overlay-r81-test.mjs +58 -0
  274. package/scripts/topo-help-overlay-r87-test.mjs +64 -0
  275. package/scripts/topo-help-overlay-r91-test.mjs +65 -0
  276. package/scripts/topo-hub-busyness-test.mjs +100 -0
  277. package/scripts/topo-hub-click-fit-test.mjs +137 -0
  278. package/scripts/topo-hub-hover-ring-test.mjs +82 -0
  279. package/scripts/topo-hub-title-test.mjs +93 -0
  280. package/scripts/topo-hub-working-count-test.mjs +99 -0
  281. package/scripts/topo-ingroup-edge-test.mjs +103 -0
  282. package/scripts/topo-label-lift-test.mjs +119 -0
  283. package/scripts/topo-label-scale-test.mjs +78 -0
  284. package/scripts/topo-lastseen-tooltip-test.mjs +59 -0
  285. package/scripts/topo-layout-key-test.mjs +91 -0
  286. package/scripts/topo-legend-count-test.mjs +88 -0
  287. package/scripts/topo-legend-hover-test.mjs +113 -0
  288. package/scripts/topo-legend-panel-header-test.mjs +83 -0
  289. package/scripts/topo-legend-pin-test.mjs +128 -0
  290. package/scripts/topo-legend-row-hover-lift-test.mjs +109 -0
  291. package/scripts/topo-legend-row-tint-test.mjs +95 -0
  292. package/scripts/topo-legend-row-title-test.mjs +102 -0
  293. package/scripts/topo-minimap-test.mjs +68 -0
  294. package/scripts/topo-mobile-chips-test.mjs +103 -0
  295. package/scripts/topo-node-a11y-test.mjs +107 -0
  296. package/scripts/topo-node-hover-lift-test.mjs +85 -0
  297. package/scripts/topo-node-title-peers-test.mjs +107 -0
  298. package/scripts/topo-node-title-test.mjs +101 -0
  299. package/scripts/topo-offline-dim-test.mjs +70 -0
  300. package/scripts/topo-online-chip-nav-test.mjs +143 -0
  301. package/scripts/topo-orbit-busyness-test.mjs +96 -0
  302. package/scripts/topo-overlap-test.mjs +32 -5
  303. package/scripts/topo-pan-cursor-test.mjs +62 -0
  304. package/scripts/topo-panel-elevation-test.mjs +76 -0
  305. package/scripts/topo-panel-hover-elevation-test.mjs +118 -0
  306. package/scripts/topo-particle-stagger-test.mjs +82 -0
  307. package/scripts/topo-pin-intersection-empty-test.mjs +129 -0
  308. package/scripts/topo-pin-intersection-test.mjs +121 -0
  309. package/scripts/topo-pin-persistence-test.mjs +105 -0
  310. package/scripts/topo-popover-meta-test.mjs +78 -0
  311. package/scripts/topo-pressure-bar-test.mjs +69 -0
  312. package/scripts/topo-pressure-hover-test.mjs +123 -0
  313. package/scripts/topo-pressure-pin-test.mjs +119 -0
  314. package/scripts/topo-pressure-seg-tooltip-test.mjs +98 -0
  315. package/scripts/topo-pulse-rate-test.mjs +71 -0
  316. package/scripts/topo-recent-panel-count-test.mjs +72 -0
  317. package/scripts/topo-recent-panel-hot-header-test.mjs +132 -0
  318. package/scripts/topo-recent-panel-more-nav-test.mjs +127 -0
  319. package/scripts/topo-recent-panel-more-test.mjs +106 -0
  320. package/scripts/topo-recent-row-hot-test.mjs +110 -0
  321. package/scripts/topo-recent-row-hover-lift-test.mjs +128 -0
  322. package/scripts/topo-recent-row-hover-test.mjs +122 -0
  323. package/scripts/topo-recent-row-pin-test.mjs +126 -0
  324. package/scripts/topo-recent-row-tint-test.mjs +91 -0
  325. package/scripts/topo-recent-row-title-test.mjs +112 -0
  326. package/scripts/topo-recent-row-ts-test.mjs +95 -0
  327. package/scripts/topo-recent-row-typography-test.mjs +119 -0
  328. package/scripts/topo-recent-signal-empty-hint-test.mjs +73 -0
  329. package/scripts/topo-recent-signal-empty-test.mjs +63 -0
  330. package/scripts/topo-reduced-motion-test.mjs +77 -0
  331. package/scripts/topo-spoke-busyness-test.mjs +110 -0
  332. package/scripts/topo-status-chip-hover-test.mjs +101 -0
  333. package/scripts/topo-status-chip-tooltip-test.mjs +95 -0
  334. package/scripts/topo-sweep-busyness-test.mjs +94 -0
  335. package/scripts/topo-tier-ring-occupancy-test.mjs +87 -0
  336. package/scripts/topo-tier-ring-test.mjs +69 -0
  337. package/scripts/topo-tier-ring-tint-test.mjs +110 -0
  338. package/scripts/topo-tier-stagger-test.mjs +89 -0
  339. package/scripts/topo-tz-ghost-test.mjs +69 -0
  340. package/scripts/topo-vendor-chip-hover-test.mjs +111 -0
  341. package/scripts/topo-vendor-letter-tooltip-test.mjs +92 -0
  342. package/scripts/topo-vendor-pill-test.mjs +125 -0
  343. package/scripts/topo-vendor-pin-test.mjs +125 -0
  344. package/scripts/topo-wheel-zoom-test.mjs +79 -0
  345. package/scripts/topo-working-chip-pin-test.mjs +148 -0
  346. package/scripts/topo-working-halo-breath-test.mjs +86 -0
  347. package/scripts/topo-zoom-keys-test.mjs +89 -0
  348. package/.next/static/chunks/0-7iowzu4.798.js +0 -1
  349. package/.next/static/chunks/02t5jr4shh.hy.js +0 -1
  350. package/.next/static/chunks/05gis4d.n_tv~.js +0 -1
  351. package/.next/static/chunks/09~8xbt2mo_.u.js +0 -1
  352. package/.next/static/chunks/0_qfg5s5uzs7..js +0 -7
  353. package/.next/static/chunks/0a0wcpeb8i2my.js +0 -1
  354. package/.next/static/chunks/0elalb9n_.~di.js +0 -1
  355. package/.next/static/chunks/0fqy_0wj_1xa7.js +0 -7
  356. package/.next/static/chunks/0j_q__o8p6g83.js +0 -1
  357. package/.next/static/chunks/0jl6_w50zh4l5.js +0 -1
  358. package/.next/static/chunks/0r4q73.-1bkyl.js +0 -1
  359. package/.next/static/chunks/0r_c8eccg3z2o.js +0 -1
  360. package/.next/static/chunks/10oi7l-tycgl9.js +0 -1
  361. package/.next/static/chunks/11uk1izuv3~0t.js +0 -1
  362. package/.next/static/chunks/13vw-ubg-9pjp.css +0 -2
  363. /package/.next/static/{71LBMB3-nZe_9iMTuD7GZ → IJ5nEFbzhj38AtfV6zg14}/_buildManifest.js +0 -0
  364. /package/.next/static/{71LBMB3-nZe_9iMTuD7GZ → IJ5nEFbzhj38AtfV6zg14}/_clientMiddlewareManifest.js +0 -0
  365. /package/.next/static/{71LBMB3-nZe_9iMTuD7GZ → IJ5nEFbzhj38AtfV6zg14}/_ssgManifest.js +0 -0
@@ -1,10 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { useRouter } from 'next/navigation';
4
5
  import { Session } from './types';
5
6
  import { aliasAvatarColors, aliasInitial } from './AliasAvatar';
6
7
  import { ChatPopover } from './ChatPopover';
7
8
  import { vendorForModel, runtimeIdentity, identityLine } from '../lib/vendorIdentity';
9
+ import { parseHubTime, relativeAgo } from '../lib/time';
8
10
 
9
11
  interface MessageFlow {
10
12
  from_alias: string;
@@ -32,6 +34,9 @@ interface FlowLink {
32
34
  to: string;
33
35
  count: number;
34
36
  content: string;
37
+ /** ISO timestamp of the most recent message on this edge — drives
38
+ * the Round 10 freshness fade so dormant links recede visually. */
39
+ last_at: string;
35
40
  }
36
41
 
37
42
  const cx = 500;
@@ -85,36 +90,118 @@ function truncate(value: string, max: number) {
85
90
  return value.length > max ? `${value.slice(0, max - 1)}...` : value;
86
91
  }
87
92
 
93
+ /** Round 46 / Loop: SWR-freshness chip.
94
+ *
95
+ * TopoGraph's data refreshes every 5 s via SWR but the user has no
96
+ * visible signal that the canvas they're looking at is current. The
97
+ * flow particles and chips (R42 active-links, R43 hub) tell you when
98
+ * the FLEET last did something, not when the DATA last syncing. This
99
+ * chip ticks `live · Ns` against a 1-second interval so the operator
100
+ * can trust freshness without doing internal math.
101
+ *
102
+ * Owns its own setInterval so the parent topology doesn't re-render
103
+ * every second (only this small chip does). lastSyncRef is updated
104
+ * whenever the `sessions` prop reference changes — that's the SWR
105
+ * refresh signal. */
106
+ function FreshnessChip({ sessions }: { sessions: unknown }) {
107
+ const lastSyncRef = useRef<number>(Date.now());
108
+ const [now, setNow] = useState(() => Date.now());
109
+ useEffect(() => {
110
+ // sessions reference changed → SWR just delivered a new payload.
111
+ lastSyncRef.current = Date.now();
112
+ }, [sessions]);
113
+ useEffect(() => {
114
+ const id = setInterval(() => setNow(Date.now()), 1000);
115
+ return () => clearInterval(id);
116
+ }, []);
117
+ const sec = Math.max(0, Math.floor((now - lastSyncRef.current) / 1000));
118
+ // Tint the chip warmer when the data goes stale (>10s since last sync).
119
+ // SWR's default refreshInterval here is 5s, so anything past ~10s is
120
+ // either a poll miss or a network hiccup.
121
+ const stale = sec > 10;
122
+ return (
123
+ <span
124
+ className={stale
125
+ ? "hidden sm:inline px-2.5 py-1 rounded-md bg-amber-500/10 text-amber-300 border border-amber-500/20 font-mono"
126
+ : "hidden sm:inline px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20 font-mono"}
127
+ title={stale ? `Last sync ${sec}s ago — SWR refresh may be lagging` : `Live data · refreshes every 5s · last sync ${sec}s ago`}
128
+ data-freshness-chip
129
+ >
130
+ live · {sec}s
131
+ </span>
132
+ );
133
+ }
134
+
135
+ /** Round 36 / Loop: prefers-reduced-motion hook.
136
+ *
137
+ * Round 29's a11y sweep zeroed CSS animations via media query, but SVG
138
+ * SMIL `<animate>` / `<animateMotion>` elements aren't reachable from CSS
139
+ * — they need JS to opt out. This hook reads the media query and listens
140
+ * for changes so the topology's flow particles, pulses, click ripple
141
+ * and hub breath honour the OS-level preference. */
142
+ function useReducedMotion(): boolean {
143
+ const [reduced, setReduced] = useState(false);
144
+ useEffect(() => {
145
+ if (typeof window === 'undefined' || !window.matchMedia) return;
146
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
147
+ setReduced(mq.matches);
148
+ const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
149
+ mq.addEventListener('change', onChange);
150
+ return () => mq.removeEventListener('change', onChange);
151
+ }, []);
152
+ return reduced;
153
+ }
154
+
155
+ // Round 38 / Loop: parseHubTime + relativeAgo factored to app/lib/time.ts
156
+ // — the Round 35 fix lives there now alongside the Round 37 mirror so
157
+ // any future TZ-safe parse update happens in one place.
158
+
159
+ /** Round 12 / Loop: status trio audit.
160
+ *
161
+ * Each (status × theme) cell returns a {primary, halo, text} trio. The trio
162
+ * invariant — keep it when adding states or tweaking shades:
163
+ *
164
+ * light: primary = <hue>-600 halo = <hue>-100 * text = <hue>-800/900
165
+ * dark: primary = <hue>-400/500 halo = <hue>-900 text = <hue>-100
166
+ *
167
+ * * offline.halo light deviates intentionally to slate-200 (#e2e8f0):
168
+ * slate-100 (#f1f5f9) is too close to the panel bg (#f8fafc) and the
169
+ * halo would vanish. The other three rows keep the 100-shade.
170
+ *
171
+ * Audit caught one cross-family drift before this round: online-other halo
172
+ * light was #dbeafe (blue-100) while its primary (#0284c7 sky-600) and text
173
+ * (#0c4a6e sky-900) were sky-family — now sky-100 (#e0f2fe). Tiny visual
174
+ * difference; large hygiene win — every trio is now mono-hue. */
88
175
  function nodeStatus(session: Session, isOnline: boolean, isLight: boolean) {
89
176
  if (!isOnline) {
90
177
  return {
91
178
  label: 'offline',
92
- primary: isLight ? '#94a3b8' : '#6b7280',
93
- halo: isLight ? '#e2e8f0' : '#111827',
94
- text: isLight ? '#475569' : '#9ca3af',
179
+ primary: isLight ? '#94a3b8' : '#6b7280', // slate-400 / gray-500
180
+ halo: isLight ? '#e2e8f0' : '#111827', // slate-200* / gray-900
181
+ text: isLight ? '#475569' : '#9ca3af', // slate-600 / gray-400
95
182
  };
96
183
  }
97
184
  if (session.status === 'working') {
98
185
  return {
99
186
  label: 'working',
100
- primary: isLight ? '#059669' : '#22c55e',
101
- halo: isLight ? '#d1fae5' : '#14532d',
102
- text: isLight ? '#065f46' : '#dcfce7',
187
+ primary: isLight ? '#059669' : '#22c55e', // emerald-600 / green-500
188
+ halo: isLight ? '#d1fae5' : '#14532d', // emerald-100 / green-900
189
+ text: isLight ? '#065f46' : '#dcfce7', // emerald-800 / green-100
103
190
  };
104
191
  }
105
192
  if (session.status === 'idle') {
106
193
  return {
107
194
  label: 'idle',
108
- primary: isLight ? '#0d9488' : '#2dd4bf',
109
- halo: isLight ? '#ccfbf1' : '#134e4a',
110
- text: isLight ? '#115e59' : '#ccfbf1',
195
+ primary: isLight ? '#0d9488' : '#2dd4bf', // teal-600 / teal-400
196
+ halo: isLight ? '#ccfbf1' : '#134e4a', // teal-100 / teal-900
197
+ text: isLight ? '#115e59' : '#ccfbf1', // teal-800 / teal-100
111
198
  };
112
199
  }
113
200
  return {
114
201
  label: session.status || 'online',
115
- primary: isLight ? '#0284c7' : '#38bdf8',
116
- halo: isLight ? '#dbeafe' : '#0c4a6e',
117
- text: isLight ? '#0c4a6e' : '#e0f2fe',
202
+ primary: isLight ? '#0284c7' : '#38bdf8', // sky-600 / sky-400
203
+ halo: isLight ? '#e0f2fe' : '#0c4a6e', // sky-100 / sky-900 (was blue-100 — drift fixed Round 12)
204
+ text: isLight ? '#0c4a6e' : '#e0f2fe', // sky-900 / sky-100
118
205
  };
119
206
  }
120
207
 
@@ -312,12 +399,20 @@ function buildFlowLinks(messages: MessageFlow[], positions: Record<string, Point
312
399
  const key = `${message.from_alias}->${message.to_alias}`;
313
400
  const current = links.get(key);
314
401
 
402
+ // Keep the most-recent timestamp per pair so the render can fade
403
+ // dormant edges (Round 10 freshness fade).
404
+ const incoming = message.created_at || '';
405
+ const last_at = !current
406
+ ? incoming
407
+ : (incoming > current.last_at ? incoming : current.last_at);
408
+
315
409
  links.set(key, {
316
410
  key,
317
411
  from: message.from_alias,
318
412
  to: message.to_alias,
319
413
  count: (current?.count || 0) + 1,
320
414
  content: current?.content || message.content,
415
+ last_at,
321
416
  });
322
417
  });
323
418
 
@@ -327,9 +422,16 @@ function buildFlowLinks(messages: MessageFlow[], positions: Record<string, Point
327
422
  export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProps) {
328
423
  const theme = useTheme();
329
424
  const isLight = theme === 'light';
425
+ const reducedMotion = useReducedMotion();
330
426
  const pal = isLight ? LIGHT_PALETTE : DARK_PALETTE;
331
427
  const brand = useBrand();
332
428
  const isIntern = brand === 'intern';
429
+ // R133: Next.js client-router for the recent-signal panel "+N more"
430
+ // navigation. TopoGraph hasn't needed routing before — every other
431
+ // affordance composes back into the canvas's own state — but the
432
+ // truncated-flow footer is the one place where the user logically
433
+ // wants to leave: "show me the rest of the flows" → /messages.
434
+ const router = useRouter();
333
435
  const [messages, setMessages] = useState<MessageFlow[]>([]);
334
436
  // Issue #87: ring | grid layout toggle. Ring is the tiered-radial default;
335
437
  // grid arranges nodes in an N×M grid (better for 30+ nodes). Persisted to
@@ -388,6 +490,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
388
490
  activeAliases,
389
491
  groupKeys,
390
492
  groupBoxes,
493
+ gridContentBottom,
391
494
  } = useMemo(() => {
392
495
  const sseCount = (s: { alias: string; network_id?: string }) =>
393
496
  (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
@@ -397,17 +500,25 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
397
500
  // index, so contiguous-in-array becomes contiguous-in-ring, i.e.
398
501
  // each team visually clusters. localeCompare keeps CJK ordering sane.
399
502
  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
503
+ // #112 umbrella: ghost age-out — an offline node not seen recently is
504
+ // almost certainly a deleted node the server `/api/status` still
402
505
  // returns (#74 root cause is server-side; this is the dashboard-side
403
506
  // fallback so stale ghosts stop cluttering the topology). A missing
404
507
  // last_seen_at is kept (conservative — could be a legitimately new node).
405
- const GHOST_MS = 24 * 60 * 60 * 1000;
508
+ // Round 27 / P0: the 24h threshold was too lenient — Vincent's preview.29
509
+ // screenshot showed B站马 nodes deleted ~4 h earlier still visible. A
510
+ // healthy agent heartbeats every few seconds; if it's been silent for
511
+ // an hour it's effectively gone. 1 h gives a fresh disconnect time to
512
+ // come back while removing dead nodes well within an operator session.
513
+ const GHOST_MS = 60 * 60 * 1000;
406
514
  const now = Date.now();
407
515
  const isGhost = (s: Session) => {
408
516
  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;
517
+ // Round 35 / Loop: parseHubTime normalises SQL-style timestamps to UTC
518
+ // before parsing so non-UTC browsers don't see a phantom 8-hour skew
519
+ // and ghost freshly-disconnected nodes.
520
+ const t = parseHubTime(s.last_seen_at);
521
+ return t !== null && now - t > GHOST_MS;
411
522
  };
412
523
  const online = sessions.filter(s => s.status !== 'offline' || sseCount(s)).sort(byAlias);
413
524
  const offline = sessions.filter(s => s.status === 'offline' && !sseCount(s) && !isGhost(s)).sort(byAlias);
@@ -470,11 +581,25 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
470
581
  // padding always fit and stacked group boxes never touch; past the
471
582
  // floor the grid overflows and zoom/pan handles it.
472
583
  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));
584
+ // Round 27 / P0 (Vincent screenshot preview.29): dense plain-text
585
+ // node labels (`pos.y + radius + denseDrop`, with a 3 px containerBg
586
+ // stroke halo for readability) at the BOTTOM row of band N would
587
+ // paint OVER the start of band N+1's group label, creating the
588
+ // "blueleap eleap", "agent-network-dashboard t-network / board"
589
+ // visual chopping. Geometry of the collision:
590
+ // dense label visual bottom = node_N.y + radius + denseDrop + halo
591
+ // group label glyph top = boxY + 4 (text y=boxY+14, ~10px ascent)
592
+ // = node_N+1.y - GROUP_TOP + 4
593
+ // node_N+1.y - node_N.y = cellH for consecutive rows, so no-collide:
594
+ // cellH ≥ radius + denseDrop + halo + GROUP_TOP - 4 + buffer
595
+ // With halo=3 buffer=4: cellH ≥ nodeR + denseDrop + GROUP_TOP + 3.
596
+ const denseDrop = nodeScale < 0.8 ? 12 : 14;
597
+ const cellH = Math.max(
598
+ 2 * nodeR + 22, // node + dense label within band
599
+ GROUP_TOP + 12, // group-label band + box padding
600
+ nodeR + denseDrop + GROUP_TOP + 3, // round-27 dense↔group label clearance
601
+ Math.min(100, (gy1 - gy0) / totalRows),
602
+ );
478
603
 
479
604
  // Pass 2 — place each band's members.
480
605
  for (const band of bands) {
@@ -505,14 +630,36 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
505
630
  const xs = pts.map(p => p.x);
506
631
  const ys = pts.map(p => p.y);
507
632
  const minX = Math.min(...xs), minY = Math.min(...ys);
633
+ // Round 58 / Loop: per-group status mix for the label pip strip.
634
+ // Working = status==='working'. Idle = online but not working.
635
+ // Offline = !isOnline (either status==='offline' AND no SSE, or
636
+ // ghost-purged elsewhere — but ghosts never reach groupBoxes
637
+ // since they're filtered out upstream). Counts feed the label
638
+ // tspans directly so the strip stays inside the label's bbox,
639
+ // preserving the node↔label overlap-test guarantee from R19.
640
+ let w = 0, i = 0, o = 0;
641
+ for (const s of band.members) {
642
+ const isOn = s.status !== 'offline' || !!sseCount(s);
643
+ if (s.status === 'working') w++;
644
+ else if (isOn) i++;
645
+ else o++;
646
+ }
508
647
  return {
509
648
  key: band.members.length ? groupKeys[band.members[0].alias] : '',
649
+ count: band.members.length,
650
+ statuses: { working: w, idle: i, offline: o },
510
651
  x: minX - GROUP_PAD,
511
652
  y: minY - GROUP_TOP,
512
653
  w: Math.max(...xs) - minX + GROUP_PAD * 2,
513
654
  h: Math.max(...ys) - minY + GROUP_TOP + GROUP_PAD,
514
655
  };
515
656
  });
657
+ // Round 28 / Loop: surface the grid's natural content bottom so the
658
+ // mount effect can auto-fit zoom when the layout would overflow the
659
+ // viewBox. = bottom of the last node row + its label drop + a small
660
+ // breathing buffer. Round 27's cellH bump makes this overflow more
661
+ // common (30-node fleets reach ~774 px, viewBox is 680).
662
+ const gridContentBottom = gy0 + totalRows * cellH + 8;
516
663
  return {
517
664
  onlineNodes: online,
518
665
  offlineNodes: offline,
@@ -521,6 +668,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
521
668
  activeAliases: active,
522
669
  groupKeys,
523
670
  groupBoxes,
671
+ gridContentBottom,
524
672
  };
525
673
  }
526
674
 
@@ -602,7 +750,9 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
602
750
  groupKeys,
603
751
  // #111: group boxes are a grid-layout feature only — radially scattered
604
752
  // 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 }[],
753
+ groupBoxes: [] as { key: string; count: number; statuses: { working: number; idle: number; offline: number }; x: number; y: number; w: number; h: number }[],
754
+ // ring fits within VIEWBOX_H by construction (offlineRadius=325 + centre at y=330)
755
+ gridContentBottom: 0,
606
756
  };
607
757
  }, [messages, sessions, sseSessions, layout, nodeScale]);
608
758
 
@@ -626,10 +776,217 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
626
776
  // label cards. 16 ≈ where the triple-tier rings start to crowd.
627
777
  const denseLayout = onlineNodes.length + offlineNodes.length > 16;
628
778
  const [hoveredAlias, setHoveredAlias] = useState<string | null>(null);
779
+ // Round 86 / Loop: pointer-over-label preview. R63 wired the group
780
+ // label to click-pin but hover gave no preview — the same hover/click
781
+ // gap R83 closed for pressure-bar segments. This state lets the
782
+ // label trigger the same dim mechanism a node hover already drives,
783
+ // independently of whether the cursor happens to be over a member
784
+ // node. ORs into hoveredGroup below so the existing derivation logic
785
+ // (which composes with hoveredAlias, activeGroup, R85 marching ants)
786
+ // keeps working unchanged.
787
+ const [hoveredGroupLabel, setHoveredGroupLabel] = useState<string | null>(null);
629
788
  // Round 8 / Loop: which group is currently focused. Nodes outside this
630
789
  // group + other group boxes fade so the eye locks onto the team you're
631
790
  // pointing at. Singletons use their own alias as the group key.
632
- const hoveredGroup = hoveredAlias ? (groupKeys[hoveredAlias] ?? hoveredAlias) : null;
791
+ const hoveredGroup = hoveredGroupLabel
792
+ ?? (hoveredAlias ? (groupKeys[hoveredAlias] ?? hoveredAlias) : null);
793
+ // Round 63 / Loop: sticky group focus. R8 dims non-group nodes while
794
+ // a member is hovered, but releasing the hover lets the focus fade.
795
+ // Clicking the group label pins the group key here; activeGroup =
796
+ // hoveredGroup ?? pinnedGroup so hover transiently overrides the
797
+ // pin (handy for spot-comparing teams). Same compose pattern as
798
+ // R60/R61 pinnedStatus.
799
+ const [pinnedGroup, setPinnedGroup] = useState<string | null>(() => {
800
+ // R66: same per-tab persistence treatment as pinnedStatus above.
801
+ // The group key is opaque text (could be any prefix), so the init
802
+ // reader can't validate it against a known enum — it just reads
803
+ // whatever's stored. A useEffect below clears it if the value no
804
+ // longer matches any current group (sessions changed since save).
805
+ if (typeof window === 'undefined') return null;
806
+ try { return sessionStorage.getItem('anet-topo-pinned-group'); } catch { return null; }
807
+ });
808
+ const activeGroup = hoveredGroup ?? pinnedGroup;
809
+ // R66: sync pinnedGroup into sessionStorage when it changes; clear
810
+ // it if it no longer matches any current group (the session set
811
+ // can change between loads). The pinnedStatus sync sits next to
812
+ // its declaration further down.
813
+ useEffect(() => {
814
+ try {
815
+ if (pinnedGroup) sessionStorage.setItem('anet-topo-pinned-group', pinnedGroup);
816
+ else sessionStorage.removeItem('anet-topo-pinned-group');
817
+ } catch {}
818
+ }, [pinnedGroup]);
819
+ useEffect(() => {
820
+ if (!pinnedGroup) return;
821
+ const known = new Set(Object.values(groupKeys));
822
+ if (!known.has(pinnedGroup)) setPinnedGroup(null);
823
+ }, [pinnedGroup, groupKeys]);
824
+ // Round 49 / Loop: reverse-direction of R40's edge-on-node-hover linkage.
825
+ // R48 widened the flow hitbox to 16 px, so edges are precise enough to
826
+ // serve as a state trigger. When the user hovers a flow edge, light up its
827
+ // two endpoint nodes and dim the rest — "who is this edge between" becomes
828
+ // visible without reading the tooltip. The set is the link's two aliases
829
+ // (null when no edge hovered); node opacity composes this after inFocus.
830
+ const [hoveredEdgeKey, setHoveredEdgeKey] = useState<string | null>(null);
831
+ // Round 116 / Loop: sticky variant of hoveredEdgeKey. R56 made the
832
+ // recent-signal rows brighten the matching edge on hover, but the
833
+ // filter released on mouseleave — no way to lock a flow for a
834
+ // closer look. Click-to-pin closes that gap, matching the
835
+ // established hover→pin idiom (R60 status, R61 legend, R63 group,
836
+ // R88 vendor). activeEdgeKey = hoveredEdgeKey ?? pinnedEdgeKey
837
+ // below so hover still wins for spot-comparison while a pin is set.
838
+ const [pinnedEdgeKey, setPinnedEdgeKey] = useState<string | null>(null);
839
+ const activeEdgeKey = hoveredEdgeKey ?? pinnedEdgeKey;
840
+ // Round 77 / Loop: hovering the "N active links" header chip globally
841
+ // brightens every flow edge (1.5× opacity). Transient affordance — the
842
+ // chip already says HOW MANY active flows exist; this answers WHERE
843
+ // they are in one glance, without the user scanning the canvas for
844
+ // moving particles. Reset on mouse leave.
845
+ const [hoveredActiveLinks, setHoveredActiveLinks] = useState(false);
846
+ // Round 115 / Loop: hub-center hover state. R52 made the hub
847
+ // clickable (fit view) + cursor:pointer, but there was no
848
+ // hover-time visual feedback — the click target felt guessed at
849
+ // rather than confirmed. This state drives a subtle hint ring on
850
+ // hover so users see the affordance before committing the click.
851
+ const [hoveredHub, setHoveredHub] = useState(false);
852
+ // R133: hover state for the recent-signal panel's "+N more flows"
853
+ // navigation footer. Drives the on-hover opacity boost + underline
854
+ // that signals interactivity, mirroring the hoveredHub idiom above.
855
+ const [hoveredRecentMore, setHoveredRecentMore] = useState(false);
856
+ // R135: panel-wide hover-elevation. The recent-signal + legend
857
+ // panels both already host clickable rows (R56/R116 recent rows,
858
+ // R55/R61 legend rows) and a clickable footer (R133), so the
859
+ // chrome itself is interactive territory. Drop-shadow boost on
860
+ // mouseenter says "this whole panel is alive" — matches the R18
861
+ // KPI-card-hover idiom from the Overview page. Single state for
862
+ // both panels since they don't overlap; null when neither hovered.
863
+ const [hoveredPanel, setHoveredPanel] = useState<'recent' | 'legend' | null>(null);
864
+ // Round 80 / Loop: vendor-letter hover in the distribution chip. The
865
+ // chip already names vendor mix (`C:5 G:3 ?:1`); hover a letter and
866
+ // every node from OTHER vendors dims. Surfaces the breakdown spatially
867
+ // without inventing a new pin slot. Stores the vendor `initial`
868
+ // (single char or "?") that matches the tally key.
869
+ const [hoveredVendor, setHoveredVendor] = useState<string | null>(null);
870
+ // Round 88 / Loop: vendor filter pin. R80 added hover-to-dim on the
871
+ // vendor letters but the filter released as soon as the cursor left
872
+ // — vendor was the only filter dimension without a sticky variant
873
+ // (R60 status, R63 group, R69 Cmd+K all support pin). This state
874
+ // closes the gap. Same pattern as pinnedStatus / pinnedGroup:
875
+ // per-tab sessionStorage persistence, Esc clears, activeVendor =
876
+ // hoveredVendor ?? pinnedVendor so hover still wins for spot-
877
+ // comparison while a pin is active.
878
+ const [pinnedVendor, setPinnedVendor] = useState<string | null>(() => {
879
+ if (typeof window === 'undefined') return null;
880
+ try { return sessionStorage.getItem('anet-topo-pinned-vendor'); } catch { return null; }
881
+ });
882
+ const activeVendor = hoveredVendor ?? pinnedVendor;
883
+ useEffect(() => {
884
+ try {
885
+ if (pinnedVendor) sessionStorage.setItem('anet-topo-pinned-vendor', pinnedVendor);
886
+ else sessionStorage.removeItem('anet-topo-pinned-vendor');
887
+ } catch {}
888
+ }, [pinnedVendor]);
889
+ // R89: stale-purge. If the fleet's vendor distribution changes such
890
+ // that a previously-pinned vendor is gone (last node of that
891
+ // vendor disconnected), clear the pin so the chip row doesn't
892
+ // show "filter: A · 0" forever. Matches the same defensive purge
893
+ // pattern pinnedGroup uses higher up — sessionStorage survives
894
+ // reloads, but a stored value that no longer matches reality
895
+ // would paint with an impossible filter.
896
+ useEffect(() => {
897
+ if (pinnedVendor && !vendorDist.some(v => v.initial === pinnedVendor)) {
898
+ setPinnedVendor(null);
899
+ }
900
+ }, [pinnedVendor, vendorDist]);
901
+ // Round 55 / Loop: hovering a legend status row dims nodes whose status
902
+ // doesn't match. The legend was passive — "what does this colour mean".
903
+ // Now it answers "show me all of these" the same way R8 group-focus
904
+ // answers "show me this team". Three values match the legend rows.
905
+ const [hoveredStatus, setHoveredStatus] = useState<'working' | 'idle' | 'offline' | null>(null);
906
+ // Round 60 / Loop: sticky variant of `hoveredStatus`. R55 only filters
907
+ // while the user is actively hovering the legend; for sweeping a fleet
908
+ // you want to LOCK the filter. Each segment of the R31 pressure bar
909
+ // toggles a pin — click again on the same segment to release. The node
910
+ // opacity formula reads `activeStatus = hoveredStatus ?? pinnedStatus`
911
+ // so hover transiently overrides a pin (handy for spot-comparison)
912
+ // without nuking it.
913
+ // Round 66 / Loop: pin survives a page reload via sessionStorage —
914
+ // per-tab not per-browser (a new tab starts clean, intentionally).
915
+ // The init reader validates against the known status set so a stale
916
+ // / corrupt value can't paint the canvas with an impossible filter.
917
+ const [pinnedStatus, setPinnedStatus] = useState<'working' | 'idle' | 'offline' | null>(() => {
918
+ if (typeof window === 'undefined') return null;
919
+ try {
920
+ const v = sessionStorage.getItem('anet-topo-pinned-status');
921
+ return (v === 'working' || v === 'idle' || v === 'offline') ? v : null;
922
+ } catch { return null; }
923
+ });
924
+ const activeStatus = hoveredStatus ?? pinnedStatus;
925
+ // R66: sync pinnedStatus into sessionStorage when it changes. Paired
926
+ // with the matching effect for pinnedGroup higher up.
927
+ useEffect(() => {
928
+ try {
929
+ if (pinnedStatus) sessionStorage.setItem('anet-topo-pinned-status', pinnedStatus);
930
+ else sessionStorage.removeItem('anet-topo-pinned-status');
931
+ } catch {}
932
+ }, [pinnedStatus]);
933
+ // R69: listen for Cmd+K palette pin actions. The palette can't reach
934
+ // this component's state directly so it dispatches a CustomEvent;
935
+ // we react by setting pinnedStatus / pinnedGroup. The palette also
936
+ // writes to sessionStorage in lockstep, so the R66 init readers
937
+ // would pick it up on reload — the event handler is what keeps the
938
+ // currently-mounted canvas in sync without one.
939
+ useEffect(() => {
940
+ const onPin = (e: Event) => {
941
+ const detail = (e as CustomEvent).detail || {};
942
+ if (detail.kind === 'clear') {
943
+ // R90: extend the universal clear to vendor. R69 only cleared
944
+ // status + group because pinnedVendor didn't exist yet; R88
945
+ // shipped the third pin and R90 closes the palette gap so
946
+ // "Clear topology filters" really means all of them.
947
+ // R117: extend again to pinnedEdgeKey — R116 added the 4th
948
+ // pin dim and this command must clear it too or the edge stays
949
+ // bright in the canvas while every other pill clears.
950
+ setPinnedStatus(null);
951
+ setPinnedGroup(null);
952
+ setPinnedVendor(null);
953
+ setPinnedEdgeKey(null);
954
+ } else if (detail.kind === 'clear-vendor') {
955
+ // R90: granular vendor-only clear so power users can release
956
+ // just the vendor without disturbing status/group pins.
957
+ setPinnedVendor(null);
958
+ } else if (detail.kind === 'clear-edge') {
959
+ // R117: granular edge-only clear, mirror of R90 clear-vendor.
960
+ setPinnedEdgeKey(null);
961
+ } else if (detail.kind === 'status') {
962
+ const v = detail.value;
963
+ if (v === 'working' || v === 'idle' || v === 'offline') setPinnedStatus(v);
964
+ } else if (detail.kind === 'group' && typeof detail.value === 'string') {
965
+ setPinnedGroup(detail.value);
966
+ } else if (detail.kind === 'vendor' && typeof detail.value === 'string') {
967
+ // R108: palette pin-vendor support. R69 added pin-status, R88
968
+ // added clickable vendor letters in the chip row, R90 added
969
+ // granular clear-vendor — but the palette never gained a
970
+ // PIN-vendor command. This branch listens for it; the matching
971
+ // commands live in CommandPalette R108. The stale-purge
972
+ // useEffect higher up already drops a pin whose vendor isn't
973
+ // in the current distribution, so commands for an absent
974
+ // vendor are harmless.
975
+ setPinnedVendor(detail.value);
976
+ }
977
+ };
978
+ window.addEventListener('anet:topo-pin', onPin);
979
+ return () => window.removeEventListener('anet:topo-pin', onPin);
980
+ }, []);
981
+ // R74 listener for layout + view palette commands lives below
982
+ // fitView's declaration — see further down in the file.
983
+ const hoveredEdgeEndpoints = useMemo<Set<string> | null>(() => {
984
+ // R116: compose hover ?? pin so pinning a row via click keeps the
985
+ // endpoint ring + edge ladder lit after mouseleave.
986
+ if (!activeEdgeKey) return null;
987
+ const link = flowLinks.find(l => l.key === activeEdgeKey);
988
+ return link ? new Set([link.from, link.to]) : null;
989
+ }, [activeEdgeKey, flowLinks]);
633
990
 
634
991
  // --- Round 103 (issue #81): fullscreen + zoom + pan interaction layer ---
635
992
  // DIY native (no d3 / svg-pan-zoom): wrap the topology content in a single
@@ -649,6 +1006,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
649
1006
  // Issue #100: singleton chat popover. One alias at a time — clicking
650
1007
  // another node swaps the target and the conversation switches in place.
651
1008
  const [chatAlias, setChatAlias] = useState<string | null>(null);
1009
+ // Round 14 / Loop: one-shot click ripple — fades outward from the clicked
1010
+ // node so the click registers physically before the popover snaps in.
1011
+ // Pairs with the Round 11 chat-focus ring: ripple expands, ring locks on.
1012
+ // Keyed by ts so re-clicking the same node remounts the <circle> and the
1013
+ // SMIL <animate> replays. Cleared 600ms after click (longer than the
1014
+ // 500ms animation so a final frame at opacity 0 is still in the tree).
1015
+ const [clickRipple, setClickRipple] = useState<{
1016
+ ts: number; x: number; y: number; r0: number; color: string;
1017
+ } | null>(null);
652
1018
  // #84: when a node is renamed while its chat popover is open, follow the
653
1019
  // rename so the conversation keeps targeting a live alias.
654
1020
  useEffect(() => {
@@ -679,6 +1045,42 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
679
1045
  } catch {}
680
1046
  }, []);
681
1047
 
1048
+ // Round 28 / Loop: first-paint auto-fit. Round 27's cellH bump means
1049
+ // dense grids (≥6-7 rows) overflow the 680-px viewBox at the natural
1050
+ // 100% zoom — a new user sees the topology with its bottom rows
1051
+ // clipped and no hint that there's more below. Auto-fit on first
1052
+ // paint (and only if the user has no persisted view) sets the
1053
+ // initial zoom so all content fits. Subsequent zoom changes persist
1054
+ // and override the auto-fit on reload; explicit reset (0 key) still
1055
+ // goes back to 100% so the gesture's "reset to natural size" semantic
1056
+ // is preserved.
1057
+ //
1058
+ // Capture pre-mount persistence in a useState initializer — the
1059
+ // existing `persist` effect (declared below) runs on first render
1060
+ // with the default {1,0,0} view and writes it to localStorage before
1061
+ // the auto-fit effect (deps on async-arriving sessions) gets a turn.
1062
+ // Reading localStorage from the effect would see that write and
1063
+ // skip the fit. The useState snapshot fires once, before any effects.
1064
+ const [hadPersistedViewOnMount] = useState<boolean>(
1065
+ () => typeof window !== 'undefined' && !!localStorage.getItem('anet-topo-view'),
1066
+ );
1067
+ const autoFitDoneRef = useRef(false);
1068
+ useEffect(() => {
1069
+ if (autoFitDoneRef.current) return;
1070
+ if (hadPersistedViewOnMount) {
1071
+ autoFitDoneRef.current = true;
1072
+ return;
1073
+ }
1074
+ if (layout !== 'grid' || sessions.length === 0 || !gridContentBottom) return;
1075
+ if (gridContentBottom <= VIEWBOX_H) {
1076
+ autoFitDoneRef.current = true; // no overflow → no fit needed
1077
+ return;
1078
+ }
1079
+ const fitZoom = Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
1080
+ setView({ zoom: fitZoom, x: 0, y: 0 });
1081
+ autoFitDoneRef.current = true;
1082
+ }, [layout, sessions.length, gridContentBottom, hadPersistedViewOnMount]);
1083
+
682
1084
  // persist view
683
1085
  useEffect(() => {
684
1086
  try { localStorage.setItem('anet-topo-view', JSON.stringify(view)); } catch {}
@@ -698,6 +1100,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
698
1100
  const svg = svgRef.current;
699
1101
  if (!svg) return;
700
1102
  const onWheel = (e: WheelEvent) => {
1103
+ // Round 23 / Loop: only zoom on Ctrl/Meta+wheel when inline; plain
1104
+ // wheel over the canvas keeps scrolling the page (the topo sits at
1105
+ // the top of /; trapping page scroll mid-page is the classic
1106
+ // "scroll-jail" anti-pattern). In fullscreen the canvas owns the
1107
+ // viewport so any wheel zooms — no page scroll to preserve. Pinch-
1108
+ // zoom on a trackpad surfaces as ctrlKey=true natively, which is
1109
+ // also what we want (zoom the canvas, not the browser).
1110
+ if (!isFullscreen && !e.ctrlKey && !e.metaKey) return;
701
1111
  e.preventDefault();
702
1112
  const rect = svg.getBoundingClientRect();
703
1113
  const mx = ((e.clientX - rect.left) / rect.width) * VIEWBOX_W;
@@ -718,8 +1128,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
718
1128
  };
719
1129
  svg.addEventListener('wheel', onWheel, { passive: false });
720
1130
  return () => svg.removeEventListener('wheel', onWheel);
721
- }, []);
1131
+ // Re-attach when the fullscreen flag toggles so the handler's
1132
+ // closure picks up the new value (capture-by-closure means we'd
1133
+ // otherwise read the stale boolean forever).
1134
+ }, [isFullscreen]);
722
1135
 
1136
+ // Round 21 / Loop: pan-aware cursor. Static `grab` gave no tactile cue
1137
+ // that the canvas was actually being dragged — "grabbing" while
1138
+ // dragRef.current.active = true tells the hand it's moving the
1139
+ // viewport. Mirroring dragRef.active into state is the only way to
1140
+ // re-render the SVG style; the ref itself doesn't trigger renders.
1141
+ const [isPanning, setIsPanning] = useState(false);
723
1142
  const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
724
1143
  if (e.button !== 0) return;
725
1144
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
@@ -730,6 +1149,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
730
1149
  baseX: viewRef.current.x,
731
1150
  baseY: viewRef.current.y,
732
1151
  };
1152
+ setIsPanning(true);
733
1153
  };
734
1154
  const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
735
1155
  const d = dragRef.current;
@@ -744,6 +1164,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
744
1164
  const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
745
1165
  if (!dragRef.current.active) return;
746
1166
  dragRef.current.active = false;
1167
+ setIsPanning(false);
747
1168
  try {
748
1169
  (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
749
1170
  } catch {}
@@ -761,6 +1182,91 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
761
1182
  };
762
1183
  const resetView = () => setView({ zoom: 1, x: 0, y: 0 });
763
1184
 
1185
+ // Round 29 / Loop: `f` = fit-to-content. Shared by the Round 28
1186
+ // first-paint auto-fit effect and the keyboard handler so the math is
1187
+ // in one place. When content already fits at natural zoom, this is
1188
+ // effectively a "recenter" — `f` always lands on a known good view.
1189
+ const fitView = useCallback(() => {
1190
+ const zoom = !gridContentBottom || gridContentBottom <= VIEWBOX_H
1191
+ ? 1
1192
+ : Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
1193
+ setView({ zoom, x: 0, y: 0 });
1194
+ }, [gridContentBottom]);
1195
+
1196
+ // R74: listen for layout + view palette commands. Sister to R69's
1197
+ // pin listener — palette dispatches a CustomEvent, the reducer here
1198
+ // calls toggleLayout / fitView. Sits below fitView's declaration so
1199
+ // the deps list resolves cleanly.
1200
+ useEffect(() => {
1201
+ const onLayout = (e: Event) => {
1202
+ const detail = (e as CustomEvent).detail || {};
1203
+ if (detail.kind === 'toggle') toggleLayout();
1204
+ };
1205
+ const onView = (e: Event) => {
1206
+ const detail = (e as CustomEvent).detail || {};
1207
+ if (detail.kind === 'fit') fitView();
1208
+ };
1209
+ window.addEventListener('anet:topo-layout', onLayout);
1210
+ window.addEventListener('anet:topo-view', onView);
1211
+ return () => {
1212
+ window.removeEventListener('anet:topo-layout', onLayout);
1213
+ window.removeEventListener('anet:topo-view', onView);
1214
+ };
1215
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1216
+ }, [fitView]);
1217
+
1218
+ // Round 22 / Loop: keyboard zoom — +/= zoom in, - zoom out, 0 reset.
1219
+ // Round 29 / Loop: +f to fit content.
1220
+ // Listen on window so the user doesn't need to focus the SVG first,
1221
+ // but only act when no text input has focus (otherwise typing "-" in
1222
+ // a chat would zoom the topology — surprising and bad). Modifier keys
1223
+ // pass through so Cmd/Ctrl combos still hit their owners. The button
1224
+ // titles below document these shortcuts so users discover them.
1225
+ useEffect(() => {
1226
+ const onKey = (e: KeyboardEvent) => {
1227
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
1228
+ const ae = document.activeElement as HTMLElement | null;
1229
+ if (ae) {
1230
+ const tag = ae.tagName;
1231
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || ae.isContentEditable) return;
1232
+ }
1233
+ if (e.key === '+' || e.key === '=') { zoomBy(1.2); e.preventDefault(); }
1234
+ else if (e.key === '-' || e.key === '_') { zoomBy(1 / 1.2); e.preventDefault(); }
1235
+ else if (e.key === '0') { resetView(); e.preventDefault(); }
1236
+ else if (e.key === 'f' || e.key === 'F') { fitView(); e.preventDefault(); }
1237
+ // Round 32 / Loop: `l` toggles ring|grid. The vim-style `g l` route
1238
+ // (Audit Log) requires a preceding `g` within 1500ms; a bare `l`
1239
+ // outside that window is free for topology use.
1240
+ else if (e.key === 'l' || e.key === 'L') { toggleLayout(); e.preventDefault(); }
1241
+ // Round 62 / Loop: Esc clears the R60/R61 pinned status filter so
1242
+ // users have a universal-cancel keyboard out. Esc on an open chat
1243
+ // is owned by ChatPopover (which only mounts when chatAlias is
1244
+ // set), so this handler is effectively scoped to "no chat open".
1245
+ // We additionally guard on chatAlias to be explicit — if the chat
1246
+ // closed mid-cycle, the pin can still be cleared on the next Esc.
1247
+ // R63: extends to pinnedGroup too. Clears WHATEVER pin is active
1248
+ // (one Esc collapses all topology pins) so the keyboard escape
1249
+ // route stays a single key, not "Esc maybe Esc again".
1250
+ else if (e.key === 'Escape' && !chatAlias && (pinnedStatus || pinnedGroup || pinnedVendor || pinnedEdgeKey)) {
1251
+ // R88/R116: extend the universal-cancel to vendor + edge too.
1252
+ // One Esc collapses every topology pin (matches R62/R63's
1253
+ // "single key, not Esc-maybe-Esc-again" promise).
1254
+ if (pinnedStatus) setPinnedStatus(null);
1255
+ if (pinnedGroup) setPinnedGroup(null);
1256
+ if (pinnedVendor) setPinnedVendor(null);
1257
+ if (pinnedEdgeKey) setPinnedEdgeKey(null);
1258
+ e.preventDefault();
1259
+ }
1260
+ };
1261
+ window.addEventListener('keydown', onKey);
1262
+ return () => window.removeEventListener('keydown', onKey);
1263
+ // zoomBy / resetView are stable wrt setView callback; fitView changes
1264
+ // with gridContentBottom so deps list catches it. R62 adds chatAlias
1265
+ // and pinnedStatus so the Escape branch reads fresh state (re-binding
1266
+ // the listener on these state changes is sub-ms — cheaper than refs).
1267
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1268
+ }, [fitView, chatAlias, pinnedStatus, pinnedGroup, pinnedVendor, pinnedEdgeKey]);
1269
+
764
1270
  const toggleFullscreen = () => {
765
1271
  const el = containerRef.current;
766
1272
  if (!el) return;
@@ -788,6 +1294,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
788
1294
  <button
789
1295
  onClick={() => { if (layout !== 'ring') toggleLayout(); }}
790
1296
  aria-pressed={layout === 'ring'}
1297
+ title="Ring layout (l to toggle)"
791
1298
  className={`px-2.5 py-1 transition-colors ${layout === 'ring' ? 'bg-cyan-500/15 text-cyan-300' : 'text-gray-500 hover:text-gray-400'}`}
792
1299
  >
793
1300
  Ring
@@ -795,33 +1302,688 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
795
1302
  <button
796
1303
  onClick={() => { if (layout !== 'grid') toggleLayout(); }}
797
1304
  aria-pressed={layout === 'grid'}
1305
+ title="Grid layout (l to toggle)"
798
1306
  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'}`}
799
1307
  >
800
1308
  Grid
801
1309
  </button>
802
1310
  </div>
803
- <span className="px-2.5 py-1 rounded-md bg-green-500/10 text-green-300 border border-green-500/20">
804
- {workingCount} working
805
- </span>
806
- <span className="px-2.5 py-1 rounded-md bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
807
- {onlineNodes.length} online
808
- </span>
809
- {vendorDist.length > 1 && (
1311
+ {/* R79: working + online count chips become hover affordances —
1312
+ extends R77's chip-hover pattern to status counts. Hover the
1313
+ working chip → setHoveredStatus('working') so the R55 dim
1314
+ mechanic kicks in (same as hovering the SVG legend "working"
1315
+ row, just a different surface for the same gesture).
1316
+ The online chip uses the same setHoveredStatus('idle') path
1317
+ when there's idle but no working — falling back to working
1318
+ if any working node exists; "online" without a single bucket
1319
+ isn't part of the R55 type set, so this chip routes to the
1320
+ dominant online sub-tier instead of inventing a new state.
1321
+ Cursor only flips when there's anything to highlight. */}
1322
+ {/* R82: pin-mirror. R60 pressure-bar segments visualise the
1323
+ pinned status via an inset boxShadow; R61 legend rows via a
1324
+ concentric r=8 ring; the chip-row chips next to them did
1325
+ not — so a user who pinned via Cmd+K (R69) or the legend
1326
+ had no chip-row signal that "working" was the current
1327
+ filter. Mirror the pressure-bar treatment here so all
1328
+ status surfaces sing in unison. The online chip mirrors
1329
+ for idle pins (working ⊆ online; the routing in
1330
+ onMouseEnter already treats online as the idle fallback). */}
1331
+ {(() => {
1332
+ // R113 / Loop: extend the R97-R102/R101 alias-list tooltip
1333
+ // sweep to the remaining chip-row affordances. R79 made
1334
+ // these chips hoverable; their generic "Hover to highlight"
1335
+ // titles never said WHICH nodes match. Compute the alias
1336
+ // lists once for both chips to share.
1337
+ const workingAliases = onlineNodes.filter(s => s.status === 'working').map(s => s.alias);
1338
+ const onlineAliases = onlineNodes.map(s => s.alias);
1339
+ const truncate = (list: string[]) => {
1340
+ const head = list.slice(0, 8).join(', ');
1341
+ const tail = list.length > 8 ? ` + ${list.length - 8} more` : '';
1342
+ return head + tail;
1343
+ };
1344
+ const workingTitle = workingCount === 0
1345
+ ? undefined
1346
+ : pinnedStatus === 'working'
1347
+ ? `${truncate(workingAliases)} — pinned, Esc to clear`
1348
+ : `${truncate(workingAliases)} — hover highlights, click to pin`;
1349
+ // R140: online chip title gains a "click to open /nodes" tail
1350
+ // when interactive. R79 made the cursor pointer-shaped but
1351
+ // wired nothing; R136 + R139 closed the same lie on two
1352
+ // sibling chips by wiring real actions. The online chip
1353
+ // can't pin a single status (online = working + idle,
1354
+ // not a single pinnedStatus value), so a pin idiom would
1355
+ // be semantically wrong. /nodes is the natural full-list
1356
+ // destination — same "click chip for the full list" idiom
1357
+ // the active-links chip uses (R136 → /messages).
1358
+ const onlineTitle = onlineNodes.length === 0
1359
+ ? undefined
1360
+ : pinnedStatus === 'idle'
1361
+ ? `${truncate(onlineAliases)} — pinned, Esc to clear`
1362
+ : `${truncate(onlineAliases)} — hover highlights · click to open /nodes`;
1363
+ return (
1364
+ <>
1365
+ <span
1366
+ className="px-2.5 py-1 rounded-md bg-green-500/10 text-green-300 border border-green-500/20"
1367
+ data-working-chip
1368
+ data-working-chip-aliases={workingAliases.join(',')}
1369
+ data-pin-mirror={pinnedStatus === 'working' ? 'true' : 'false'}
1370
+ data-working-chip-clickable={workingCount > 0 ? 'true' : 'false'}
1371
+ title={workingTitle}
1372
+ role={workingCount > 0 ? 'button' : undefined}
1373
+ tabIndex={workingCount > 0 ? 0 : undefined}
1374
+ aria-pressed={workingCount > 0 ? (pinnedStatus === 'working') : undefined}
1375
+ style={{
1376
+ cursor: workingCount > 0 ? 'pointer' : undefined,
1377
+ boxShadow: pinnedStatus === 'working' ? 'inset 0 0 0 1px #4ade80, inset 0 0 0 2px rgba(255,255,255,0.45)' : undefined,
1378
+ }}
1379
+ onMouseEnter={() => { if (workingCount > 0) setHoveredStatus('working'); }}
1380
+ onMouseLeave={() => setHoveredStatus(prev => prev === 'working' ? null : prev)}
1381
+ // R139: the title hover-text has been promising "click to
1382
+ // pin" since R79 but no onClick was ever wired. The cursor:
1383
+ // pointer at line 1363 set up the same lie R136 fixed on
1384
+ // the active-links chip. Wire it now: click toggles the
1385
+ // status pin to 'working', composing with R60 (pressure-
1386
+ // bar segments) and R61 (legend rows) — three different
1387
+ // surfaces that all toggle the same pinnedStatus. boxShadow
1388
+ // pin-mirror at line 1364 already reflects the state; aria-
1389
+ // pressed now exposes it for screen readers too.
1390
+ onClick={() => {
1391
+ if (workingCount > 0) setPinnedStatus(prev => prev === 'working' ? null : 'working');
1392
+ }}
1393
+ onKeyDown={(e) => {
1394
+ if (workingCount === 0) return;
1395
+ if (e.key === 'Enter' || e.key === ' ') {
1396
+ e.preventDefault();
1397
+ setPinnedStatus(prev => prev === 'working' ? null : 'working');
1398
+ }
1399
+ }}
1400
+ >
1401
+ {workingCount} working
1402
+ </span>
1403
+ <span
1404
+ className="px-2.5 py-1 rounded-md bg-cyan-500/10 text-cyan-300 border border-cyan-500/20"
1405
+ data-online-chip
1406
+ data-online-chip-aliases={onlineAliases.join(',')}
1407
+ data-pin-mirror={pinnedStatus === 'idle' ? 'true' : 'false'}
1408
+ data-online-chip-clickable={onlineNodes.length > 0 ? 'true' : 'false'}
1409
+ title={onlineTitle}
1410
+ role={onlineNodes.length > 0 ? 'link' : undefined}
1411
+ tabIndex={onlineNodes.length > 0 ? 0 : undefined}
1412
+ style={{
1413
+ cursor: onlineNodes.length > 0 ? 'pointer' : undefined,
1414
+ boxShadow: pinnedStatus === 'idle' ? 'inset 0 0 0 1px #67e8f9, inset 0 0 0 2px rgba(255,255,255,0.45)' : undefined,
1415
+ }}
1416
+ onMouseEnter={() => {
1417
+ // If a working filter would isolate nothing, route to idle.
1418
+ const idleCount = onlineNodes.length - workingCount;
1419
+ if (workingCount > 0) setHoveredStatus('working');
1420
+ else if (idleCount > 0) setHoveredStatus('idle');
1421
+ }}
1422
+ onMouseLeave={() => setHoveredStatus(prev => prev === 'working' || prev === 'idle' ? null : prev)}
1423
+ // R140: click → /nodes. Mirrors R136 active-links→/messages
1424
+ // idiom. The chip's hover semantics (R79 highlight all
1425
+ // online) keep their meaning — hover for canvas preview,
1426
+ // click for the full list. Pinning idle here would
1427
+ // semantically misrepresent the chip (which means
1428
+ // working+idle), so we navigate instead of pin.
1429
+ onClick={() => {
1430
+ if (onlineNodes.length > 0) router.push('/nodes');
1431
+ }}
1432
+ onKeyDown={(e) => {
1433
+ if (onlineNodes.length === 0) return;
1434
+ if (e.key === 'Enter' || e.key === ' ') {
1435
+ e.preventDefault();
1436
+ router.push('/nodes');
1437
+ }
1438
+ }}
1439
+ >
1440
+ {onlineNodes.length} online
1441
+ </span>
1442
+ </>
1443
+ );
1444
+ })()}
1445
+ {/* Round 31 / Loop: fleet-health pressure bar. The "X working /
1446
+ Y online" chips already carry the raw counts; the bar lets
1447
+ the eye get the working/idle/offline RATIO in one glance
1448
+ without mental math. Stacked 3-segment chip, ~64 px wide. */}
1449
+ {(() => {
1450
+ const w = workingCount;
1451
+ const i = onlineNodes.length - workingCount; // idle = online - working
1452
+ const o = offlineNodes.length;
1453
+ const total = w + i + o;
1454
+ if (total === 0) return null;
1455
+ // Round 60 / Loop: each segment toggles a sticky filter via
1456
+ // `pinnedStatus`. Click the working segment → all non-working
1457
+ // nodes dim; click again → release. Segments share width with
1458
+ // their proportion of `total`, so a 1-node working share is
1459
+ // ~3 px wide on a 64-px bar. We pad the click target with a
1460
+ // negative-margin overlay wrapper to give thin slices a
1461
+ // 14-px minimum hit area without disturbing the rendered
1462
+ // chip width. cursor:pointer + a one-line title hint at the
1463
+ // affordance.
1464
+ const seg = (n: number, color: string, key: 'working' | 'idle' | 'offline', label: string) => {
1465
+ if (n === 0) return null;
1466
+ const isPinned = pinnedStatus === key;
1467
+ // R102: list the aliases that match this segment's bucket
1468
+ // so the title answers WHICH n, not just HOW MANY. Closes
1469
+ // the last "info-density gap" in the chip-row surfaces
1470
+ // (R97 pills / R99 group labels / R101 vendor letters all
1471
+ // already enumerate). Truncates at 8 with "+N more".
1472
+ const matchAliases = key === 'working'
1473
+ ? onlineNodes.filter(s => s.status === 'working').map(s => s.alias)
1474
+ : key === 'idle'
1475
+ ? onlineNodes.filter(s => s.status !== 'working').map(s => s.alias)
1476
+ : offlineNodes.map(s => s.alias);
1477
+ const previewList = matchAliases.slice(0, 8).join(', ');
1478
+ const suffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
1479
+ const titleAction = isPinned ? 'click to release filter' : 'click to highlight';
1480
+ return (
1481
+ <span
1482
+ key={key}
1483
+ data-pressure-seg={key}
1484
+ data-pressure-seg-aliases={matchAliases.join(',')}
1485
+ role="button"
1486
+ tabIndex={0}
1487
+ aria-pressed={isPinned}
1488
+ title={`${n} ${label}\n${previewList}${suffix}\n${titleAction}`}
1489
+ style={{
1490
+ width: `${(n / total) * 100}%`,
1491
+ background: color,
1492
+ height: '100%',
1493
+ cursor: 'pointer',
1494
+ boxShadow: isPinned ? `inset 0 0 0 1px ${color}, inset 0 0 0 2px rgba(255,255,255,0.6)` : undefined,
1495
+ }}
1496
+ onClick={(e) => {
1497
+ e.stopPropagation();
1498
+ setPinnedStatus(prev => prev === key ? null : key);
1499
+ }}
1500
+ onKeyDown={(e) => {
1501
+ if (e.key === 'Enter' || e.key === ' ') {
1502
+ e.preventDefault();
1503
+ setPinnedStatus(prev => prev === key ? null : key);
1504
+ }
1505
+ }}
1506
+ // R83: hover preview — segments now match the R55 legend,
1507
+ // R79 working/online chips, and R80 vendor letters in
1508
+ // offering a hover-transient highlight before the click
1509
+ // commits to a pin. Users get to feel what the filter
1510
+ // does before they lock it in. Same activeStatus =
1511
+ // hoveredStatus ?? pinnedStatus formula; releasing the
1512
+ // pointer falls back to the pin if one is set, or to
1513
+ // baseline. Thin (3-px) segments still hit-test fine
1514
+ // because the chip itself is a flex row — span doesn't
1515
+ // need any extra hit padding for hover.
1516
+ onMouseEnter={() => setHoveredStatus(key)}
1517
+ onMouseLeave={() => setHoveredStatus(prev => prev === key ? null : prev)}
1518
+ />
1519
+ );
1520
+ };
1521
+ // Round 47 / Loop: hidden on mobile — at <640px the chip row
1522
+ // wraps to multiple lines and crowds the topology header;
1523
+ // pressure ratio is best read with the working+online raw
1524
+ // counts (kept visible) anyway.
1525
+ return (
1526
+ <span
1527
+ 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"
1528
+ title={`${w} working · ${i} idle · ${o} offline`}
1529
+ data-fleet-pressure
1530
+ >
1531
+ <span className="text-[10px] tracking-wide">pressure</span>
1532
+ <span className="inline-flex h-1.5 w-16 rounded-full overflow-hidden" style={{ background: 'rgb(75 85 99 / 0.25)' }}>
1533
+ {seg(w, isLight ? '#059669' : '#22c55e', 'working', 'working')}
1534
+ {seg(i, isLight ? '#0d9488' : '#2dd4bf', 'idle', 'idle')}
1535
+ {seg(o, isLight ? '#94a3b8' : '#6b7280', 'offline', 'offline')}
1536
+ </span>
1537
+ </span>
1538
+ );
1539
+ })()}
1540
+ {/* Round 64 / Loop: active-filter pills. When pinnedStatus or
1541
+ pinnedGroup is set, show a small "filter: <key> ×" pill so
1542
+ the user can see the pin from the chip row even if they
1543
+ scrolled the canvas off-screen. × button clears the
1544
+ specific pin (Esc still clears all — both paths are
1545
+ valid). pinnedStatus and pinnedGroup pin independently so
1546
+ both pills may render simultaneously. */}
1547
+ {/* R67: pills enter via anet-fade-in so they appear softly
1548
+ instead of popping. The "filter: " prefix collapses below
1549
+ sm — at narrow viewports the chip row is precious real
1550
+ estate (R47 already hides pressure / vendor / freshness),
1551
+ so dropping the redundant label keeps the working/idle/
1552
+ alpha keys readable without overflow. */}
1553
+ {/* R71: each pill picks up a "· N" match count tail. Tells the
1554
+ user at a glance how many sessions the active filter
1555
+ matches without scanning the canvas. Counts come from the
1556
+ already-computed workingCount + onlineNodes + offlineNodes
1557
+ for status, and groupKeys for group. */}
1558
+ {/* R73: entire pill body is a click-to-clear target — matches
1559
+ the Notion / Linear tag UX (the whole chip releases). The
1560
+ × keeps its dedicated <button> + aria-label for screen
1561
+ readers; the outer span just adds an extra mouse-friendly
1562
+ hit area with a title hint. ×'s onClick stopPropagation
1563
+ so the redundant outer onClick doesn't double-fire (no
1564
+ functional difference since both clear, but cleaner
1565
+ event flow). */}
1566
+ {pinnedStatus && (() => {
1567
+ const matchCount = pinnedStatus === 'working' ? workingCount
1568
+ : pinnedStatus === 'idle' ? (onlineNodes.length - workingCount)
1569
+ : offlineNodes.length;
1570
+ // Round 97 / Loop: the pill says HOW MANY but not WHICH.
1571
+ // Hovering it now shows the matched alias list in the
1572
+ // tooltip — answers "exactly who is this filtering to"
1573
+ // without forcing the user to scan the dim mask on the
1574
+ // canvas. Truncates at 8 names so a 50-node working
1575
+ // bucket doesn't produce a 12-line tooltip; the count
1576
+ // chip already answers "how many overall".
1577
+ const matchAliases = pinnedStatus === 'working'
1578
+ ? onlineNodes.filter(s => s.status === 'working').map(s => s.alias)
1579
+ : pinnedStatus === 'idle'
1580
+ ? onlineNodes.filter(s => s.status !== 'working').map(s => s.alias)
1581
+ : offlineNodes.map(s => s.alias);
1582
+ const matchPreview = matchAliases.slice(0, 8).join(', ');
1583
+ const matchSuffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
1584
+ return (
1585
+ <span
1586
+ data-active-filter="status"
1587
+ data-filter-match-count={matchCount}
1588
+ data-filter-match-aliases={matchAliases.join(',')}
1589
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in"
1590
+ title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
1591
+ onClick={() => setPinnedStatus(null)}
1592
+ style={{
1593
+ background: pinnedStatus === 'working' ? (isLight ? '#05966914' : '#22c55e1f')
1594
+ : pinnedStatus === 'idle' ? (isLight ? '#0d948814' : '#2dd4bf1f')
1595
+ : (isLight ? '#94a3b814' : '#6b72801f'),
1596
+ color: pinnedStatus === 'working' ? (isLight ? '#047857' : '#86efac')
1597
+ : pinnedStatus === 'idle' ? (isLight ? '#0f766e' : '#5eead4')
1598
+ : (isLight ? '#475569' : '#9ca3af'),
1599
+ borderColor: 'currentColor',
1600
+ cursor: 'pointer',
1601
+ }}
1602
+ >
1603
+ <span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{pinnedStatus}<span className="opacity-70"> · {matchCount}</span></span>
1604
+ <button
1605
+ type="button"
1606
+ aria-label={`Clear ${pinnedStatus} filter`}
1607
+ onClick={(e) => { e.stopPropagation(); setPinnedStatus(null); }}
1608
+ className="ml-0.5 leading-none hover:opacity-70"
1609
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1610
+ >×</button>
1611
+ </span>
1612
+ );
1613
+ })()}
1614
+ {pinnedGroup && (() => {
1615
+ const matchCount = Object.values(groupKeys).filter(k => k === pinnedGroup).length;
1616
+ // R97: list group members in the tooltip.
1617
+ const matchAliases = Object.entries(groupKeys)
1618
+ .filter(([, key]) => key === pinnedGroup)
1619
+ .map(([alias]) => alias);
1620
+ const matchPreview = matchAliases.slice(0, 8).join(', ');
1621
+ const matchSuffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
1622
+ return (
1623
+ <span
1624
+ data-active-filter="group"
1625
+ data-filter-match-count={matchCount}
1626
+ data-filter-match-aliases={matchAliases.join(',')}
1627
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in"
1628
+ title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
1629
+ onClick={() => setPinnedGroup(null)}
1630
+ style={{
1631
+ background: isLight ? '#67e8f914' : '#67e8f91f',
1632
+ color: pal.legendAccent,
1633
+ borderColor: 'currentColor',
1634
+ cursor: 'pointer',
1635
+ }}
1636
+ >
1637
+ <span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{pinnedGroup}<span className="opacity-70"> · {matchCount}</span></span>
1638
+ <button
1639
+ type="button"
1640
+ aria-label={`Clear group filter ${pinnedGroup}`}
1641
+ onClick={(e) => { e.stopPropagation(); setPinnedGroup(null); }}
1642
+ className="ml-0.5 leading-none hover:opacity-70"
1643
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1644
+ >×</button>
1645
+ </span>
1646
+ );
1647
+ })()}
1648
+ {/* R89: vendor pin gets its own filter pill, matching the R64
1649
+ status + group pattern. R88 added the pin but only the
1650
+ letter itself carried the state; the chip-row had no
1651
+ "filter: A · 2 ×" affordance the other two pins have. The
1652
+ pill colour borrows the vendor's own swatch so each pin
1653
+ still reads in its native hue (A green, O cyan, 书 blue,
1654
+ ? slate). Same body-click-clears + × button pattern as
1655
+ R64. */}
1656
+ {pinnedVendor && (() => {
1657
+ const matchEntry = vendorDist.find(v => v.initial === pinnedVendor);
1658
+ const matchCount = matchEntry?.count ?? 0;
1659
+ const vendorColor = matchEntry?.color ?? pal.legendText;
1660
+ // R97: list vendor users in the tooltip. The vendorIdentity
1661
+ // resolver maps model strings to a vendor initial — match
1662
+ // any session whose initial equals the pinned letter (with
1663
+ // unknowns folded to '?').
1664
+ const matchAliases = [...onlineNodes, ...offlineNodes]
1665
+ .filter(s => {
1666
+ const v = vendorForModel(s.model);
1667
+ return (v.id === 'unknown' ? '?' : v.initial) === pinnedVendor;
1668
+ })
1669
+ .map(s => s.alias);
1670
+ const matchPreview = matchAliases.slice(0, 8).join(', ');
1671
+ const matchSuffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
1672
+ return (
1673
+ <span
1674
+ data-active-filter="vendor"
1675
+ data-filter-match-count={matchCount}
1676
+ data-filter-match-aliases={matchAliases.join(',')}
1677
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in"
1678
+ title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear vendor filter'}
1679
+ onClick={() => setPinnedVendor(null)}
1680
+ style={{
1681
+ background: `${vendorColor}1f`,
1682
+ color: vendorColor,
1683
+ borderColor: 'currentColor',
1684
+ cursor: 'pointer',
1685
+ }}
1686
+ >
1687
+ <span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{pinnedVendor}<span className="opacity-70"> · {matchCount}</span></span>
1688
+ <button
1689
+ type="button"
1690
+ aria-label={`Clear vendor filter ${pinnedVendor}`}
1691
+ onClick={(e) => { e.stopPropagation(); setPinnedVendor(null); }}
1692
+ className="ml-0.5 leading-none hover:opacity-70"
1693
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1694
+ >×</button>
1695
+ </span>
1696
+ );
1697
+ })()}
1698
+ {/* R119: edge pin filter pill — completes the R64 / R89 pill
1699
+ pattern across all four pin dimensions. R116 added
1700
+ pinnedEdgeKey but the chip row never grew the matching
1701
+ pill, leaving the locked flow visible only on the canvas +
1702
+ recent-signal row tint. This pill shows "filter:
1703
+ alpha→beta · 3" so the locked flow appears in the same
1704
+ row as the other three pin types. Body-click + × button
1705
+ clear, same pattern as R64. */}
1706
+ {pinnedEdgeKey && (() => {
1707
+ const link = flowLinks.find(l => l.key === pinnedEdgeKey);
1708
+ if (!link) return null;
1709
+ // R150 / Loop: extend the hot-lane amber convention from the
1710
+ // canvas badge (R126) / recent-row count (R127) / panel
1711
+ // header (R129) to the R119 edge filter pill — when the
1712
+ // pinned edge has count ≥ 10 the count tspan flips to
1713
+ // amber + 700 weight, and the tooltip grows a "(hot lane
1714
+ // · ≥ 10)" marker. Closes the 4th hot-lane surface,
1715
+ // completing R150's milestone: every place that surfaces
1716
+ // an edge count now uses the same amber-when-hot vocab.
1717
+ const isHot = link.count >= 10;
1718
+ const hotStroke = isLight ? '#d97706' : '#fbbf24';
1719
+ return (
810
1720
  <span
811
- className="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"
812
- title="Fleet by model vendor"
1721
+ data-active-filter="edge"
1722
+ data-filter-match-count={link.count}
1723
+ data-filter-match-aliases={`${link.from},${link.to}`}
1724
+ data-active-filter-edge-hot={isHot ? 'true' : 'false'}
1725
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in"
1726
+ title={`${link.from} → ${link.to} (${link.count} msg${link.count === 1 ? '' : 's'}${isHot ? ', hot lane · ≥ 10' : ''}) — click to clear`}
1727
+ onClick={() => setPinnedEdgeKey(null)}
1728
+ style={{
1729
+ background: isLight ? `${pal.flowEdge}14` : `${pal.flowEdge}1f`,
1730
+ color: pal.flowEdge,
1731
+ borderColor: 'currentColor',
1732
+ cursor: 'pointer',
1733
+ }}
813
1734
  >
814
- {vendorDist.map(v => (
815
- <span key={v.initial} className="inline-flex items-baseline gap-0.5">
816
- <span style={{ color: v.color }}>{v.initial}</span>
817
- <span className="text-gray-500">:{v.count}</span>
1735
+ <span>
1736
+ <span className="hidden sm:inline" data-filter-prefix>filter: </span>
1737
+ {link.from}{link.to}
1738
+ {isHot ? (
1739
+ <span
1740
+ className="opacity-90"
1741
+ style={{ color: hotStroke, fontWeight: 700 }}
1742
+ data-active-filter-edge-count-hot
1743
+ >
1744
+ {' · '}{link.count}
1745
+ </span>
1746
+ ) : (
1747
+ <span className="opacity-70" data-active-filter-edge-count>
1748
+ {' · '}{link.count}
1749
+ </span>
1750
+ )}
1751
+ </span>
1752
+ <button
1753
+ type="button"
1754
+ aria-label={`Clear edge filter ${link.from} → ${link.to}`}
1755
+ onClick={(e) => { e.stopPropagation(); setPinnedEdgeKey(null); }}
1756
+ className="ml-0.5 leading-none hover:opacity-70"
1757
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1758
+ >×</button>
1759
+ </span>
1760
+ );
1761
+ })()}
1762
+ {/* Round 124 / Loop: pin-intersection summary chip. The four
1763
+ filter pills (R64 status, R63 group, R89 vendor, R119
1764
+ edge) each report their own dim's match count in
1765
+ isolation — that answers "what does THIS filter catch"
1766
+ but not "what survives ALL pins". Since the node-opacity
1767
+ chain AND-composes the four pin dimensions, two active
1768
+ pins routinely produce an intersection smaller than
1769
+ either individual count. With three or four pins active
1770
+ the gap widens further. This chip appears ONLY when
1771
+ ≥ 2 pin dims are active (a single pin's pill already
1772
+ tells the whole story) and shows the count of nodes
1773
+ that satisfy every active pin simultaneously. Color is
1774
+ deliberately neutral (gray) since the chip represents
1775
+ the AND of mixed-color filters — borrowing any one of
1776
+ the pill hues would mis-signal which dim dominates.
1777
+ Tooltip lists the surviving aliases with the same
1778
+ 8-truncate + "+N more" pattern as the individual pill
1779
+ tooltips (R97). No click handler — it's a pure readout;
1780
+ Esc / per-pill × are still the cancel paths. */}
1781
+ {(() => {
1782
+ const pinDimCount =
1783
+ (pinnedStatus ? 1 : 0) +
1784
+ (pinnedGroup ? 1 : 0) +
1785
+ (pinnedVendor ? 1 : 0) +
1786
+ (pinnedEdgeKey ? 1 : 0);
1787
+ if (pinDimCount < 2) return null;
1788
+ const edgeLink = pinnedEdgeKey
1789
+ ? flowLinks.find(l => l.key === pinnedEdgeKey)
1790
+ : null;
1791
+ const edgeEndpoints: Set<string> | null = edgeLink
1792
+ ? new Set([edgeLink.from, edgeLink.to])
1793
+ : null;
1794
+ const allSessions = [...onlineNodes, ...offlineNodes];
1795
+ const survivors = allSessions.filter(s => {
1796
+ const isOnline = s.status !== 'offline';
1797
+ if (pinnedStatus === 'working' && s.status !== 'working') return false;
1798
+ if (pinnedStatus === 'idle' && !(isOnline && s.status !== 'working')) return false;
1799
+ if (pinnedStatus === 'offline' && isOnline) return false;
1800
+ if (pinnedGroup) {
1801
+ const gk = groupKeys[s.alias] ?? s.alias;
1802
+ if (gk !== pinnedGroup) return false;
1803
+ }
1804
+ if (pinnedVendor) {
1805
+ const v = vendorForModel(s.model);
1806
+ const initial = v.id === 'unknown' ? '?' : v.initial;
1807
+ if (initial !== pinnedVendor) return false;
1808
+ }
1809
+ if (edgeEndpoints && !edgeEndpoints.has(s.alias)) return false;
1810
+ return true;
1811
+ });
1812
+ const matchAliases = survivors.map(s => s.alias);
1813
+ const matchPreview = matchAliases.slice(0, 8).join(', ');
1814
+ const matchSuffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
1815
+ const isEmpty = matchAliases.length === 0;
1816
+ const tooltip = !isEmpty
1817
+ ? `${matchPreview}${matchSuffix} — nodes passing all ${pinDimCount} pinned filters`
1818
+ : `No nodes pass all ${pinDimCount} pinned filters — release one to widen (Esc clears all)`;
1819
+ // R125: when the intersection drops to zero, the chip flips
1820
+ // from neutral gray to a warning amber. Zero-overlap is the
1821
+ // exact case users get confused — canvas dims to 0.28
1822
+ // everywhere, no positive signal explains why. The "· 0"
1823
+ // tail in neutral gray reads as just another number,
1824
+ // indistinguishable from "· 12". Amber + a ⚠ glyph lifts
1825
+ // it to "your filters cancel out" at a glance. Color
1826
+ // choice: amber (#d97706 light, #fbbf24 dark) — same hue
1827
+ // family as warning chips elsewhere in the dashboard,
1828
+ // distinct from any pill color (status green/teal/slate,
1829
+ // group cyan, vendor varies, edge cyan, neutral gray for
1830
+ // non-empty intersection) so the empty state stands out.
1831
+ const emptyColor = isLight ? '#d97706' : '#fbbf24';
1832
+ return (
1833
+ <span
1834
+ data-pin-intersection
1835
+ data-pin-dim-count={pinDimCount}
1836
+ data-pin-intersection-count={matchAliases.length}
1837
+ data-pin-intersection-empty={isEmpty ? 'true' : 'false'}
1838
+ data-pin-intersection-aliases={matchAliases.join(',')}
1839
+ 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"
1840
+ title={tooltip}
1841
+ style={{
1842
+ background: isEmpty
1843
+ ? (isLight ? '#d97706' + '14' : '#fbbf24' + '1f')
1844
+ : (isLight ? '#94a3b814' : '#94a3b81f'),
1845
+ color: isEmpty
1846
+ ? emptyColor
1847
+ : (isLight ? '#475569' : '#9ca3af'),
1848
+ borderColor: 'currentColor',
1849
+ }}
1850
+ >
1851
+ <span>
1852
+ <span className="hidden sm:inline" data-pin-intersection-prefix>match: </span>
1853
+ {pinDimCount} pins<span className="opacity-70"> · {matchAliases.length}</span>
1854
+ {isEmpty && <span className="ml-1" aria-hidden>⚠</span>}
818
1855
  </span>
819
- ))}
1856
+ </span>
1857
+ );
1858
+ })()}
1859
+ {vendorDist.length > 1 && (
1860
+ <span
1861
+ 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"
1862
+ title="Hover to highlight; click to pin"
1863
+ >
1864
+ {vendorDist.map(v => {
1865
+ const isPinned = pinnedVendor === v.initial;
1866
+ // R88: click toggles a sticky filter the same way R60
1867
+ // pressure-bar segments toggle pinnedStatus. Visual
1868
+ // mirror = inset boxShadow using the vendor's own
1869
+ // colour, so each pinned letter sings in its own hue
1870
+ // (Anthropic green / OpenAI cyan / 书 blue / ?).
1871
+ // R101: tooltip lists the aliases that use this vendor —
1872
+ // completes the info-density triple started by R97 pills,
1873
+ // R98 node titles, R99 group-label titles. Anywhere the
1874
+ // UI shows "A:3" should hover-explain which 3.
1875
+ const aliases = [...onlineNodes, ...offlineNodes]
1876
+ .filter(s => {
1877
+ const vid = vendorForModel(s.model);
1878
+ return (vid.id === 'unknown' ? '?' : vid.initial) === v.initial;
1879
+ })
1880
+ .map(s => s.alias);
1881
+ const preview = aliases.slice(0, 8).join(', ');
1882
+ const suffix = aliases.length > 8 ? ` + ${aliases.length - 8} more` : '';
1883
+ const tooltip = isPinned
1884
+ ? `${preview}${suffix} — click again or Esc to clear`
1885
+ : `${preview}${suffix} — click to pin`;
1886
+ return (
1887
+ <span
1888
+ key={v.initial}
1889
+ role="button"
1890
+ tabIndex={0}
1891
+ aria-pressed={isPinned}
1892
+ className="inline-flex items-baseline gap-0.5 px-1 rounded"
1893
+ data-vendor-letter={v.initial}
1894
+ data-vendor-pinned={isPinned ? 'true' : 'false'}
1895
+ data-vendor-aliases={aliases.join(',')}
1896
+ title={tooltip}
1897
+ style={{
1898
+ cursor: 'pointer',
1899
+ boxShadow: isPinned
1900
+ ? `inset 0 0 0 1px ${v.color}, inset 0 0 0 2px rgba(255,255,255,0.45)`
1901
+ : undefined,
1902
+ }}
1903
+ onMouseEnter={() => setHoveredVendor(v.initial)}
1904
+ onMouseLeave={() => setHoveredVendor(prev => prev === v.initial ? null : prev)}
1905
+ onClick={() => setPinnedVendor(prev => prev === v.initial ? null : v.initial)}
1906
+ onKeyDown={(e) => {
1907
+ if (e.key === 'Enter' || e.key === ' ') {
1908
+ e.preventDefault();
1909
+ setPinnedVendor(prev => prev === v.initial ? null : v.initial);
1910
+ }
1911
+ }}
1912
+ >
1913
+ <span style={{ color: v.color }}>{v.initial}</span>
1914
+ <span className="text-gray-500">:{v.count}</span>
1915
+ </span>
1916
+ );
1917
+ })}
820
1918
  </span>
821
1919
  )}
822
- <span className="px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20">
823
- {flowLinks.length} active links
824
- </span>
1920
+ {/* Round 42 / Loop: extend active-links chip with the timestamp
1921
+ of the most-recent flow event. Tells the operator at a glance
1922
+ whether the topology is currently humming (last 30s) or has
1923
+ been quiet for a while — the visual flow particles and
1924
+ edge brightness only show that there IS traffic, not when
1925
+ the last one was. Reuses Round 38's relativeAgo. */}
1926
+ {(() => {
1927
+ const recent = flowLinks.reduce<number | null>((acc, l) => {
1928
+ if (!l.last_at) return acc;
1929
+ const t = parseHubTime(l.last_at);
1930
+ if (t === null) return acc;
1931
+ return acc === null || t > acc ? t : acc;
1932
+ }, null);
1933
+ const rel = recent !== null ? relativeAgo(new Date(recent).toISOString()) : null;
1934
+ // R114: tooltip lists the actual flows. Closes the
1935
+ // info-density sweep on the last chip-row hover surface
1936
+ // (R97-R113 covered everything else). Format:
1937
+ // "alpha→beta (3), gamma→delta (1) — hover brightens all"
1938
+ // Truncates at 6 flows with "+N more" so a busy fleet
1939
+ // doesn't paint a tall tooltip; the recent-signal panel
1940
+ // already shows the top 3 in detail.
1941
+ const flowList = flowLinks
1942
+ .slice(0, 6)
1943
+ .map(l => `${l.from}→${l.to} (${l.count})`)
1944
+ .join(', ');
1945
+ const flowSuffix = flowLinks.length > 6 ? ` + ${flowLinks.length - 6} more` : '';
1946
+ // R136: the chip already had cursor:pointer when flowLinks
1947
+ // > 0 (line 1877) — but no onClick was wired. The cursor
1948
+ // lied; users got the affordance signal with no follow-
1949
+ // through. Wire it to /messages, mirroring R133's footer-
1950
+ // nav idiom. Hover (R77) keeps its semantic "preview all
1951
+ // flows on canvas"; click is the action "open the full
1952
+ // list". Two distinct gestures, both meaningful. The
1953
+ // tooltip grows a "click to open" tail when interactive.
1954
+ // Drop the chip out of click territory entirely when
1955
+ // flowLinks is empty — no flows = no list to open.
1956
+ const isInteractive = flowLinks.length > 0;
1957
+ const tooltip = !isInteractive
1958
+ ? undefined
1959
+ : `${flowList}${flowSuffix} — hover brightens all · click to open /messages`;
1960
+ return (
1961
+ <span
1962
+ className="hidden sm:inline px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20"
1963
+ data-active-links-chip
1964
+ data-active-links-flow-count={flowLinks.length}
1965
+ data-active-links-clickable={isInteractive ? 'true' : 'false'}
1966
+ title={tooltip}
1967
+ role={isInteractive ? 'link' : undefined}
1968
+ tabIndex={isInteractive ? 0 : undefined}
1969
+ style={{ cursor: isInteractive ? 'pointer' : undefined }}
1970
+ onMouseEnter={() => { if (isInteractive) setHoveredActiveLinks(true); }}
1971
+ onMouseLeave={() => setHoveredActiveLinks(false)}
1972
+ onClick={() => { if (isInteractive) router.push('/messages'); }}
1973
+ onKeyDown={(e) => {
1974
+ if (!isInteractive) return;
1975
+ if (e.key === 'Enter' || e.key === ' ') {
1976
+ e.preventDefault();
1977
+ router.push('/messages');
1978
+ }
1979
+ }}
1980
+ >
1981
+ {flowLinks.length} active link{flowLinks.length === 1 ? '' : 's'}
1982
+ {rel ? <span className="text-gray-500"> · last {rel}</span> : null}
1983
+ </span>
1984
+ );
1985
+ })()}
1986
+ <FreshnessChip sessions={sessions} />
825
1987
  </div>
826
1988
  </div>
827
1989
 
@@ -841,7 +2003,18 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
841
2003
  onPointerMove={onPointerMove}
842
2004
  onPointerUp={onPointerUp}
843
2005
  onPointerLeave={onPointerUp}
844
- style={{ cursor: 'grab', touchAction: 'none' }}
2006
+ // Round 41 / Loop: the reset-button title (R22) and the Help
2007
+ // overlay (R25) both advertise "double-click the canvas to
2008
+ // reset", but the handler was never actually wired. The text
2009
+ // was lying. Wire it here, guarded so dbl-clicking a node
2010
+ // (which would also trigger the SVG-level handler via event
2011
+ // bubbling) doesn't unexpectedly reset the view on the user.
2012
+ onDoubleClick={(e) => {
2013
+ const t = e.target as Element | null;
2014
+ if (t?.closest('g[data-node]')) return;
2015
+ resetView();
2016
+ }}
2017
+ style={{ cursor: isPanning ? 'grabbing' : 'grab', touchAction: 'none' }}
845
2018
  >
846
2019
  <defs>
847
2020
  <linearGradient id="topo-panel" x1="0" x2="1" y1="0" y2="1">
@@ -863,9 +2036,56 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
863
2036
  </feMerge>
864
2037
  </filter>
865
2038
  )}
866
- <marker id="topo-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
867
- <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
868
- </marker>
2039
+ {/* R142 / Loop: drop-shadow filter for group-box hover-lift.
2040
+ Mirrors R135's panel hover-elevation idiom but applied
2041
+ at the per-group canvas level. R68 already gives a
2042
+ hovered/pinned group box a solid accent stroke; R142
2043
+ adds a soft outward shadow on top so the box visually
2044
+ "rises off the canvas" when selected — same visual
2045
+ vocabulary as the panels + the Overview KPI cards
2046
+ (R18). Theme-aware flood: darker shadow on cyber so
2047
+ it reads above the dark canvas, lighter for light
2048
+ theme. Bounding box of the group box is unchanged —
2049
+ filters only affect paint area, not bbox geometry, so
2050
+ the overlap-test invariant is preserved. */}
2051
+ <filter id="topo-groupbox-lift" x="-10%" y="-10%" width="120%" height="120%">
2052
+ <feDropShadow
2053
+ dx="0" dy="3" stdDeviation="4"
2054
+ floodColor={isLight ? '#0f172a' : '#000000'}
2055
+ floodOpacity={isLight ? 0.18 : 0.55}
2056
+ />
2057
+ </filter>
2058
+ {/* Round 16 / Loop: 3-tier flow-link arrow markers.
2059
+ The single marker had `markerUnits` defaulting to
2060
+ `strokeWidth`, so heavy edges (stroke=7) rendered 35-user-
2061
+ unit arrowheads — visually dominant, the head outweighed
2062
+ the line. Switching to `userSpaceOnUse` decouples arrow
2063
+ size from stroke; binning by count gives a clearer
2064
+ hierarchy than continuous linear scaling:
2065
+ s (count 1-2) → 12 user units
2066
+ m (count 3-4) → 16 user units (alias for `topo-arrow`)
2067
+ l (count 5+) → 22 user units
2068
+ `topo-arrow` stays bound to the medium tier so the legend
2069
+ swatch (line ~1500) renders without change. */}
2070
+ {[
2071
+ { id: 'topo-arrow-s', size: 12 },
2072
+ { id: 'topo-arrow', size: 16 },
2073
+ { id: 'topo-arrow-l', size: 22 },
2074
+ ].map(m => (
2075
+ <marker
2076
+ key={m.id}
2077
+ id={m.id}
2078
+ viewBox="0 0 10 10"
2079
+ refX="8"
2080
+ refY="5"
2081
+ markerWidth={m.size}
2082
+ markerHeight={m.size}
2083
+ markerUnits="userSpaceOnUse"
2084
+ orient="auto-start-reverse"
2085
+ >
2086
+ <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
2087
+ </marker>
2088
+ ))}
869
2089
  {/* Round 45: radial radar sweep gradient — bright at center
870
2090
  (where it meets the hub) fading to leading edge. */}
871
2091
  <radialGradient id="topo-sweep" cx="0%" cy="50%" r="100%">
@@ -885,13 +2105,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
885
2105
  grid mode drops it so the concentric rings don't sit behind a
886
2106
  rectangular grid. */}
887
2107
  {layout === 'ring' && (<>
888
- <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" />
2108
+ {/* R52: radar bg is pure decoration — drop its pointer events so
2109
+ the hub <g> under it (and any future inner-disk affordances)
2110
+ can receive clicks. Previously the r=330 disc intercepted
2111
+ the hub click outright. */}
2112
+ <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" style={{ pointerEvents: 'none' }} />
889
2113
 
890
2114
  {/* Round 45: subtle star field — 24 deterministic dots scattered
891
2115
  across the canvas give the radar bg some depth. Skipped on
892
2116
  light theme so the white surface stays clean. */}
893
2117
  {!isLight && (
894
- <g opacity="0.5">
2118
+ <g opacity="0.5" style={{ pointerEvents: 'none' }}>
895
2119
  {Array.from({ length: 28 }).map((_, i) => {
896
2120
  // Deterministic pseudo-random scatter so positions are
897
2121
  // stable between renders (no JS hydration mismatch).
@@ -909,50 +2133,169 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
909
2133
  scan without being noisy. Inline transform-origin on the
910
2134
  <g> wrapper ensures Chrome / Firefox rotate around (cx,cy)
911
2135
  instead of the SVG viewBox corner. */}
912
- <g
913
- style={{
914
- transformOrigin: `${cx}px ${cy}px`,
915
- transformBox: 'view-box',
916
- }}
917
- className="anet-topo-sweep"
918
- opacity={isLight ? 0.7 : 1}
919
- >
920
- <path
921
- d={`M ${cx} ${cy} L ${cx + 330} ${cy} A 330 330 0 0 0 ${cx + 330 * Math.cos(-Math.PI / 4.5)} ${cy + 330 * Math.sin(-Math.PI / 4.5)} Z`}
922
- fill="url(#topo-sweep)"
923
- />
924
- </g>
2136
+ {(() => {
2137
+ // R146: radar sweep rotation buckets on workingCount, joining
2138
+ // R84 hub breath / R131 outer orbit / R132 group march /
2139
+ // R145 idle spokes as the 5th and final layer in the busyness-
2140
+ // driven motion family. 8 / 6 / 4 / 3 seconds — same 0 / 1-2 /
2141
+ // 3-5 / 6+ thresholds R84 uses. "Busier fleet = more frequent
2142
+ // scans" feels semantically right for a radar idiom. R45
2143
+ // baseline 6s sits at bucket 1 so the calm/busy spread bracks
2144
+ // around the historical default.
2145
+ const busy = workingCount === 0 ? 0
2146
+ : workingCount <= 2 ? 1
2147
+ : workingCount <= 5 ? 2
2148
+ : 3;
2149
+ const sweepDur = [8, 6, 4, 3][busy];
2150
+ return (
2151
+ <g
2152
+ style={{
2153
+ transformOrigin: `${cx}px ${cy}px`,
2154
+ transformBox: 'view-box',
2155
+ pointerEvents: 'none',
2156
+ // CSS var consumed by `.anet-topo-sweep` (line 848 of
2157
+ // globals.css). React's CSSProperties type doesn't model
2158
+ // custom properties → cast through Record<string, string>.
2159
+ ...({ ['--sweep-dur']: `${sweepDur}s` } as Record<string, string>),
2160
+ } as React.CSSProperties}
2161
+ className="anet-topo-sweep"
2162
+ opacity={isLight ? 0.7 : 1}
2163
+ data-topo-sweep-bucket={busy}
2164
+ data-topo-sweep-dur={sweepDur}
2165
+ >
2166
+ <path
2167
+ d={`M ${cx} ${cy} L ${cx + 330} ${cy} A 330 330 0 0 0 ${cx + 330 * Math.cos(-Math.PI / 4.5)} ${cy + 330 * Math.sin(-Math.PI / 4.5)} Z`}
2168
+ fill="url(#topo-sweep)"
2169
+ />
2170
+ </g>
2171
+ );
2172
+ })()}
925
2173
 
926
- {/* radar rings */}
2174
+ {/* radar rings — pure decoration at fixed radii, independent of
2175
+ node positions so the radar aesthetic is preserved across tier
2176
+ changes. */}
927
2177
  {[90, 170, 250, 330].map(radius => (
928
2178
  <circle key={radius} cx={cx} cy={cy} r={radius} fill="none" stroke={pal.ringStroke} strokeWidth="1" opacity={isLight ? 0.6 : 0.35} />
929
2179
  ))}
930
2180
 
2181
+ {/* Round 54 / Loop: tier-radius guide rings. The radar rings above
2182
+ are decorative and don't match the actual tier radii nodes sit
2183
+ on (single 220 / dual 175,260 / triple 145,215,285). Drawing
2184
+ a faint dashed ring at each ACTIVE tier radius lets the eye
2185
+ anchor "this is the inner / outer ring" without inferring from
2186
+ node spacing. Picked based on online node count so only the
2187
+ tiers currently in use draw — empty tiers stay quiet. pointer-
2188
+ events:none so they never intercept hub or node clicks. The
2189
+ 0.7 stroke + dashed pattern reads as guide, not feature. */}
2190
+ {(() => {
2191
+ const tierRadii = onlineNodes.length > onlineTripleThreshold
2192
+ ? [onlineTripleInnerR, onlineTripleMidR, onlineTripleOuterR]
2193
+ : onlineNodes.length > onlineTierThreshold
2194
+ ? [onlineInnerRadius, onlineOuterRadius]
2195
+ : onlineNodes.length > 0
2196
+ ? [onlineRadius]
2197
+ : [];
2198
+ // Round 92 / Loop: tier-ring occupancy. R54 drew the guide
2199
+ // rings at fixed opacity regardless of how many nodes lived
2200
+ // on each tier. With pinned filters dimming most nodes to
2201
+ // 0.28, the ring at a deserted tier looked identical to a
2202
+ // crowded one — wasting a free piece of canvas. Count
2203
+ // online nodes whose hub-distance falls within ±15 px of
2204
+ // each ring (15 px = half the inter-tier gap, so each
2205
+ // node is assigned to exactly one ring). Empty tier → skip
2206
+ // entirely. Crowded tier → stronger opacity, says "look
2207
+ // here". Buckets so the ladder feels intentional, not
2208
+ // jittery as one node migrates between tiers.
2209
+ const occupancyOf = (r: number) => onlineNodes.reduce((acc, s) => {
2210
+ const p = nodePositions[s.alias];
2211
+ if (!p) return acc;
2212
+ const d = Math.hypot(p.x - cx, p.y - cy);
2213
+ return Math.abs(d - r) < 15 ? acc + 1 : acc;
2214
+ }, 0);
2215
+ // Round 93 / Loop: when any pin is active, tint the tier
2216
+ // rings to the legend accent so the spatial guide visually
2217
+ // shares the canvas's "filtered mode" colour. The chip
2218
+ // row already says WHICH filter is on (pills + letter
2219
+ // mirror); rings answering "we're filtered" reinforces
2220
+ // the state when the eye is on the canvas, not the chip
2221
+ // row. Composes cleanly with R92 occupancy — same opacity
2222
+ // bucket logic; just the stroke colour swaps.
2223
+ const anyPin = !!(pinnedStatus || pinnedGroup || pinnedVendor);
2224
+ const tierStroke = anyPin ? pal.legendAccent : pal.ringStroke;
2225
+ return tierRadii.map(r => {
2226
+ const n = occupancyOf(r);
2227
+ if (n === 0) return null;
2228
+ const bucket = n <= 2 ? 0 : n <= 6 ? 1 : 2;
2229
+ const opLight = [0.24, 0.36, 0.50][bucket];
2230
+ const opDark = [0.32, 0.46, 0.62][bucket];
2231
+ return (
2232
+ <circle
2233
+ key={`tier-${r}`}
2234
+ cx={cx} cy={cy} r={r}
2235
+ fill="none"
2236
+ stroke={tierStroke}
2237
+ strokeWidth="0.7"
2238
+ strokeDasharray="2 8"
2239
+ opacity={isLight ? opLight : opDark}
2240
+ style={{ pointerEvents: 'none', transition: 'stroke 200ms ease-out' }}
2241
+ data-tier-ring={r}
2242
+ data-tier-occupancy={n}
2243
+ data-tier-bucket={bucket}
2244
+ data-tier-tinted={anyPin ? 'true' : 'false'}
2245
+ />
2246
+ );
2247
+ });
2248
+ })()}
2249
+
931
2250
  {/* Round 50: 4 small particles slowly orbiting the outer ring
932
2251
  (r=330). Each starts at a different angle (offset 0/0.25/0.5/0.75
933
2252
  of the cycle) so they're evenly spaced. 16s per revolution is
934
2253
  slow enough to feel ambient, not noisy. Skipped on light theme
935
- so the white surface stays clean. */}
936
- {!isLight && [0, 0.25, 0.5, 0.75].map((phase, i) => (
937
- <g key={`orbit-${i}`}>
938
- <circle
939
- cx={cx + 330} cy={cy}
940
- r={i === 0 ? 2.8 : 2.2}
941
- fill="#22d3ee"
942
- opacity={0.9}
943
- filter="url(#topo-glow)"
2254
+ so the white surface stays clean.
2255
+
2256
+ R131 / Loop: orbit period now buckets on workingCount,
2257
+ mirroring R84's hub-busyness breath cadence. An idle
2258
+ fleet keeps the original 16s "calm sweep"; as work
2259
+ accumulates the orbit subtly accelerates (capped at
2260
+ 10s so it never feels frantic). Two-layer motion
2261
+ coordination now: R84 breathes the hub, R131 spins
2262
+ the outer ring — both reading the same underlying
2263
+ "is the network busy" signal, both visible
2264
+ simultaneously without competing. Same bucket
2265
+ thresholds (0 / 1-2 / 3-5 / 6+) the R84 block uses
2266
+ at line ~2702 so the two cadences stay in sync if
2267
+ a future refactor shifts buckets. */}
2268
+ {!isLight && (() => {
2269
+ const busy = workingCount === 0 ? 0
2270
+ : workingCount <= 2 ? 1
2271
+ : workingCount <= 5 ? 2
2272
+ : 3;
2273
+ const dur = [16, 14, 12, 10][busy];
2274
+ return [0, 0.25, 0.5, 0.75].map((phase, i) => (
2275
+ <g
2276
+ key={`orbit-${i}`}
2277
+ data-topo-orbit-bucket={busy}
2278
+ data-topo-orbit-dur={dur}
944
2279
  >
945
- <animateTransform
946
- attributeName="transform"
947
- type="rotate"
948
- from={`${phase * 360} ${cx} ${cy}`}
949
- to={`${phase * 360 + 360} ${cx} ${cy}`}
950
- dur="16s"
951
- repeatCount="indefinite"
952
- />
953
- </circle>
954
- </g>
955
- ))}
2280
+ <circle
2281
+ cx={cx + 330} cy={cy}
2282
+ r={i === 0 ? 2.8 : 2.2}
2283
+ fill="#22d3ee"
2284
+ opacity={0.9}
2285
+ filter="url(#topo-glow)"
2286
+ >
2287
+ <animateTransform
2288
+ attributeName="transform"
2289
+ type="rotate"
2290
+ from={`${phase * 360} ${cx} ${cy}`}
2291
+ to={`${phase * 360 + 360} ${cx} ${cy}`}
2292
+ dur={`${dur}s`}
2293
+ repeatCount="indefinite"
2294
+ />
2295
+ </circle>
2296
+ </g>
2297
+ ));
2298
+ })()}
956
2299
 
957
2300
  {[0, 30, 60, 90, 120, 150].map(angle => (
958
2301
  <line
@@ -971,65 +2314,281 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
971
2314
  {/* hub links — round 46: idle spokes now have animated
972
2315
  stroke-dashoffset so dashes flow outward from the hub
973
2316
  ("command relay" feel). Active spokes carrying live message
974
- flow stay as solid bright strokes. */}
975
- {layout === 'ring' && onlineNodes.map((session, idx) => {
976
- const pos = nodePositions[session.alias];
977
- if (!pos) return null;
978
- const path = curvePath({ x: cx, y: cy }, pos, 0);
979
- const isActiveSpoke = activeAliases.has(session.alias);
2317
+ flow stay as solid bright strokes.
2318
+ R145 / Loop: idle-spoke animation cadence buckets on
2319
+ workingCount, mirroring R84 hub-breath / R131 outer-
2320
+ ring orbit / R132 groupbox-march coordination. Same
2321
+ 0 / 1-2 / 3-5 / 6+ thresholds. Idle idle network
2322
+ has a slow "command relay" feel; as work accumulates
2323
+ the dashes accelerate outward, completing the 4th
2324
+ motion layer in the busyness-driven family:
2325
+ R84 hub breath (centre)
2326
+ R131 outer ring orbit (periphery)
2327
+ R132 groupbox march (per-team, grid layout)
2328
+ R145 idle-spoke flow (ring layout, hub→nodes)
2329
+ 4 surfaces, 1 signal. Same cadence ladder 2.8/2.4/2.0/
2330
+ 1.6s — 1.75× range from calm to busy, capped so the
2331
+ network never feels frantic. */}
2332
+ {layout === 'ring' && (() => {
2333
+ const busy = workingCount === 0 ? 0
2334
+ : workingCount <= 2 ? 1
2335
+ : workingCount <= 5 ? 2
2336
+ : 3;
2337
+ const spokeDur = [2.8, 2.4, 2.0, 1.6][busy];
2338
+ return onlineNodes.map((session, idx) => {
2339
+ const pos = nodePositions[session.alias];
2340
+ if (!pos) return null;
2341
+ const path = curvePath({ x: cx, y: cy }, pos, 0);
2342
+ const isActiveSpoke = activeAliases.has(session.alias);
980
2343
 
981
- return (
982
- <path
983
- key={`hub-${session.alias}`}
984
- d={path}
985
- fill="none"
986
- stroke={isActiveSpoke ? pal.spokeStroke.active : pal.spokeStroke.idle}
987
- strokeWidth={isActiveSpoke ? 2 : 1}
988
- strokeDasharray={isActiveSpoke ? 'none' : '6 14'}
989
- opacity={isActiveSpoke ? 0.7 : 0.45}
990
- className={isActiveSpoke ? undefined : 'anet-topo-spoke-flow'}
991
- style={isActiveSpoke ? undefined : { animationDelay: `${-(idx * 0.25)}s` }}
992
- />
993
- );
994
- })}
2344
+ return (
2345
+ <path
2346
+ key={`hub-${session.alias}`}
2347
+ d={path}
2348
+ fill="none"
2349
+ stroke={isActiveSpoke ? pal.spokeStroke.active : pal.spokeStroke.idle}
2350
+ strokeWidth={isActiveSpoke ? 2 : 1}
2351
+ strokeDasharray={isActiveSpoke ? 'none' : '6 14'}
2352
+ opacity={isActiveSpoke ? 0.7 : 0.45}
2353
+ className={isActiveSpoke ? undefined : 'anet-topo-spoke-flow'}
2354
+ data-topo-spoke-bucket={isActiveSpoke ? undefined : busy}
2355
+ data-topo-spoke-dur={isActiveSpoke ? undefined : spokeDur}
2356
+ style={isActiveSpoke ? undefined : ({
2357
+ animationDelay: `${-(idx * 0.25)}s`,
2358
+ // CSS var consumed by `.anet-topo-spoke-flow`
2359
+ // (line 859 of globals.css). React's CSSProperties
2360
+ // type doesn't model custom properties → cast
2361
+ // through Record<string, string>.
2362
+ ...({ ['--spoke-dur']: `${spokeDur}s` } as Record<string, string>),
2363
+ } as React.CSSProperties)}
2364
+ />
2365
+ );
2366
+ });
2367
+ })()}
995
2368
 
996
2369
  {/* #111: prefix-group boundary boxes (Vincent 4722). Grid layout
997
2370
  only — groupBoxes is empty in ring mode. Rendered behind the
998
2371
  flow links + nodes; pointer-events off so they never intercept
999
2372
  a node click. Restrained dashed container + group-name label. */}
1000
- {groupBoxes.map(box => (
1001
- <g
1002
- key={`grp-${box.key}`}
1003
- data-group={box.key}
1004
- className="transition-opacity"
1005
- // Round 8: dim group boxes outside the hover focus so the eye
1006
- // locks on the team being inspected.
1007
- style={{ pointerEvents: 'none', opacity: !hoveredGroup || box.key === hoveredGroup ? 1 : 0.28 }}
1008
- >
1009
- <rect
1010
- x={box.x}
1011
- y={box.y}
1012
- width={box.w}
1013
- height={box.h}
1014
- rx="14"
1015
- fill={isLight ? '#0f172a' : '#a5b4fc'}
1016
- fillOpacity={isLight ? 0.025 : 0.045}
1017
- stroke={pal.ringStroke}
1018
- strokeWidth="1.5"
1019
- strokeDasharray="6 6"
1020
- />
1021
- <text
1022
- x={box.x + 12}
1023
- y={box.y + 14}
1024
- fill={pal.legendText}
1025
- fontSize="13"
1026
- fontFamily="monospace"
1027
- fontWeight="700"
2373
+ {groupBoxes.map(box => {
2374
+ const isHovered = activeGroup === box.key;
2375
+ // R68: distinguish "locked by click" from "currently hovered".
2376
+ // R63 made pinned and hovered identical (both hit isHovered
2377
+ // via activeGroup). A user with one team pinned should see at
2378
+ // a glance which is the locked one even while their cursor
2379
+ // sweeps elsewhere. isPinned reads pinnedGroup directly
2380
+ // (NOT activeGroup) so the visual is specific to the sticky
2381
+ // state — transient hover keeps the R63 isHovered styling.
2382
+ const isPinned = pinnedGroup === box.key;
2383
+ // Round 18 / Loop: group-box hover linkage. The Round 8 fade
2384
+ // already dropped OUT-of-focus groups to 0.28, but the IN-focus
2385
+ // group sat at its baseline appearance — no positive emphasis.
2386
+ // Hovering now upgrades the box to an "accent" treatment:
2387
+ // solid stroke (not dashed), thicker, accent-coloured; brighter
2388
+ // text and slightly stronger fill. Label and box read as one
2389
+ // selected unit. Geometry unchanged → overlap test untouched.
2390
+ // R132: per-group marching-ants duration computed once,
2391
+ // reused on the data attribute + the inline custom property.
2392
+ const w = box.statuses.working;
2393
+ const marchDur = w >= 6 ? 8 : w >= 4 ? 10 : w >= 2 ? 12 : 14;
2394
+ return (
2395
+ <g
2396
+ key={`grp-${box.key}`}
2397
+ data-group={box.key}
2398
+ className="transition-opacity"
2399
+ // R63: drop the blanket pointerEvents:'none' that
2400
+ // previously sat here. Chrome's SVG impl doesn't let a
2401
+ // child override a parent's `none` even though the spec
2402
+ // says it should — moving the property onto just the
2403
+ // rect (where it's needed so nodes underneath stay
2404
+ // clickable) lets the label text receive its own click.
2405
+ style={{ opacity: !activeGroup || isHovered ? 1 : 0.28 }}
1028
2406
  >
1029
- {box.key}
1030
- </text>
1031
- </g>
1032
- ))}
2407
+ <rect
2408
+ x={box.x}
2409
+ y={box.y}
2410
+ width={box.w}
2411
+ height={box.h}
2412
+ rx="14"
2413
+ fill={isLight ? '#0f172a' : '#a5b4fc'}
2414
+ // R68: 3-tier opacity + stroke ladder.
2415
+ // pinned → fill 0.08 / 0.13, stroke 3 px (locked)
2416
+ // hovered → fill 0.05 / 0.09, stroke 2 px (inspecting)
2417
+ // idle → fill 0.025 / 0.045, stroke 1.5 px dashed
2418
+ fillOpacity={isPinned ? (isLight ? 0.08 : 0.13)
2419
+ : isHovered ? (isLight ? 0.05 : 0.09)
2420
+ : (isLight ? 0.025 : 0.045)}
2421
+ stroke={(isPinned || isHovered) ? pal.legendAccent : pal.ringStroke}
2422
+ strokeWidth={isPinned ? 3 : isHovered ? 2 : 1.5}
2423
+ strokeDasharray={(isPinned || isHovered) ? 'none' : '6 6'}
2424
+ data-group-box-pinned={isPinned ? 'true' : 'false'}
2425
+ // R85: ambient "marching ants" drift on the perimeter
2426
+ // when this group has at least one working member, and
2427
+ // neither pin nor hover is active (those treatments
2428
+ // already shout for attention via solid stroke). 12s
2429
+ // cycle reads as ambient — the eye parses "live work
2430
+ // here" without registering the box as animating.
2431
+ // R132: per-group ant rate buckets on box.statuses.working
2432
+ // — the same coupling-to-busyness idiom R84 uses for the
2433
+ // hub and R131 uses for the outer-ring orbit, applied at
2434
+ // the GROUP scale. A team with one working member ambles;
2435
+ // a team with five working members visibly accelerates.
2436
+ // Bucket boundaries (1 / 2-3 / 4-5 / 6+) chosen to land
2437
+ // on the same 14/12/10/8 cadence ladder so the three
2438
+ // motion layers (hub / ring / group) keep a coherent
2439
+ // tempo grammar. Default 12s when working=0 doesn't
2440
+ // matter — the className is only applied when working>0.
2441
+ data-group-box-live={!isPinned && !isHovered && box.statuses.working > 0 ? 'true' : 'false'}
2442
+ data-group-box-march-dur={marchDur}
2443
+ data-group-box-lifted={(isPinned || isHovered) ? 'true' : 'false'}
2444
+ className={!isPinned && !isHovered && box.statuses.working > 0 ? 'anet-topo-groupbox-live' : undefined}
2445
+ // R142: drop-shadow filter when pinned or hovered. Box
2446
+ // visually "rises off the canvas" — same vocabulary
2447
+ // R18 KPI cards + R135 overlay panels use. Idle group
2448
+ // boxes carry no filter (purely flat dashed outline)
2449
+ // so the unstyled canvas stays uncluttered. Filter
2450
+ // affects paint area only, not the geometric bbox
2451
+ // the overlap-test reads, so zero-overlap invariant
2452
+ // is preserved.
2453
+ filter={(isPinned || isHovered) ? 'url(#topo-groupbox-lift)' : undefined}
2454
+ style={{
2455
+ transition: 'stroke 200ms ease-out, stroke-width 200ms ease-out, fill-opacity 200ms ease-out, filter 200ms ease-out',
2456
+ pointerEvents: 'none',
2457
+ // CSS var consumed by `.anet-topo-groupbox-live`
2458
+ // (line 877 of globals.css). React's CSSProperties
2459
+ // type doesn't model custom properties, so cast
2460
+ // through Record<string, string>.
2461
+ ...({['--march-dur']: `${marchDur}s`} as Record<string, string>),
2462
+ }}
2463
+ />
2464
+ {/* R63: wrap label in a clickable <g> with an invisible
2465
+ rect hitbox. The text alone wasn't getting hit-tested
2466
+ reliably — the SVG-wide topo-panel <rect> intercepts
2467
+ at the label's screen position when the label sits at
2468
+ a high viewBox-y (it lands below where the compositor
2469
+ expects the text to paint on top, same gotcha as the
2470
+ recent-signal panel rows in R56). Hitbox rect + the
2471
+ parent <g> taking the click + onPointerDown stop-
2472
+ propagation match the R55/R56/R61 pattern. */}
2473
+ <g
2474
+ role="button"
2475
+ tabIndex={0}
2476
+ aria-pressed={pinnedGroup === box.key}
2477
+ data-group-label-hit={box.key}
2478
+ style={{ pointerEvents: 'all', cursor: 'pointer' }}
2479
+ onPointerDown={(e) => e.stopPropagation()}
2480
+ onClick={() => setPinnedGroup(prev => prev === box.key ? null : box.key)}
2481
+ // R86: hover the label → transient group focus. Releases
2482
+ // on leave; activeGroup = hoveredGroup ?? pinnedGroup
2483
+ // formula already handles transient-over-pin so a
2484
+ // user can spot-compare teams without losing their
2485
+ // pinned one. Closes the same R83-style hover/click
2486
+ // gap (segments) — now group labels carry it too.
2487
+ onPointerEnter={() => setHoveredGroupLabel(box.key)}
2488
+ onPointerLeave={() => setHoveredGroupLabel(prev => prev === box.key ? null : prev)}
2489
+ // R152: a11y completeness — R63 added role + tabIndex +
2490
+ // aria-pressed but never wired onKeyDown, so the focused
2491
+ // group label was tab-reachable but Enter/Space was a
2492
+ // no-op. Closes the last keyboard gap among the
2493
+ // role="button" surfaces. Other group-pin trigger paths
2494
+ // (R69 palette, R74 cmdk, R86 hover, dispatchEvent) are
2495
+ // unchanged. Matches the onKeyDown idiom from R116 /
2496
+ // R139 / R140 / R151 (Enter & Space → same setter as
2497
+ // onClick, preventDefault on Space to stop SVG scroll).
2498
+ onKeyDown={(e) => {
2499
+ if (e.key === 'Enter' || e.key === ' ') {
2500
+ e.preventDefault();
2501
+ setPinnedGroup(prev => prev === box.key ? null : box.key);
2502
+ }
2503
+ }}
2504
+ >
2505
+ {/* R99: SVG <title> tooltip listing group members +
2506
+ status breakdown. Same info-density spirit as
2507
+ R97 pill tooltips + R98 node tooltips — anywhere
2508
+ a UI element says "alpha · 3" should hover-
2509
+ explain WHICH 3. Truncates at 8 aliases with a
2510
+ "+N more" suffix so a 20-member band doesn't
2511
+ paint a 22-line tooltip. */}
2512
+ {(() => {
2513
+ const members = Object.entries(groupKeys)
2514
+ .filter(([, key]) => key === box.key)
2515
+ .map(([alias]) => alias);
2516
+ const memberPreview = members.slice(0, 8).join(', ');
2517
+ const suffix = members.length > 8 ? ` + ${members.length - 8} more` : '';
2518
+ const statusSummary = [
2519
+ box.statuses.working > 0 ? `${box.statuses.working} working` : null,
2520
+ box.statuses.idle > 0 ? `${box.statuses.idle} idle` : null,
2521
+ box.statuses.offline > 0 ? `${box.statuses.offline} offline` : null,
2522
+ ].filter(Boolean).join(' · ');
2523
+ return (
2524
+ <title>{[
2525
+ `${box.key} (${members.length} member${members.length === 1 ? '' : 's'})`,
2526
+ statusSummary || null,
2527
+ `${memberPreview}${suffix}`,
2528
+ pinnedGroup === box.key ? 'click to release pin' : 'click to pin this group',
2529
+ ].filter(Boolean).join('\n')}</title>
2530
+ );
2531
+ })()}
2532
+ <rect
2533
+ x={box.x + 6}
2534
+ y={box.y + 2}
2535
+ width={Math.min(box.w - 12, 240)}
2536
+ height={20}
2537
+ rx="4"
2538
+ /* R107 / Loop: list-item tint extends to the SVG
2539
+ group labels — same idiom R104 added to recent-
2540
+ signal rows and R105 to the legend rows. The
2541
+ tint colour is pal.legendAccent (cyan) since
2542
+ groups don't carry an inherent swatch the way
2543
+ legend rows do; this matches R68's group-box
2544
+ isPinned/isHovered accent stroke for consistency.
2545
+ hover < pinned opacity so locked vs preview is
2546
+ discriminable at a glance. */
2547
+ fill={pinnedGroup === box.key || hoveredGroupLabel === box.key ? pal.legendAccent : 'transparent'}
2548
+ opacity={pinnedGroup === box.key ? (isLight ? 0.16 : 0.20)
2549
+ : hoveredGroupLabel === box.key ? (isLight ? 0.09 : 0.13)
2550
+ : 1}
2551
+ data-group-label-tinted={pinnedGroup === box.key ? 'pinned' : hoveredGroupLabel === box.key ? 'hover' : 'none'}
2552
+ style={{ transition: 'fill 150ms ease-out, opacity 150ms ease-out' }}
2553
+ />
2554
+ <text
2555
+ x={box.x + 12}
2556
+ y={box.y + 14}
2557
+ fill={isHovered ? pal.legendHeadline : pal.legendText}
2558
+ fontSize="13"
2559
+ fontFamily="monospace"
2560
+ fontWeight="700"
2561
+ style={{ transition: 'fill 200ms ease-out' }}
2562
+ data-group-label={box.key}
2563
+ >
2564
+ {box.key}
2565
+ {/* Round 19 / Loop: member-count chip. Inline tspan stays
2566
+ in the single <text> bbox the overlap test reads, so
2567
+ the node↔label guard still catches if the chip ever
2568
+ pushes the label far enough right to clip a node.
2569
+ Smaller + lighter weight reads as metadata, not name. */}
2570
+ <tspan dx="6" fill={pal.legendText} fontSize="11" fontWeight="400">· {box.count}</tspan>
2571
+ {/* Round 58 / Loop: status mix pip strip. Compact text-
2572
+ based chips (e.g. "2w 1i") so the strip stays inside
2573
+ the same <text> bbox the overlap-test reads — keeps
2574
+ the R27 label↔label and R19 node↔label guards intact.
2575
+ Each tier is colour-coded against the legend swatches
2576
+ and only renders when count > 0, so a healthy all-
2577
+ working group reads simply " · 2w". */}
2578
+ {box.statuses.working > 0 && (
2579
+ <tspan dx="8" fill={isLight ? '#059669' : '#22c55e'} fontSize="11" fontWeight="600">{box.statuses.working}w</tspan>
2580
+ )}
2581
+ {box.statuses.idle > 0 && (
2582
+ <tspan dx="4" fill={isLight ? '#0d9488' : '#2dd4bf'} fontSize="11" fontWeight="600">{box.statuses.idle}i</tspan>
2583
+ )}
2584
+ {box.statuses.offline > 0 && (
2585
+ <tspan dx="4" fill={isLight ? '#94a3b8' : '#6b7280'} fontSize="11" fontWeight="600">{box.statuses.offline}o</tspan>
2586
+ )}
2587
+ </text>
2588
+ </g>
2589
+ </g>
2590
+ );
2591
+ })}
1033
2592
 
1034
2593
  {/* directed message flows */}
1035
2594
  {flowLinks.map((link, index) => {
@@ -1047,17 +2606,111 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1047
2606
  const path = curvePath(from, to, lift);
1048
2607
  const width = Math.min(2 + link.count, 7);
1049
2608
  const duration = Math.max(0.9, 2.6 / Math.sqrt(link.count));
2609
+ // Round 10 / Loop: freshness fade. An edge that fired ≤30s ago
2610
+ // stays at full intensity; over 5 minutes it decays to ~35%.
2611
+ // Surfaces "what's happening now" vs background chatter without
2612
+ // hiding old flow entirely (some context still useful). `now`
2613
+ // captured at useMemo-recompute time (every 5s message refresh)
2614
+ // — accuracy is within the poll interval, plenty.
2615
+ const ageMs = link.last_at ? Math.max(0, Date.now() - Date.parse(link.last_at)) : 0;
2616
+ const fresh = Math.max(0.35, 1 - ageMs / (5 * 60 * 1000));
2617
+ // Round 16 arrow-tier binning — keep `topo-arrow` as the
2618
+ // medium tier id so the legend swatch picks it up unchanged.
2619
+ const arrowId = link.count <= 2 ? 'topo-arrow-s'
2620
+ : link.count <= 4 ? 'topo-arrow'
2621
+ : 'topo-arrow-l';
1050
2622
 
2623
+ // Round 39 / Loop: edge hover tooltip — surface the same
2624
+ // last_at + count info the freshness fade and arrow tier
2625
+ // already encode visually, in plain text. The stroke is the
2626
+ // hover target; SVG `<title>` honours newlines on every
2627
+ // browser the dashboard targets.
2628
+ const lastAt = relativeAgo(link.last_at);
2629
+ const tooltip = `${link.from} → ${link.to}\n${link.count} message${link.count === 1 ? '' : 's'}${lastAt ? ` · last ${lastAt}` : ''}`;
2630
+ // Round 40 / Loop: edges follow node hover — when an alias is
2631
+ // hovered, every edge touching it brightens, the rest fade.
2632
+ // Pairs with the Round 8 group-focus fade on nodes: hover a
2633
+ // node to find "who is this agent talking to" at a glance.
2634
+ // No hover → multiplier is 1.0 (current behaviour preserved).
2635
+ // Round 50 / Loop: edge-on-self priority. When the user hovers
2636
+ // a flow edge directly (R48 widened the hitbox so this is now
2637
+ // a precise gesture), THAT edge gets the strongest boost (2.0)
2638
+ // and every other edge dims to 0.35. Node-hover (R40) keeps
2639
+ // its own ladder. Edge-hover and node-hover are mutually
2640
+ // exclusive in practice — the cursor is over one or the
2641
+ // other — but the order below makes edge-hover win if both
2642
+ // ever read truthy at the same React tick.
2643
+ // Round 53 / Loop: in-group edges follow team focus. R40
2644
+ // brightened only edges touching the exact hovered alias —
2645
+ // but with R8/R18 prefix-clustering, a user hovering one
2646
+ // member is asking about the team. So when BOTH endpoints of
2647
+ // an edge share the hoveredGroup (and neither is the exact
2648
+ // hovered alias — that gets the stronger 1.7×), the edge
2649
+ // boosts to 1.3×. Edges leaving the team (one endpoint in,
2650
+ // one out) still dim to 0.35× per R40 since they read as
2651
+ // background to the focus. Singletons fall through unchanged
2652
+ // (their group key is the alias itself, so bothInHoveredGroup
2653
+ // is impossible for a non-self-edge).
2654
+ // R116: composes hover ?? pin — a pinned edge stays "hot" after the cursor leaves.
2655
+ const isHoveredEdge = activeEdgeKey === link.key;
2656
+ const fromGroup = groupKeys[link.from] ?? link.from;
2657
+ const toGroup = groupKeys[link.to] ?? link.to;
2658
+ const bothInHoveredGroup = !!activeGroup && fromGroup === activeGroup && toGroup === activeGroup;
2659
+ // R63: also gate "no filter" on activeGroup so pinnedGroup
2660
+ // alone activates the in-group 1.3× boost + non-group dim.
2661
+ // When neither a node nor a group is in focus, mul is 1.
2662
+ // R77: when the user hovers the "N active links" chip, the
2663
+ // baseline 1× becomes 1.5× — every flow brightens at once.
2664
+ // Sits inside the no-other-hover branch so it doesn't fight
2665
+ // the edge-hover (2.0×) or node-hover (1.7×) priorities.
2666
+ const edgeOpacityMul = isHoveredEdge
2667
+ ? 2.0
2668
+ : activeEdgeKey
2669
+ ? 0.35
2670
+ : !hoveredAlias && !activeGroup
2671
+ ? (hoveredActiveLinks ? 1.5 : 1)
2672
+ : (link.from === hoveredAlias || link.to === hoveredAlias)
2673
+ ? 1.7
2674
+ : bothInHoveredGroup
2675
+ ? 1.3
2676
+ : 0.35;
2677
+ // Round 50: the hovered edge also visibly thickens so the eye
2678
+ // tracks it even at low message counts (width was 3 → 4.5 on
2679
+ // hover). 1.4× is enough to read as "lifted" without
2680
+ // breaching the 16-px hitbox bound.
2681
+ const renderWidth = isHoveredEdge ? Math.min(width * 1.4, 10) : width;
1051
2682
  return (
1052
2683
  <g key={link.key}>
2684
+ {/* Round 48 / Loop: invisible hover hitbox — visible flow
2685
+ path is 3-7 px wide and damn hard to hover precisely.
2686
+ Stack a transparent 16-px-wide stroke behind it so the
2687
+ cursor only needs to be ~8 px from the line for the
2688
+ tooltip to fire. Native <title> moves here; the
2689
+ visible path no longer needs it. pointer-events on
2690
+ the visible path drop to "none" since the hitbox
2691
+ owns the hover surface. */}
2692
+ <path
2693
+ d={path}
2694
+ fill="none"
2695
+ stroke="transparent"
2696
+ strokeWidth={Math.max(width + 10, 16)}
2697
+ style={{ pointerEvents: 'stroke' }}
2698
+ data-edge-hitbox
2699
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
2700
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
2701
+ >
2702
+ <title>{tooltip}</title>
2703
+ </path>
1053
2704
  <path
1054
2705
  d={path}
1055
2706
  fill="none"
1056
2707
  stroke={pal.flowEdge}
1057
- strokeWidth={width}
1058
- opacity={isLight ? 0.22 : 0.28}
2708
+ strokeWidth={renderWidth}
2709
+ opacity={Math.min(1, (isLight ? 0.22 : 0.28) * fresh * edgeOpacityMul)}
1059
2710
  filter={isLight ? undefined : 'url(#topo-glow)'}
1060
- markerEnd="url(#topo-arrow)"
2711
+ markerEnd={`url(#${arrowId})`}
2712
+ className="transition-opacity duration-300"
2713
+ style={{ pointerEvents: 'none' }}
1061
2714
  />
1062
2715
  <path
1063
2716
  id={`flow-path-${index}`}
@@ -1066,45 +2719,344 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1066
2719
  stroke={pal.flowPath}
1067
2720
  strokeWidth="1"
1068
2721
  strokeDasharray="2 12"
1069
- opacity={isLight ? 0.4 : 0.75}
2722
+ opacity={Math.min(1, (isLight ? 0.4 : 0.75) * fresh * edgeOpacityMul)}
2723
+ className="transition-opacity duration-300"
1070
2724
  />
1071
- <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'}>
1072
- <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
1073
- </circle>
2725
+ {!reducedMotion && (
2726
+ /* Round 103 / Loop: phase-stagger the particles so
2727
+ concurrent edges don't pulse in lockstep. SMIL
2728
+ `begin` accepts negative offsets to shift the cycle
2729
+ backwards in time, which means the particle starts
2730
+ mid-flight on first paint — no visible "all
2731
+ particles spawn from source simultaneously" tell.
2732
+ `(index * 0.37) % duration` gives a deterministic,
2733
+ well-distributed offset (the golden-ratio-ish 0.37
2734
+ fraction prevents lining up when N is a small
2735
+ multiple). Edge order is stable (sorted by recent
2736
+ activity), so the offsets feel calm rather than
2737
+ reshuffling each refresh. */
2738
+ <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'} opacity={Math.min(1, fresh * edgeOpacityMul)}>
2739
+ <animateMotion
2740
+ dur={`${duration}s`}
2741
+ begin={`-${((index * 0.37) % duration).toFixed(3)}s`}
2742
+ repeatCount="indefinite"
2743
+ path={path}
2744
+ />
2745
+ </circle>
2746
+ )}
2747
+ {/* Round 75 / Loop: arrival ping at the destination. The
2748
+ particle currently fades into the arrow marker
2749
+ silently — adding a small radiating ring synchronised
2750
+ to the particle's period turns message delivery into
2751
+ a visible event. begin = -dur*0.92 offsets the
2752
+ animation so the ring expands NEAR the end of each
2753
+ cycle (≈when the particle arrives). Gated by
2754
+ reducedMotion and on fresh > 0.5 — stale edges that
2755
+ haven't fired in minutes don't need the eye-grab.
2756
+ data-arrival-ping for testability. */}
2757
+ {!reducedMotion && fresh > 0.5 && (
2758
+ <circle
2759
+ cx={to.x}
2760
+ cy={to.y}
2761
+ r="0"
2762
+ fill="none"
2763
+ stroke={pal.flowEdge}
2764
+ strokeWidth="1.5"
2765
+ opacity="0"
2766
+ style={{ pointerEvents: 'none' }}
2767
+ data-arrival-ping={link.key}
2768
+ >
2769
+ <animate attributeName="r" values="0;14;22" dur={`${duration}s`} begin={`-${(duration * 0.92).toFixed(2)}s`} repeatCount="indefinite" />
2770
+ <animate attributeName="opacity" values="0;0.55;0" dur={`${duration}s`} begin={`-${(duration * 0.92).toFixed(2)}s`} repeatCount="indefinite" />
2771
+ </circle>
2772
+ )}
2773
+ {/* Round 76 / Loop: source dispatch pulse — mirror to the
2774
+ R75 arrival ping. begin = 0s (start of cycle) so the
2775
+ ring expands as the particle LEAVES the source. Only
2776
+ fires for high-traffic edges (link.count >= 3) — on
2777
+ quiet conversations the canvas should stay calm; on
2778
+ busy senders the pulse plus arrival ping bookend
2779
+ every message in flight, making the topology feel
2780
+ alive. Same fresh/reducedMotion gates as R75. Slightly
2781
+ smaller radius (0→12→18 vs R75's 0→14→22) so the
2782
+ source reads as "smaller event than arrival" — the
2783
+ destination is the meaningful endpoint. */}
2784
+ {!reducedMotion && fresh > 0.5 && link.count >= 3 && (
2785
+ <circle
2786
+ cx={from.x}
2787
+ cy={from.y}
2788
+ r="0"
2789
+ fill="none"
2790
+ stroke={pal.flowEdge}
2791
+ strokeWidth="1.5"
2792
+ opacity="0"
2793
+ style={{ pointerEvents: 'none' }}
2794
+ data-dispatch-pulse={link.key}
2795
+ >
2796
+ <animate attributeName="r" values="0;12;18" dur={`${duration}s`} begin="0s" repeatCount="indefinite" />
2797
+ <animate attributeName="opacity" values="0;0.45;0" dur={`${duration}s`} begin="0s" repeatCount="indefinite" />
2798
+ </circle>
2799
+ )}
2800
+ {/* Round 100 / Loop: midpoint count badge for high-
2801
+ traffic edges. Width already gauges intensity but
2802
+ "5 vs 12" is hard to read off stroke alone. Render
2803
+ a compact pill at the bezier midpoint (t=0.5 →
2804
+ midpoint + perpendicular × lift/2) showing the
2805
+ integer count. Threshold link.count >= 3 keeps
2806
+ the canvas quiet for sparse traffic. Bezier
2807
+ midpoint math: for a quadratic curve M A Q C B,
2808
+ the t=0.5 point is (A + 2C + B) / 4, which
2809
+ simplifies to (midAB) + 0.5 × (control - midAB)
2810
+ = midAB + perpendicular × lift/2. */}
2811
+ {link.count >= 3 && (() => {
2812
+ const midX = (from.x + to.x) / 2;
2813
+ const midY = (from.y + to.y) / 2;
2814
+ const dx = to.x - from.x;
2815
+ const dy = to.y - from.y;
2816
+ const len = Math.hypot(dx, dy) || 1;
2817
+ const badgeX = midX + (-dy / len) * lift * 0.5;
2818
+ const badgeY = midY + ( dx / len) * lift * 0.5;
2819
+ const badgeOpacity = Math.min(1, fresh * edgeOpacityMul);
2820
+ /* R121: the badge becomes a canvas-side click-to-pin
2821
+ affordance. R100 introduced it as a passive count
2822
+ display; R116 added pinnedEdgeKey. Joining them
2823
+ lets users pin a flow directly from the canvas
2824
+ without crossing to the recent-signal panel.
2825
+ pointerEvents move from 'none' to 'all' on the
2826
+ wrapper <g>; the underlying R48 edge hitbox is
2827
+ wider (16 px) than the 18-px badge diameter so
2828
+ clicks landing on either still route correctly
2829
+ — the badge consumes its small footprint, the
2830
+ hitbox owns the rest. stopPropagation on
2831
+ pointerdown so the SVG pan capture doesn't
2832
+ redirect the click. */
2833
+ const isPinned = pinnedEdgeKey === link.key;
2834
+ // R126: hot-edge accent. The edge stroke width (line
2835
+ // 2344) already scales with count but clamps at 7 —
2836
+ // so count=5 and count=50 look identical at the line,
2837
+ // and the only signal differentiating them is the
2838
+ // integer in the badge. Bucket count into a "hot"
2839
+ // band (≥ 10) and flip the badge stroke to a warmer
2840
+ // tone + thicker ring so busy lanes telegraph at a
2841
+ // glance without reading the digit. Reuses the amber
2842
+ // family from R125 (chip empty-state) — semantic is
2843
+ // "draw the eye here"; R125 amber is in the chip row
2844
+ // above the SVG, R126 amber is on the canvas, so the
2845
+ // surfaces never compete in the same eye-sweep. Pin
2846
+ // still wins (uses legendHeadline) so a pinned hot
2847
+ // edge reads as "locked + busy" via the badge text
2848
+ // and edge brightness, not via a third stroke colour.
2849
+ const isHot = link.count >= 10;
2850
+ const hotStroke = isLight ? '#d97706' : '#fbbf24';
2851
+ return (
2852
+ <g
2853
+ data-edge-count-badge={link.key}
2854
+ data-edge-count-badge-pinned={isPinned ? 'true' : 'false'}
2855
+ data-edge-count-badge-hot={isHot ? 'true' : 'false'}
2856
+ role="button"
2857
+ tabIndex={0}
2858
+ aria-pressed={isPinned}
2859
+ style={{ pointerEvents: 'all', cursor: 'pointer' }}
2860
+ opacity={badgeOpacity}
2861
+ onPointerDown={(e) => e.stopPropagation()}
2862
+ // R122: badge hover propagates to hoveredEdgeKey so
2863
+ // moving the cursor onto the badge lights the
2864
+ // same endpoint rings + edge brighten as hovering
2865
+ // the line. R121 only wired click; the badge sat
2866
+ // visually separate from the line on hover,
2867
+ // which felt like two surfaces rather than one.
2868
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
2869
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
2870
+ onClick={(e) => {
2871
+ e.stopPropagation();
2872
+ setPinnedEdgeKey(prev => prev === link.key ? null : link.key);
2873
+ }}
2874
+ onKeyDown={(e) => {
2875
+ if (e.key === 'Enter' || e.key === ' ') {
2876
+ e.preventDefault();
2877
+ setPinnedEdgeKey(prev => prev === link.key ? null : link.key);
2878
+ }
2879
+ }}
2880
+ >
2881
+ <title>{isPinned
2882
+ ? `${link.from} → ${link.to} (${link.count}) — click to release pin`
2883
+ : `${link.from} → ${link.to} (${link.count}) — click to pin`}</title>
2884
+ <circle
2885
+ cx={badgeX} cy={badgeY} r="9"
2886
+ fill={pal.legendBox.fill}
2887
+ stroke={isPinned ? pal.legendHeadline : isHot ? hotStroke : pal.flowEdge}
2888
+ strokeWidth={isPinned ? 2 : isHot ? 2 : 1}
2889
+ opacity={isLight ? 0.95 : 0.82}
2890
+ />
2891
+ <text
2892
+ x={badgeX} y={badgeY + 3}
2893
+ textAnchor="middle"
2894
+ fill={pal.legendHeadline}
2895
+ fontSize="10"
2896
+ fontFamily="monospace"
2897
+ fontWeight="700"
2898
+ style={{ pointerEvents: 'none' }}
2899
+ >{link.count}</text>
2900
+ </g>
2901
+ );
2902
+ })()}
1074
2903
  </g>
1075
2904
  );
1076
2905
  })}
1077
2906
 
1078
- {/* center hub — round 39: enlarged from r=6 (12px) to r=10 (20px)
1079
- core so the "control plane" reads as the network's anchor,
1080
- not a stray particle. Static halo ring at r=18 grounds it
1081
- visually; two outward pulses keep the "signal source" idea
1082
- from r17. Light theme gets a paler core to avoid hot-spotting
1083
- the bg. */}
1084
- {layout === 'ring' && (<g>
1085
- {/* static grounding halo sits underneath, low opacity */}
1086
- <circle
1087
- cx={cx} cy={cy} r="18"
1088
- fill={isLight ? '#d1fae5' : '#10b981'}
1089
- opacity={isLight ? 0.35 : 0.10}
1090
- />
1091
- {/* outer pulse 1 */}
1092
- <circle cx={cx} cy={cy} r="10" fill="none" stroke="#10b981" strokeWidth="1.5">
1093
- <animate attributeName="r" values="10;38;10" dur="2.4s" repeatCount="indefinite" />
1094
- <animate attributeName="opacity" values="0.55;0;0.55" dur="2.4s" repeatCount="indefinite" />
1095
- </circle>
1096
- {/* outer pulse 2 (delayed 1.2s) */}
1097
- <circle cx={cx} cy={cy} r="10" fill="none" stroke="#10b981" strokeWidth="1.5" opacity="0">
1098
- <animate attributeName="r" values="10;38;10" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
1099
- <animate attributeName="opacity" values="0.45;0;0.45" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
1100
- </circle>
2907
+ {/* center hub — round 39 sized + round 13 restraint.
2908
+ The hub is the control-plane anchor. r39 gave it two outward
2909
+ pulses (r 10→38, 2.4s, double-phase) which read as "loudest
2910
+ node in the room" wrong semantics: anchors don't emit,
2911
+ they hold. r13 swaps the kinetic pulse for a slow opacity
2912
+ breath on the static halo (4s, ±15% from base), so the hub
2913
+ stays alive without throwing kinetic energy outward. The
2914
+ dual-circle "lit lamp" core is unchanged. */}
2915
+ {layout === 'ring' && (<g
2916
+ data-topo-hub
2917
+ data-topo-hub-hovered={hoveredHub ? 'true' : 'false'}
2918
+ style={{ cursor: 'pointer' }}
2919
+ // Stop pointerdown from reaching the SVG pan handler — same
2920
+ // reason as the node <g>: a captured pointer makes Chromium
2921
+ // fire the follow-up click on the SVG, not this group.
2922
+ onPointerDown={(e) => e.stopPropagation()}
2923
+ onMouseEnter={() => setHoveredHub(true)}
2924
+ onMouseLeave={() => setHoveredHub(false)}
2925
+ // Round 52 / Loop: hub is the visual anchor but had no click
2926
+ // action users discovered fit-to-content only via the `f`
2927
+ // key or the chrome button. Wire the hub to fitView() so the
2928
+ // most prominent element in the canvas is also the "re-
2929
+ // center" affordance. A click-ripple keyed by ts confirms
2930
+ // the gesture; the hub <title> now ends with a hint.
2931
+ onClick={() => {
2932
+ fitView();
2933
+ setClickRipple({ ts: Date.now(), x: cx, y: cy, r0: 18, color: isLight ? '#059669' : '#10b981' });
2934
+ setTimeout(() => setClickRipple(prev => prev && prev.x === cx && prev.y === cy ? null : prev), 600);
2935
+ }}
2936
+ >
2937
+ {/* Round 43 / Loop: hub `<title>` summary — hovering the
2938
+ central glow now answers "what is this?" with a one-line
2939
+ fleet snapshot. Duplicates the header chips by design;
2940
+ hovering the most visually prominent element is the
2941
+ most natural impulse, so satisfy it where the cursor
2942
+ already is. Falls clean: omits sub-clauses when their
2943
+ counts are zero. R52 appends a "click to fit view" hint.
2944
+ */}
2945
+ <title>{(() => {
2946
+ const total = sessions.length;
2947
+ const parts = [`Network hub`, `${total} session${total === 1 ? '' : 's'}`];
2948
+ if (onlineNodes.length > 0) parts.push(`${onlineNodes.length} online`);
2949
+ if (workingCount > 0) parts.push(`${workingCount} working`);
2950
+ if (flowLinks.length > 0) parts.push(`${flowLinks.length} active link${flowLinks.length === 1 ? '' : 's'}`);
2951
+ return parts.join(' · ') + '\nclick to fit view';
2952
+ })()}</title>
2953
+ {/* grounding halo — breathes in opacity, no expansion.
2954
+ R84: breath amplitude + tempo reflect workingCount. An
2955
+ idle fleet keeps the original gentle 0.32→0.52 (light) /
2956
+ 0.08→0.16 (dark) cycle over 4s — "heart at rest". As
2957
+ working sessions accumulate, the peak climbs and the
2958
+ period shortens, capped at 2.4s so it never feels
2959
+ manic. Quiet fleets see zero change; busy fleets feel
2960
+ the canvas working. */}
2961
+ {(() => {
2962
+ // Bucket workingCount to keep the visual feedback discrete
2963
+ // rather than continuous: 0 / 1-2 / 3-5 / 6+. Three buckets
2964
+ // are enough — finer gradations are imperceptible.
2965
+ const busy = workingCount === 0 ? 0
2966
+ : workingCount <= 2 ? 1
2967
+ : workingCount <= 5 ? 2
2968
+ : 3;
2969
+ const peakLight = [0.52, 0.58, 0.65, 0.72][busy];
2970
+ const peakDark = [0.16, 0.20, 0.26, 0.32][busy];
2971
+ const troughLight = 0.32;
2972
+ const troughDark = 0.08;
2973
+ const dur = [4.0, 3.2, 2.7, 2.4][busy];
2974
+ const valuesLight = `${troughLight};${peakLight};${troughLight}`;
2975
+ const valuesDark = `${troughDark};${peakDark};${troughDark}`;
2976
+ return (
2977
+ <circle
2978
+ cx={cx} cy={cy} r="18"
2979
+ fill={isLight ? '#d1fae5' : '#10b981'}
2980
+ opacity={isLight ? 0.42 : 0.12}
2981
+ data-hub-busyness={busy}
2982
+ >
2983
+ {!reducedMotion && (
2984
+ <animate
2985
+ attributeName="opacity"
2986
+ values={isLight ? valuesLight : valuesDark}
2987
+ dur={`${dur}s`}
2988
+ repeatCount="indefinite"
2989
+ />
2990
+ )}
2991
+ </circle>
2992
+ );
2993
+ })()}
1101
2994
  {/* core — 20px diameter, larger inner highlight reads as a "lit lamp" */}
1102
2995
  <circle cx={cx} cy={cy} r="10" fill={isLight ? '#059669' : '#10b981'} />
1103
- <circle cx={cx} cy={cy} r="5" fill="#d1fae5" opacity="0.9" />
2996
+ {/* R130 / Loop: when workingCount > 0, the decorative inner
2997
+ highlight gets replaced with the workingCount digit. The
2998
+ R84 busyness breath already encodes the same metric
2999
+ through motion — adding the digit gives it a second
3000
+ visual channel right at the canvas's focal point. A
3001
+ user glancing at the hub now sees both "the network is
3002
+ pulsing" (motion) AND "3 agents are working" (digit)
3003
+ without having to scan the chip row or panels.
3004
+
3005
+ Geometry: text at (cx, cy) with fontSize 11 monospace
3006
+ + fontWeight 700 sits inside the r=10 core (a 2-digit
3007
+ 12 reads ~12 px wide × 11 px tall, well inside the
3008
+ 20-px diameter core). Centered vertically via dy=
3009
+ "0.36em" — the standard SVG trick for text-vertical-
3010
+ center without measuring fontMetrics.
3011
+
3012
+ pointerEvents:none so the digit can't intercept the
3013
+ hub click (R52 fit-to-view still fires).
3014
+
3015
+ workingCount=0 falls through to the existing
3016
+ decorative highlight so the hub never looks empty. */}
3017
+ {workingCount > 0 ? (
3018
+ <text
3019
+ x={cx} y={cy}
3020
+ textAnchor="middle"
3021
+ dy="0.36em"
3022
+ fill={isLight ? '#d1fae5' : '#ecfdf5'}
3023
+ fontSize="11"
3024
+ fontFamily="monospace"
3025
+ fontWeight="700"
3026
+ style={{ pointerEvents: 'none' }}
3027
+ data-topo-hub-working-count={workingCount}
3028
+ >
3029
+ {workingCount}
3030
+ </text>
3031
+ ) : (
3032
+ <circle
3033
+ cx={cx} cy={cy} r="5"
3034
+ fill="#d1fae5"
3035
+ opacity="0.9"
3036
+ data-topo-hub-highlight
3037
+ />
3038
+ )}
3039
+ {/* R115 / Loop: hover hint ring. Stroke-only circle at r=14
3040
+ that fades in when the hub is hovered — the same idea
3041
+ R44 used for node avatars (group-hover stroke). r=14
3042
+ sits comfortably outside the r=10 core and INSIDE the
3043
+ r=18 grounding halo, so the hover indicator is fully
3044
+ contained within the existing hub footprint (no bbox
3045
+ growth, overlap test unchanged). pointerEvents:none so
3046
+ the hint can't intercept the click that produced it. */}
3047
+ <circle
3048
+ cx={cx} cy={cy} r="14"
3049
+ fill="none"
3050
+ stroke={isLight ? '#059669' : '#10b981'}
3051
+ strokeWidth="1.5"
3052
+ opacity={hoveredHub ? (isLight ? 0.85 : 0.7) : 0}
3053
+ data-topo-hub-hover-ring
3054
+ style={{ pointerEvents: 'none', transition: 'opacity 180ms ease-out' }}
3055
+ />
1104
3056
  </g>)}
1105
3057
 
1106
3058
  {/* agent nodes */}
1107
- {[...onlineNodes, ...offlineNodes].map(session => {
3059
+ {[...onlineNodes, ...offlineNodes].map((session, nodeIdx) => {
1108
3060
  const pos = nodePositions[session.alias];
1109
3061
  if (!pos) return null;
1110
3062
 
@@ -1127,12 +3079,54 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1127
3079
  // Round 8: when a node in another group is hovered, fade this
1128
3080
  // one. Same-group nodes (incl. singletons matching the hovered
1129
3081
  // alias) stay full. Pure visual focus, geometry unchanged.
1130
- const inFocus = !hoveredGroup || (groupKeys[session.alias] ?? session.alias) === hoveredGroup;
3082
+ const inFocus = !activeGroup || (groupKeys[session.alias] ?? session.alias) === activeGroup;
3083
+ // R72: in ring layout, classify the node into a tier by its
3084
+ // distance from hub centre so the first-paint stagger can
3085
+ // emanate inner → outer instead of running clockwise. Grid
3086
+ // layout doesn't have a radial structure; it keeps R9's pure
3087
+ // nodeIdx stagger. Thresholds bracket the actual tier radii
3088
+ // (single 220, dual 175/260, triple 145/215/285, offline 325+).
3089
+ let tierIdx = 0;
3090
+ if (layout === 'ring') {
3091
+ const p = nodePositions[session.alias];
3092
+ if (p) {
3093
+ const d = Math.hypot(p.x - cx, p.y - cy);
3094
+ tierIdx = d < 195 ? 0 : d < 270 ? 1 : d < 310 ? 2 : 3;
3095
+ }
3096
+ }
1131
3097
 
1132
3098
  return (
1133
3099
  <g
1134
3100
  key={session.alias}
1135
3101
  data-node={session.alias}
3102
+ data-tier-idx={layout === 'ring' ? tierIdx : -1}
3103
+ // R151 / Loop: node a11y compliance — match the pattern
3104
+ // R116 / R139 / R140 / R148 / R149 applied to other
3105
+ // interactive surfaces. Node <g> has been clickable for
3106
+ // chat since R45 but tab-unreachable + screen-reader-
3107
+ // unannounced. role="button" + tabIndex=0 + aria-label
3108
+ // expose it; aria-pressed reflects chat-target state so
3109
+ // SR users know which node currently has the popover
3110
+ // open. onKeyDown for Enter / Space fires the same
3111
+ // setChatAlias path as onClick. preventDefault on
3112
+ // Space stops the SVG canvas from scrolling.
3113
+ role="button"
3114
+ tabIndex={0}
3115
+ aria-pressed={chatAlias === session.alias}
3116
+ aria-label={`Chat with ${session.alias} (${session.status})`}
3117
+ onKeyDown={(e) => {
3118
+ if (e.key === 'Enter' || e.key === ' ') {
3119
+ e.preventDefault();
3120
+ setChatAlias(session.alias);
3121
+ setClickRipple({
3122
+ ts: Date.now(),
3123
+ x: pos.x, y: pos.y, r0: radius,
3124
+ color: status.primary,
3125
+ });
3126
+ setTimeout(() => setClickRipple(prev =>
3127
+ prev && Date.now() - prev.ts >= 590 ? null : prev), 600);
3128
+ }
3129
+ }}
1136
3130
  // Round 3 / Loop: `anet-fade-in` runs once when the <g>
1137
3131
  // mounts — a new session entering the fleet (or the topology
1138
3132
  // first rendering) eases in instead of popping. Re-renders of
@@ -1140,7 +3134,87 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1140
3134
  // via the alias key), so status changes don't flicker. The
1141
3135
  // global prefers-reduced-motion sweep already neutralises it.
1142
3136
  className="group transition-opacity anet-fade-in"
1143
- style={{ cursor: 'pointer', opacity: inFocus ? 1 : 0.32 }}
3137
+ style={{
3138
+ cursor: 'pointer',
3139
+ // Round 17 / Loop: offline nodes drop to 0.6 at rest so
3140
+ // online nodes pop without losing the offline-as-ghost
3141
+ // information. The dashed stroke + smaller radius already
3142
+ // say "offline"; dimming the whole group strengthens the
3143
+ // online-vs-offline hierarchy at first glance. Exempt the
3144
+ // chat-focused node — if the user explicitly opened a
3145
+ // popover targeting an offline alias, that node stays
3146
+ // full-brightness so the focus ring + popover read as one
3147
+ // selected thing rather than a dimmed selection. Group-
3148
+ // hover fade (Round 8) still wins when inFocus is false.
3149
+ // Round 49 / Loop: edge-hover endpoint highlight composes
3150
+ // OVER the inFocus/online formula — a non-endpoint node
3151
+ // dims to 0.28 (just below the inFocus 0.32 to read as a
3152
+ // stronger "not relevant" signal), endpoints keep their
3153
+ // base opacity. chatAlias still wins to keep the focus
3154
+ // ring legible if the user clicked through.
3155
+ // Round 55 / Loop: legend-status hover composes on the
3156
+ // SAME level as edge-hover-endpoint. Non-matching nodes
3157
+ // dim to 0.28; matching nodes stay at their base.
3158
+ // activeStatus matches: working = status==='working',
3159
+ // idle = online but not working, offline = !isOnline.
3160
+ // Round 60 / Loop: activeStatus = hoveredStatus ?? pinnedStatus
3161
+ // so the pressure-bar segment pins (R60) and the legend
3162
+ // row hover (R55) feed the same branch.
3163
+ // Round 80 / Loop: vendor-letter hover composes ABOVE the
3164
+ // activeStatus branch so hovering the vendor chip's `C`
3165
+ // dims everything except C-vendor nodes. Same dim value
3166
+ // (0.28) as edge-endpoint and status filters — visually
3167
+ // consistent. Vendor lookup uses the same vendorForModel
3168
+ // helper the avatar render uses, keyed by initial so the
3169
+ // chip and the avatar always agree on grouping.
3170
+ opacity: hoveredEdgeEndpoints && !hoveredEdgeEndpoints.has(session.alias) && chatAlias !== session.alias
3171
+ ? 0.28
3172
+ : activeVendor && chatAlias !== session.alias && (() => {
3173
+ const v = vendorForModel(session.model);
3174
+ const initial = v.id === 'unknown' ? '?' : v.initial;
3175
+ return initial !== activeVendor;
3176
+ })()
3177
+ ? 0.28
3178
+ : activeStatus && chatAlias !== session.alias && !(
3179
+ activeStatus === 'working' ? session.status === 'working'
3180
+ : activeStatus === 'idle' ? (isOnline && session.status !== 'working')
3181
+ : /* offline */ !isOnline
3182
+ )
3183
+ ? 0.28
3184
+ : !inFocus
3185
+ ? 0.32
3186
+ : chatAlias === session.alias
3187
+ ? 1
3188
+ : isOnline ? 1 : 0.6,
3189
+ // Round 9 / Loop: stagger the anet-fade-in so the topology
3190
+ // reveals as a wave on first paint instead of one big pop.
3191
+ // Cap at 24 indices (≈600ms tail) so 50-node fleets still
3192
+ // finish revealing within a beat. CSS animation-delay only
3193
+ // applies during the keyframe — re-renders without a new
3194
+ // mount (same alias key) don't replay, so status changes
3195
+ // never trigger the stagger again.
3196
+ // Round 72 / Loop: in ring layout, stagger by tier radius
3197
+ // so the topology emanates from the hub outward — inner
3198
+ // tier at 0ms, middle at ~180ms, outer at ~360ms, offline
3199
+ // at ~540ms. A small within-tier offset (nodeIdx % 6 * 25)
3200
+ // adds variety so each ring rotates in instead of all-at-
3201
+ // once popping. Grid keeps R9's pure nodeIdx stagger.
3202
+ animationDelay: layout === 'ring'
3203
+ ? `${tierIdx * 180 + (nodeIdx % 6) * 25}ms`
3204
+ : `${Math.min(nodeIdx, 24) * 25}ms`,
3205
+ // Round 51 / Loop: hover micro-lift. The label already
3206
+ // lifts on group-hover (R26). The node body — circle,
3207
+ // avatar, status ring — stays planted, so the gesture
3208
+ // reads "label moves, body doesn't". Now the whole <g>
3209
+ // translates -2px when hovered: avatar + ring + label
3210
+ // move as one unit. 2 px is well inside the inter-row
3211
+ // gap (cellH headroom ≥22), and CSS transform on SVG
3212
+ // <g> never affects the overlap-test geometry (the
3213
+ // test never simulates hover). useReducedMotion drops
3214
+ // the lift to 0 for prefers-reduced-motion users.
3215
+ transform: !reducedMotion && hoveredAlias === session.alias ? 'translateY(-2px)' : undefined,
3216
+ transition: 'transform 180ms cubic-bezier(0.4,0,0.2,1)',
3217
+ }}
1144
3218
  // Stop the pointerdown from reaching the SVG pan handler: the
1145
3219
  // SVG calls setPointerCapture, and a captured pointer makes
1146
3220
  // Chromium fire the follow-up `click` on the SVG instead of
@@ -1152,12 +3226,97 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1152
3226
  // Round 8 group-focus fade works at any fleet size.
1153
3227
  onPointerEnter={() => setHoveredAlias(session.alias)}
1154
3228
  onPointerLeave={() => setHoveredAlias(prev => (prev === session.alias ? null : prev))}
1155
- onClick={() => setChatAlias(session.alias)}
3229
+ onClick={() => {
3230
+ setChatAlias(session.alias);
3231
+ // Round 14 ripple — capture position, radius and status
3232
+ // colour at click time so the one-shot circle is self-
3233
+ // contained (no re-render-time recomputation needed).
3234
+ setClickRipple({
3235
+ ts: Date.now(),
3236
+ x: pos.x, y: pos.y, r0: radius,
3237
+ color: status.primary,
3238
+ });
3239
+ setTimeout(() => setClickRipple(prev =>
3240
+ prev && Date.now() - prev.ts >= 590 ? null : prev), 600);
3241
+ }}
1156
3242
  >
1157
3243
  {/* Issue #96: native hover tooltip — "Vendor · model · Runtime".
1158
3244
  Falls back to just the alias when the node reports no
1159
- model/runtime. */}
1160
- <title>{identityLine(session.model, session.runtime) || session.alias}</title>
3245
+ model/runtime.
3246
+ Round 33 / Loop: cwd line answers "what is this agent on?".
3247
+ Round 34 / Loop: for offline nodes, append "last seen: 6m ago"
3248
+ so the operator knows whether to wait or chase. Online nodes
3249
+ skip the line (Round 27's 1 h ghost age-out means anything
3250
+ still online has heartbeated recently — the line would just
3251
+ be noise). Accepts both ISO ("…T06:00:28Z") and SQL-style
3252
+ ("… 06:00:28" assumed UTC) formats. */}
3253
+ {(() => {
3254
+ // Round 35 / Loop: TZ-safe parsing via the shared lib helper
3255
+ // — same parseHubTime is used by isGhost above (Round 38
3256
+ // factored it to app/lib/time.ts so both paths interpret SQL
3257
+ // bare timestamps as UTC on every browser).
3258
+ const lastSeen = !isOnline && session.last_seen_at ? relativeAgo(session.last_seen_at) : null;
3259
+ // Round 98 / Loop: enrich the node tooltip with status,
3260
+ // group membership, and inbound/outbound flow counts —
3261
+ // same info-density spirit as R97 active-filter pill
3262
+ // tooltips. Hovering any node now answers "what is this
3263
+ // and how does it sit in the topology" without forcing
3264
+ // a click into the chat popover. Group line only shows
3265
+ // when the alias is part of a multi-member band (R106
3266
+ // prefix grouping); singletons skip it. Flow line only
3267
+ // when at least one direction has count > 0.
3268
+ const groupKey = groupKeys[session.alias];
3269
+ const groupMembers = groupKey
3270
+ ? Object.values(groupKeys).filter(k => k === groupKey).length
3271
+ : 1;
3272
+ const groupLine = groupMembers > 1 ? `group: ${groupKey} · ${groupMembers}` : null;
3273
+ // R147 / Loop: node tooltip extends R98's flow summary
3274
+ // with the actual sender / receiver aliases. R98 told you
3275
+ // "12 in / 5 out" but not WHO. The R97 idiom — anywhere
3276
+ // the UI shows "N" should hover-explain WHICH N — applied
3277
+ // to filter pills, group labels, vendor letters, pressure
3278
+ // segments, recent-row text, active-links chip; the node
3279
+ // title was the last surface still showing only the
3280
+ // aggregate. Direction-tagged: senders are who's
3281
+ // messaging THIS node (inbound), receivers are who this
3282
+ // node is messaging (outbound). 6-truncate + "+N more"
3283
+ // matches the pattern other tooltips use so a 30-flow
3284
+ // node doesn't paint a 30-line tooltip.
3285
+ let flowIn = 0, flowOut = 0;
3286
+ const sendersMap = new Map<string, number>(); // alias → count, inbound
3287
+ const receiversMap = new Map<string, number>(); // alias → count, outbound
3288
+ for (const fl of flowLinks) {
3289
+ if (fl.from === session.alias) {
3290
+ flowOut += fl.count;
3291
+ receiversMap.set(fl.to, (receiversMap.get(fl.to) ?? 0) + fl.count);
3292
+ }
3293
+ if (fl.to === session.alias) {
3294
+ flowIn += fl.count;
3295
+ sendersMap.set(fl.from, (sendersMap.get(fl.from) ?? 0) + fl.count);
3296
+ }
3297
+ }
3298
+ const fmtPeers = (m: Map<string, number>) => {
3299
+ const pairs = [...m.entries()].sort((a, b) => b[1] - a[1]);
3300
+ const preview = pairs.slice(0, 6).map(([a, n]) => `${a} (${n})`).join(', ');
3301
+ const suffix = pairs.length > 6 ? ` + ${pairs.length - 6} more` : '';
3302
+ return preview + suffix;
3303
+ };
3304
+ const flowLine = (flowIn + flowOut) > 0 ? `flows: ${flowIn} in / ${flowOut} out` : null;
3305
+ const sendersLine = sendersMap.size > 0 ? `← from: ${fmtPeers(sendersMap)}` : null;
3306
+ const receiversLine = receiversMap.size > 0 ? `→ to: ${fmtPeers(receiversMap)}` : null;
3307
+ return (
3308
+ <title>{[
3309
+ `${session.alias} · ${session.status}`,
3310
+ identityLine(session.model, session.runtime),
3311
+ groupLine,
3312
+ session.project_dir ? `cwd: ${session.project_dir}` : null,
3313
+ lastSeen ? `last seen: ${lastSeen}` : null,
3314
+ flowLine,
3315
+ sendersLine,
3316
+ receiversLine,
3317
+ ].filter(Boolean).join('\n')}</title>
3318
+ );
3319
+ })()}
1161
3320
  {/* Round 2 / Loop: hover ring — a thin outer stroke that fades
1162
3321
  in when the cursor enters the node, signalling clickability
1163
3322
  (real-user feedback for the chat-popover open). Pure CSS via
@@ -1177,7 +3336,47 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1177
3336
  className="opacity-0 group-hover:opacity-70 transition-opacity duration-150"
1178
3337
  style={{ pointerEvents: 'none' }}
1179
3338
  />
1180
- {isActive && (
3339
+ {/* Round 11 / Loop: chat-focus ring — when the ChatPopover is
3340
+ open targeting this node, anchor a persistent ring around
3341
+ it so the floating popover visibly links back to its source
3342
+ node. Static (not pulsing) so it reads as "selected state"
3343
+ rather than "this node is active". strokeWidth=2.5 stays
3344
+ clear of the overlap-test selectors (1.5 / 3). Sits just
3345
+ outside the halo radius+8 so it never overlaps a neighbour
3346
+ (halos already pack flush in dense grids). */}
3347
+ {chatAlias === session.alias && (
3348
+ /* R51 chat-target ring. R120 / Loop: gentle SMIL
3349
+ breath on the ring's opacity (±0.1 over 3s) when
3350
+ chat is open + !reducedMotion. Says "active session
3351
+ here" continuously without animation noise — the
3352
+ ring only appears for one node at a time (the
3353
+ chatAlias), so it never competes with R84 hub
3354
+ busyness or R112 working halo for attention. */
3355
+ <circle
3356
+ cx={pos.x}
3357
+ cy={pos.y}
3358
+ r={radius + 14}
3359
+ fill="none"
3360
+ stroke={status.primary}
3361
+ strokeWidth="2.5"
3362
+ opacity={isLight ? 0.85 : 0.95}
3363
+ filter={!isLight ? 'url(#topo-glow)' : undefined}
3364
+ className="transition-opacity duration-200"
3365
+ style={{ pointerEvents: 'none' }}
3366
+ data-chat-target-ring
3367
+ data-chat-target-breath={!reducedMotion ? 'on' : 'off'}
3368
+ >
3369
+ {!reducedMotion && (
3370
+ <animate
3371
+ attributeName="opacity"
3372
+ values={isLight ? '0.72;0.95;0.72' : '0.82;1;0.82'}
3373
+ dur="3s"
3374
+ repeatCount="indefinite"
3375
+ />
3376
+ )}
3377
+ </circle>
3378
+ )}
3379
+ {isActive && !reducedMotion && (
1181
3380
  <circle cx={pos.x} cy={pos.y} r={radius + 14} fill={status.primary} opacity={isLight ? 0.08 : 0.12}>
1182
3381
  <animate attributeName="r" values={`${radius + 8};${radius + 22};${radius + 8}`} dur="2.4s" repeatCount="indefinite" />
1183
3382
  <animate attributeName="opacity" values={isLight ? '0.12;0.02;0.12' : '0.18;0.04;0.18'} dur="2.4s" repeatCount="indefinite" />
@@ -1185,7 +3384,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1185
3384
  )}
1186
3385
  {/* Round 4 / Loop: transition-[fill,stroke,opacity] smooths
1187
3386
  status colour changes so idle↔working↔offline doesn't snap
1188
- — task replies / node-rename / SSE updates ease in. */}
3387
+ — task replies / node-rename / SSE updates ease in.
3388
+ Round 112 / Loop: working nodes get a subtle halo breath
3389
+ (±0.12 opacity at 3s cycle) so the eye can find "what's
3390
+ busy" at a glance without scanning chips. Idle + offline
3391
+ halos stay flat — they don't need to demand attention.
3392
+ R84 hub-center breath stays the loudest "fleet busyness"
3393
+ signal; this one is quieter, per-node. SMIL `<animate>`
3394
+ inside the circle, gated by reducedMotion. */}
1189
3395
  <circle
1190
3396
  cx={pos.x}
1191
3397
  cy={pos.y}
@@ -1193,7 +3399,39 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1193
3399
  fill={status.halo}
1194
3400
  opacity={isOnline ? (isLight ? 0.85 : 0.65) : (isLight ? 0.4 : 0.25)}
1195
3401
  className="transition-[fill,opacity] duration-300 ease-out"
1196
- />
3402
+ data-node-halo-breath={!reducedMotion && session.status === 'working' ? 'on' : 'off'}
3403
+ >
3404
+ {!reducedMotion && session.status === 'working' && (
3405
+ <animate
3406
+ attributeName="opacity"
3407
+ values={isLight ? '0.73;0.92;0.73' : '0.53;0.78;0.53'}
3408
+ dur="3s"
3409
+ repeatCount="indefinite"
3410
+ />
3411
+ )}
3412
+ </circle>
3413
+ {/* Round 111 / Loop: edge-endpoint emphasis ring. R49
3414
+ already keeps endpoint nodes at opacity 1 while
3415
+ others dim when an edge is hovered, but the
3416
+ endpoints had no POSITIVE indicator — they just
3417
+ "stayed bright". An accent stroke at r=radius+7
3418
+ (just inside the halo's r=radius+8 bbox so we
3419
+ don't grow the overlap footprint) clearly says
3420
+ "these are the two participants in this flow".
3421
+ pointerEvents:none so the node hitbox stays alive. */}
3422
+ {hoveredEdgeEndpoints && hoveredEdgeEndpoints.has(session.alias) && (
3423
+ <circle
3424
+ cx={pos.x}
3425
+ cy={pos.y}
3426
+ r={radius + 7}
3427
+ fill="none"
3428
+ stroke={pal.flowEdge}
3429
+ strokeWidth={1.5}
3430
+ opacity={isLight ? 0.9 : 0.85}
3431
+ data-edge-endpoint-ring
3432
+ style={{ pointerEvents: 'none', transition: 'opacity 180ms ease-out' }}
3433
+ />
3434
+ )}
1197
3435
  <circle
1198
3436
  cx={pos.x}
1199
3437
  cy={pos.y}
@@ -1290,52 +3528,115 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1290
3528
  </g>
1291
3529
  );
1292
3530
  })()}
1293
- {session.status === 'working' && (
1294
- // working pulse small dot just inside the status ring,
1295
- // above the avatar text. Was a 18px horizontal bar at
1296
- // y+11 which now collides with the avatar; the dot reads
1297
- // as the same "active" cue without occluding the initial.
1298
- <circle cx={pos.x} cy={pos.y - (radius - 6)} r="2.5" fill={pal.flowParticle}>
1299
- <animate attributeName="opacity" values="1;0.25;1" dur="1.1s" repeatCount="indefinite" />
1300
- </circle>
1301
- )}
3531
+ {session.status === 'working' && (() => {
3532
+ // Round 24 / Loop: pulse rate traffic. The dot's dur
3533
+ // was a flat 1.1s every working node looked equally
3534
+ // busy. Mapping it to sse:N lets the eye land on what's
3535
+ // actually hot. 3 discrete tiers read cleaner than
3536
+ // continuous easing at a glance; thresholds picked so
3537
+ // typical 1-2-SSE Claude/Codex sessions stay in the
3538
+ // "active" tier and only multi-pane orchestrators pop
3539
+ // into the "busy" tier. Geometry unchanged (r=2.5).
3540
+ const sse = sseCountFor ?? 0;
3541
+ const dur = sse >= 4 ? '0.7s' : sse >= 2 ? '0.9s' : '1.2s';
3542
+ return (
3543
+ <circle cx={pos.x} cy={pos.y - (radius - 6)} r="2.5" fill={pal.flowParticle} data-pulse-dur={dur} opacity={reducedMotion ? 0.6 : undefined}>
3544
+ {!reducedMotion && (
3545
+ <animate attributeName="opacity" values="1;0.25;1" dur={dur} repeatCount="indefinite" />
3546
+ )}
3547
+ </circle>
3548
+ );
3549
+ })()}
1302
3550
 
1303
3551
  {/* Round 98 (issue #61): label rect 124px → 100px.
1304
3552
  Round 109/110 (Vincent P0): full opaque card below the
1305
3553
  density threshold / on hover / when zoomed; otherwise a
1306
3554
  lightweight plain-text alias that keeps every node
1307
- labelled without an opaque box covering its neighbours. */}
1308
- {showFullLabel ? (
1309
- <g transform={`translate(${pos.x}, ${pos.y + radius + 22})`} style={{ pointerEvents: 'none' }}>
1310
- <rect x="-50" y="-14" width="100" height="42" rx="6" fill={pal.labelBox.fill} stroke={pal.labelBox.stroke} opacity={isLight ? 1 : 0.94} />
1311
- <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize="12" fontFamily="monospace" fontWeight="700">
1312
- {truncate(session.alias, 12)}
1313
- </text>
1314
- <text x="0" y="17" textAnchor="middle" fill={status.primary} fontSize="9" fontFamily="monospace">
1315
- {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
3555
+ labelled without an opaque box covering its neighbours.
3556
+ Round 15 / Loop: when nodeScale=S the node shrinks 30%
3557
+ but the label card was staying full-size, so the label
3558
+ visually outweighed the small node. Tighten the card
3559
+ frame, alias / sub fontSize, drop-offset and truncate
3560
+ length specifically for S; M and L keep their existing
3561
+ sizes (M ≈ L for labels by design — the S user is the
3562
+ one who explicitly asked for a denser view). */}
3563
+ {(() => {
3564
+ const isSmall = nodeScale < 0.8;
3565
+ const cardW = isSmall ? 88 : 100;
3566
+ const cardH = isSmall ? 36 : 42;
3567
+ const cardTopY = isSmall ? -12 : -14;
3568
+ const aliasFs = isSmall ? 11 : 12;
3569
+ const subFs = isSmall ? 8 : 9;
3570
+ const subY = isSmall ? 15 : 17;
3571
+ const dropY = isSmall ? 18 : 22;
3572
+ const fullMax = isSmall ? 11 : 12;
3573
+ const denseFs = isSmall ? 9 : 10;
3574
+ const denseDrop = isSmall ? 12 : 14;
3575
+ // Round 26 / Loop: micro-lift the label group on hover —
3576
+ // 1.5 px upward, 200 ms ease. Pairs with the Round 18
3577
+ // group-box hover-accent treatment to give the same
3578
+ // "this is the focused element" feedback at the per-
3579
+ // node level. CSS transform stacks onto the SVG
3580
+ // positioning transform attribute (SVG 2 cascade);
3581
+ // bbox is unchanged at rest, so the overlap-test gate
3582
+ // continues to see the geometric layout it expects.
3583
+ return showFullLabel ? (
3584
+ <g transform={`translate(${pos.x}, ${pos.y + radius + dropY})`} style={{ pointerEvents: 'none' }}
3585
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]">
3586
+ <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} />
3587
+ <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize={aliasFs} fontFamily="monospace" fontWeight="700">
3588
+ {truncate(session.alias, fullMax)}
3589
+ </text>
3590
+ <text x="0" y={subY} textAnchor="middle" fill={status.primary} fontSize={subFs} fontFamily="monospace">
3591
+ {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
3592
+ </text>
3593
+ </g>
3594
+ ) : (
3595
+ <text
3596
+ x={pos.x}
3597
+ y={pos.y + radius + denseDrop}
3598
+ textAnchor="middle"
3599
+ fill={status.text}
3600
+ fontSize={denseFs}
3601
+ fontFamily="monospace"
3602
+ fontWeight="700"
3603
+ opacity={0.9}
3604
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]"
3605
+ style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
3606
+ stroke={pal.containerBg}
3607
+ strokeWidth="3"
3608
+ >
3609
+ {truncate(session.alias, isSmall ? 9 : 10)}
1316
3610
  </text>
1317
- </g>
1318
- ) : (
1319
- <text
1320
- x={pos.x}
1321
- y={pos.y + radius + 14}
1322
- textAnchor="middle"
1323
- fill={status.text}
1324
- fontSize="10"
1325
- fontFamily="monospace"
1326
- fontWeight="700"
1327
- opacity={0.9}
1328
- style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
1329
- stroke={pal.containerBg}
1330
- strokeWidth="3"
1331
- >
1332
- {truncate(session.alias, 10)}
1333
- </text>
1334
- )}
3611
+ );
3612
+ })()}
1335
3613
  </g>
1336
3614
  );
1337
3615
  })}
1338
3616
 
3617
+ {/* Round 14 / Loop: click ripple — one-shot expanding ring from
3618
+ the clicked node, ~500ms, status-coloured. Sits inside the
3619
+ zoom/pan <g> so it scales / pans with the topology. Keyed by
3620
+ ts so a re-click on any node (same or different) remounts the
3621
+ <circle> and the SMIL <animate> elements replay from t=0.
3622
+ strokeWidth=2 doesn't match the overlap-test selectors. */}
3623
+ {clickRipple && !reducedMotion && (
3624
+ <circle
3625
+ key={clickRipple.ts}
3626
+ cx={clickRipple.x}
3627
+ cy={clickRipple.y}
3628
+ r={clickRipple.r0 + 4}
3629
+ fill="none"
3630
+ stroke={clickRipple.color}
3631
+ strokeWidth="2"
3632
+ opacity="0"
3633
+ style={{ pointerEvents: 'none' }}
3634
+ >
3635
+ <animate attributeName="r" values={`${clickRipple.r0 + 4};${clickRipple.r0 + 30}`} dur="0.5s" fill="freeze" />
3636
+ <animate attributeName="opacity" values="0.7;0" dur="0.5s" fill="freeze" />
3637
+ </circle>
3638
+ )}
3639
+
1339
3640
  </g>
1340
3641
 
1341
3642
  {/* #112: overlay panels (recent-signal + legend) render OUTSIDE the
@@ -1346,38 +3647,592 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1346
3647
  corner triangles, so the panels never overlap a node, in ring
1347
3648
  OR grid layout (Vincent 4727 zero-overlap criterion). */}
1348
3649
  {/* latest flow labels */}
1349
- <g transform="translate(16, 16)">
1350
- <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} />
3650
+ {/* Round 57 / Loop: drop-shadow on the panel rects gives them
3651
+ card-like elevation, especially on light theme where the
3652
+ near-white fill on a near-white canvas read as pasted-on.
3653
+ data-topo-panel-elevation tag so the test can verify both
3654
+ panels carry the filter. The filter is on the rect, not
3655
+ the parent <g>, so it doesn't shadow the rows + text inside
3656
+ — only the panel chrome lifts. */}
3657
+ <g
3658
+ transform="translate(16, 16)"
3659
+ data-topo-panel="recent"
3660
+ data-topo-panel-hovered={hoveredPanel === 'recent' ? 'true' : 'false'}
3661
+ onMouseEnter={() => setHoveredPanel('recent')}
3662
+ onMouseLeave={() => setHoveredPanel(prev => prev === 'recent' ? null : prev)}
3663
+ >
3664
+ <rect
3665
+ x="0" y="0" width="230" height="84" rx="10"
3666
+ fill={pal.legendBox.fill}
3667
+ stroke={pal.legendBox.stroke}
3668
+ opacity={isLight ? 0.97 : 0.92}
3669
+ style={{
3670
+ /* R135: drop-shadow intensifies on panel hover. Base
3671
+ shadow (2px / 6px blur) signals card elevation
3672
+ (R57); hovered (4px / 12px blur) tells the user
3673
+ the whole chrome is interactive territory — rows
3674
+ pin (R116), footer navigates (R133), legend rows
3675
+ pin status (R61). Reuses R18's KPI-card hover-
3676
+ elevation idiom for visual consistency. Theme-
3677
+ aware shadow colour stays the same; just the
3678
+ spread + blur grow. */
3679
+ filter: hoveredPanel === 'recent'
3680
+ ? (isLight ? 'drop-shadow(0 4px 12px rgba(15,23,42,0.14))'
3681
+ : 'drop-shadow(0 4px 12px rgba(0,0,0,0.65))')
3682
+ : (isLight ? 'drop-shadow(0 2px 6px rgba(15,23,42,0.08))'
3683
+ : 'drop-shadow(0 2px 6px rgba(0,0,0,0.45))'),
3684
+ transition: 'filter 200ms ease-out',
3685
+ }}
3686
+ data-topo-panel-elevation="recent"
3687
+ />
1351
3688
  <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700">recent signal</text>
1352
- <text x="150" y="21" fill={pal.legendAccent} fontSize="10" fontFamily="monospace">{messages.length} msgs</text>
1353
- {flowLinks.slice(0, 3).map((link, index) => (
1354
- <text key={link.key} x="13" y={38 + index * 16} fill={pal.legendText} fontSize="9" fontFamily="monospace">
1355
- {truncate(link.from, 6)} {'->'} {truncate(link.to, 6)} / {link.count} / {truncate(link.content, 12)}
1356
- </text>
1357
- ))}
3689
+ {/* R96: header count now matches what the rows show. Pre-R96
3690
+ this read "X msgs" off the raw messages array, but the
3691
+ rows below render DEDUPED flowLinks so a fleet with 10
3692
+ messages aggregating to 3 pairs read "10 msgs" above
3693
+ only 3 rows. Misreads as "where are the other 7?".
3694
+ "X flows" mirrors flowLinks.length one-for-one. When
3695
+ flows < msgs the chip-row's "N active links · last 2s"
3696
+ already tells the operator about traffic volume — no
3697
+ duplicate metric needed here.
3698
+ R129 / Loop: header gains an amber " · N hot" tail
3699
+ when ≥ 1 flowLink has count ≥ 10. The third surface
3700
+ of the hot-lane convention (R126 canvas badge / R127
3701
+ row count) lives at the panel header so a user
3702
+ scanning vertically — header → rows — gets a top-
3703
+ level summary before reading each row's amber digit.
3704
+ Restructured into a single <text> with <tspan>
3705
+ fragments so the amber portion can carry its own
3706
+ fill + weight without a sibling <text>. Switched
3707
+ anchor x=150 left-justified → x=217 right-justified
3708
+ so the count column unifies visually with the legend
3709
+ panel's right-justified header (line 3511 — also
3710
+ fontSize 10 monospace, also x≈215 textAnchor end).
3711
+ data-recent-panel-count stays on the flow tspan so
3712
+ the R96 / R128 tests still resolve. data-recent-
3713
+ panel-hot-count exposes the hot bucket count. */}
3714
+ {(() => {
3715
+ const hotFlowCount = flowLinks.filter(l => l.count >= 10).length;
3716
+ const hotStroke = isLight ? '#d97706' : '#fbbf24';
3717
+ return (
3718
+ <text
3719
+ x="217" y="21"
3720
+ textAnchor="end"
3721
+ fontSize="10"
3722
+ fontFamily="monospace"
3723
+ >
3724
+ <tspan fill={pal.legendAccent} data-recent-panel-count>{flowLinks.length} flows</tspan>
3725
+ {hotFlowCount > 0 && (
3726
+ <tspan
3727
+ fill={hotStroke}
3728
+ fontWeight="700"
3729
+ data-recent-panel-hot-count={hotFlowCount}
3730
+ >
3731
+ {' · '}{hotFlowCount} hot
3732
+ </tspan>
3733
+ )}
3734
+ </text>
3735
+ );
3736
+ })()}
3737
+ {/* Round 45 / Loop: empty state. The panel used to render
3738
+ "recent signal" + "0 msgs" with three blank slots below
3739
+ when no flow yet — read as "broken" rather than "quiet".
3740
+ A muted centred placeholder makes the empty state
3741
+ deliberate. Messages count CAN diverge from flowLinks
3742
+ count (raw count vs. deduped pairs), so the placeholder
3743
+ fires on flowLinks.length=0 specifically. */}
3744
+ {flowLinks.length === 0 ? (
3745
+ /* R45 placeholder. R110: add a sub-text hint so the
3746
+ empty state explains what it's empty OF — operators
3747
+ looking at "no flow yet" sometimes mistook it for
3748
+ a connection error. The sub-line reads as a quiet
3749
+ invitation, not an error. Two-line layout uses
3750
+ standard SVG <text>+<text> rather than tspan so the
3751
+ y-coordinates are explicit and the data-attr selector
3752
+ still finds the primary line. */
3753
+ <>
3754
+ <text x="115" y="54" textAnchor="middle" fill={pal.legendText} fontSize="10" fontFamily="monospace" fontStyle="italic" opacity={0.65} data-recent-signal-empty>
3755
+ no flow yet
3756
+ </text>
3757
+ <text x="115" y="68" textAnchor="middle" fill={pal.legendText} fontSize="8" fontFamily="monospace" opacity={0.45} data-recent-signal-empty-hint>
3758
+ send a message between agents
3759
+ </text>
3760
+ </>
3761
+ ) : (
3762
+ // Round 56 / Loop: each row is a navigator into the canvas.
3763
+ // Hover a row → set hoveredEdgeKey, which the existing R50
3764
+ // edge-focus + R49 endpoint-highlight ladders consume. The
3765
+ // matching flow edge brightens to 2× + thickens, its two
3766
+ // endpoint nodes stay full opacity, and every other edge +
3767
+ // non-endpoint node dims. Released → all restore. Wrapping
3768
+ // <g> + a transparent 218×14 hitbox so the cursor doesn't
3769
+ // have to land precisely on the truncated text.
3770
+ flowLinks.slice(0, 3).map((link, index) => {
3771
+ // Round 94 / Loop: per-row relative timestamp. The chip
3772
+ // row shows "last 2s" for the most recent flow overall,
3773
+ // but a user scanning the recent-signal panel had no way
3774
+ // to tell whether row 2 was 5s old or 5m old without
3775
+ // hovering nodes. Compact `2s` / `1m` glyph at the
3776
+ // right edge — same relativeAgo helper R42 uses for
3777
+ // the chip — pulls double duty as a recency anchor and
3778
+ // a sortedness hint (top row is freshest by construction).
3779
+ // Strip the " ago" suffix — at fontSize=8 in a 32-px
3780
+ // right-edge slot, every char counts. "30s ago" → "30s".
3781
+ const rawAt = link.last_at ? relativeAgo(link.last_at) : null;
3782
+ const lastAt = rawAt ? rawAt.replace(/\s+ago$/, '') : null;
3783
+ const isRowHovered = hoveredEdgeKey === link.key;
3784
+ const isRowPinned = pinnedEdgeKey === link.key;
3785
+ const isRowActive = isRowHovered || isRowPinned;
3786
+ // R127: panel-side mirror of R126's canvas hot-badge.
3787
+ // The recent-signal row text packs `alias→alias / N /
3788
+ // preview` into one line, with N rendered identically
3789
+ // regardless of magnitude. Now that the canvas badge
3790
+ // tells the user "≥ 10 msgs = hot lane" via amber
3791
+ // stroke, the panel row needs the same affordance so
3792
+ // the user reading the list at a glance can spot hot
3793
+ // lanes without crossing to the canvas. Renders the
3794
+ // count digit in amber + 700-weight when isHot; the
3795
+ // surrounding alias text + separators stay in the
3796
+ // existing legendText/legendHeadline palette. Reuses
3797
+ // R126's hotStroke colour for visual consistency.
3798
+ const isHot = link.count >= 10;
3799
+ const hotStroke = isLight ? '#d97706' : '#fbbf24';
3800
+ return (
3801
+ <g
3802
+ key={link.key}
3803
+ data-recent-row={link.key}
3804
+ data-recent-row-hovered={isRowHovered ? 'true' : 'false'}
3805
+ data-recent-row-pinned={isRowPinned ? 'true' : 'false'}
3806
+ data-recent-row-hot={isHot ? 'true' : 'false'}
3807
+ data-recent-row-lifted={(isRowHovered || isRowPinned) ? 'true' : 'false'}
3808
+ role="button"
3809
+ tabIndex={0}
3810
+ aria-pressed={isRowPinned}
3811
+ // R143 / Loop: extend the R135/R142 "interactive surface
3812
+ // elevates" idiom down one layer to the recent-signal
3813
+ // panel rows. R104 already tints the row background on
3814
+ // hover; R143 adds a 1-px translate so the row text
3815
+ // visually lifts off the panel — same vocabulary R51
3816
+ // uses for nodes, R135 uses for panels, R142 uses for
3817
+ // group boxes. Pinned rows lift too (sticky state
3818
+ // should look like locked-in selection). Reduced-motion
3819
+ // safe via prefers-reduced-motion blanket override
3820
+ // applied to transition-duration in globals.css.
3821
+ style={{
3822
+ cursor: 'pointer',
3823
+ transform: (isRowHovered || isRowPinned) ? 'translateY(-1px)' : undefined,
3824
+ transition: 'transform 150ms cubic-bezier(0.4, 0, 0.2, 1)',
3825
+ }}
3826
+ onPointerDown={(e) => e.stopPropagation()}
3827
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
3828
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
3829
+ // R116: click toggles pin. activeEdgeKey =
3830
+ // hoveredEdgeKey ?? pinnedEdgeKey so the matching
3831
+ // edge stays "hot" after mouseleave; click again
3832
+ // (or Esc) releases.
3833
+ onClick={() => setPinnedEdgeKey(prev => prev === link.key ? null : link.key)}
3834
+ onKeyDown={(e) => {
3835
+ if (e.key === 'Enter' || e.key === ' ') {
3836
+ e.preventDefault();
3837
+ setPinnedEdgeKey(prev => prev === link.key ? null : link.key);
3838
+ }
3839
+ }}
3840
+ >
3841
+ {/* R148 / Loop: row tooltip with full message context.
3842
+ The row text truncates aliases to 6 chars (R127)
3843
+ and content to 8 chars — useful for scan-density
3844
+ but obscures the underlying message. A native SVG
3845
+ <title> reveals the full alias / content /
3846
+ timestamp on hover. Pinned vs unpinned switches
3847
+ the click hint so the user knows the next
3848
+ gesture's effect. R98 enriched the node tooltip
3849
+ the same way for the source/destination scope;
3850
+ R148 brings the per-row equivalent to the panel
3851
+ side. */}
3852
+ <title>{[
3853
+ `${link.from} → ${link.to} · ${link.count} msg${link.count === 1 ? '' : 's'}${isHot ? ' (hot lane · ≥ 10)' : ''}`,
3854
+ link.last_at ? `last: ${new Date(link.last_at).toLocaleString()}` : null,
3855
+ link.content ? `"${link.content}"` : null,
3856
+ isRowPinned ? 'click to release pin (Esc to clear)' : 'click to pin · hover to preview',
3857
+ ].filter(Boolean).join('\n')}</title>
3858
+ {/* R104: subtle row-background tint on hover. R56
3859
+ already brightens the matching edge on the canvas,
3860
+ but the panel row itself stayed flat — felt more
3861
+ like text-with-handlers than a navigable list.
3862
+ Filling the rect at hover with `pal.legendAccent`
3863
+ at low alpha gives the row visual feedback at the
3864
+ source surface, mirroring the list-item idiom from
3865
+ the chip-row pills. R116: pinned rows tint
3866
+ stronger than hovered ones so locked vs preview
3867
+ is discriminable. */}
3868
+ <rect
3869
+ x="6" y={38 + index * 16 - 10}
3870
+ width="218" height="14" rx="3"
3871
+ fill={isRowActive ? pal.legendAccent : 'transparent'}
3872
+ opacity={isRowPinned ? (isLight ? 0.18 : 0.22)
3873
+ : isRowHovered ? (isLight ? 0.10 : 0.14)
3874
+ : 1}
3875
+ style={{ transition: 'fill 150ms ease-out, opacity 150ms ease-out' }}
3876
+ />
3877
+ <text
3878
+ x="13" y={38 + index * 16}
3879
+ fill={isRowActive ? pal.legendHeadline : pal.legendText}
3880
+ fontSize="9"
3881
+ fontFamily="monospace"
3882
+ style={{ transition: 'fill 150ms ease-out' }}
3883
+ >
3884
+ {/* R138 / Loop: typography unification with the rest
3885
+ of the topology UI. Filter pills (R119) render
3886
+ "{from}→{to}", node tooltips (R98) use →, the
3887
+ active-links chip tooltip (R114) and edge-badge
3888
+ titles all use unicode →. The recent-signal row
3889
+ was the lone holdout still rendering "from -> to"
3890
+ in ASCII. The data delimiter likewise: filter
3891
+ pills use " · " ("status · 3"); the row was using
3892
+ " / ". Both swaps make the row read like every
3893
+ other surface — one less micro-style to remember. */}
3894
+ {truncate(link.from, 6)} {'→'} {truncate(link.to, 6)} {' · '}
3895
+ {isHot ? (
3896
+ <tspan fill={hotStroke} fontWeight="700" data-recent-row-count-hot>
3897
+ {link.count}
3898
+ </tspan>
3899
+ ) : (
3900
+ <tspan data-recent-row-count>{link.count}</tspan>
3901
+ )}
3902
+ {' · '}{truncate(link.content, 8)}
3903
+ </text>
3904
+ {lastAt ? (
3905
+ <text
3906
+ x="217" y={38 + index * 16}
3907
+ textAnchor="end"
3908
+ fill={pal.legendText}
3909
+ fontSize="8"
3910
+ fontFamily="monospace"
3911
+ opacity={0.55}
3912
+ data-recent-row-ts={link.key}
3913
+ style={{ pointerEvents: 'none' }}
3914
+ >
3915
+ {lastAt}
3916
+ </text>
3917
+ ) : null}
3918
+ </g>
3919
+ );
3920
+ })
3921
+ )}
3922
+ {/* Round 128 / Loop: overflow hint. The recent-signal panel
3923
+ renders the top 3 flowLinks via .slice(0, 3) — but a
3924
+ fleet with 5 or 10 active flows silently truncates the
3925
+ rest. The R96 "X flows" header tells the total but
3926
+ doesn't say "you're seeing top-3". This hint fires
3927
+ only when flowLinks.length > 3 so quiet fleets stay
3928
+ clean. Footer y=82 sits in the 10-px gap between
3929
+ row 3 (baseline 70) and the panel bottom (84), so
3930
+ overlap-test geometry is unchanged. fontStyle=italic
3931
+ + opacity 0.55 reads as muted metadata, not an
3932
+ actionable row — matches the R110 empty-state hint
3933
+ idiom. */}
3934
+ {flowLinks.length > 3 && (() => {
3935
+ const moreCount = flowLinks.length - 3;
3936
+ const label = `+ ${moreCount} more flow${moreCount === 1 ? '' : 's'}`;
3937
+ // R133: the truncation hint becomes a clickable nav to
3938
+ // /messages. R128 introduced the footer as pure metadata
3939
+ // ("you're seeing top-3"); R133 closes the gap by giving
3940
+ // users a way to ACT on that info — see the full list.
3941
+ // Wrap in <g> with onClick so SVG hit-testing fires the
3942
+ // route push. cursor:pointer + the underline-on-hover
3943
+ // visual cue tells users this is interactive. The hover
3944
+ // state is React-controlled (no CSS :hover on SVG <g>
3945
+ // descendant text would feel reliable across Chrome's
3946
+ // SVG quirks). pointerEvents:all so the text receives
3947
+ // the click; onPointerDown stop-propagation so the
3948
+ // R103 zoom/pan capture doesn't intercept first.
3949
+ return (
3950
+ <g
3951
+ data-recent-panel-more-nav
3952
+ role="link"
3953
+ tabIndex={0}
3954
+ style={{ cursor: 'pointer', pointerEvents: 'all' }}
3955
+ onPointerDown={(e) => e.stopPropagation()}
3956
+ onMouseEnter={() => setHoveredRecentMore(true)}
3957
+ onMouseLeave={() => setHoveredRecentMore(false)}
3958
+ onClick={() => router.push('/messages')}
3959
+ onKeyDown={(e) => {
3960
+ if (e.key === 'Enter' || e.key === ' ') {
3961
+ e.preventDefault();
3962
+ router.push('/messages');
3963
+ }
3964
+ }}
3965
+ >
3966
+ <title>{`${label} — open /messages for the full list`}</title>
3967
+ <text
3968
+ x="115" y="82"
3969
+ textAnchor="middle"
3970
+ fill={pal.legendText}
3971
+ fontSize="8"
3972
+ fontFamily="monospace"
3973
+ fontStyle="italic"
3974
+ opacity={hoveredRecentMore ? 0.85 : 0.55}
3975
+ textDecoration={hoveredRecentMore ? 'underline' : 'none'}
3976
+ data-recent-panel-more={moreCount}
3977
+ style={{ transition: 'opacity 150ms ease-out' }}
3978
+ >
3979
+ {label}
3980
+ </text>
3981
+ </g>
3982
+ );
3983
+ })()}
1358
3984
  </g>
1359
3985
 
1360
- {/* legend */}
1361
- <g transform="translate(760, 16)">
1362
- <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} />
1363
- <circle cx="16" cy="24" r="5.5" fill={isLight ? '#059669' : '#22c55e'} />
1364
- <text x="30" y="28" fill={pal.legendText} fontSize="11" fontFamily="monospace">working node</text>
1365
- <circle cx="16" cy="48" r="5.5" fill={isLight ? '#0d9488' : '#2dd4bf'} />
1366
- <text x="30" y="52" fill={pal.legendText} fontSize="11" fontFamily="monospace">online idle</text>
1367
- <circle cx="16" cy="72" r="5.5" fill={isLight ? '#94a3b8' : '#6b7280'} />
1368
- <text x="30" y="76" fill={pal.legendText} fontSize="11" fontFamily="monospace">offline / no SSE</text>
1369
- <path d="M140,72 Q164,48 196,72" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" />
3986
+ {/* legend — Round 55 / Loop: each status row is now a hover
3987
+ target. Pointer enter sets `hoveredStatus`; pointer leave
3988
+ clears it. Node opacity formula composes the match below.
3989
+ The row text brightens to legendHeadline while hovered as
3990
+ a small affordance hint. Geometry unchanged — the new
3991
+ <g> wrappers only carry pointer handlers. */}
3992
+ <g
3993
+ transform="translate(760, 16)"
3994
+ data-topo-panel="legend"
3995
+ data-topo-panel-hovered={hoveredPanel === 'legend' ? 'true' : 'false'}
3996
+ onMouseEnter={() => setHoveredPanel('legend')}
3997
+ onMouseLeave={() => setHoveredPanel(prev => prev === 'legend' ? null : prev)}
3998
+ >
3999
+ {/* R57: matching drop-shadow elevation to the legend panel.
4000
+ R106: panel height grew 96 → 104 to seat the new header
4001
+ line + 4 px row-shift below it (so the new header text
4002
+ doesn't overlap the row-1 hitbox region).
4003
+ R135: hover-elevation mirrors the recent-signal panel
4004
+ rect at line ~3299. Both panels grow their shadow on
4005
+ hover to telegraph "the chrome is interactive" since
4006
+ their rows pin / nav. */}
4007
+ <rect
4008
+ x="0" y="0" width="224" height="104" rx="10"
4009
+ fill={pal.legendBox.fill}
4010
+ stroke={pal.legendBox.stroke}
4011
+ opacity={isLight ? 0.97 : 0.92}
4012
+ style={{
4013
+ filter: hoveredPanel === 'legend'
4014
+ ? (isLight ? 'drop-shadow(0 4px 12px rgba(15,23,42,0.14))'
4015
+ : 'drop-shadow(0 4px 12px rgba(0,0,0,0.65))')
4016
+ : (isLight ? 'drop-shadow(0 2px 6px rgba(15,23,42,0.08))'
4017
+ : 'drop-shadow(0 2px 6px rgba(0,0,0,0.45))'),
4018
+ transition: 'filter 200ms ease-out',
4019
+ }}
4020
+ data-topo-panel-elevation="legend"
4021
+ />
4022
+ {/* R106 / Loop: panel header — symmetric with the recent-
4023
+ signal panel's "recent signal · N flows" (R96). Same
4024
+ font + position vocabulary so the two side panels feel
4025
+ paired. Title text at x=13 y=21; total fleet count
4026
+ right-aligned at x=215 y=21 in the accent colour. */}
4027
+ <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700">legend</text>
4028
+ <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>
4029
+ {(() => {
4030
+ const idleCount = onlineNodes.length - workingCount;
4031
+ // R106: rows shift +8 px (was y0=24, 48, 72 → 32, 56, 80)
4032
+ // to clear the new header row. R57 panel rect grew 96 →
4033
+ // 104 to seat them.
4034
+ const rows = [
4035
+ { key: 'working' as const, y0: 32, y1: 36, fill: isLight ? '#059669' : '#22c55e', label: 'working node', count: workingCount },
4036
+ { key: 'idle' as const, y0: 56, y1: 60, fill: isLight ? '#0d9488' : '#2dd4bf', label: 'online idle', count: idleCount },
4037
+ { key: 'offline' as const, y0: 80, y1: 84, fill: isLight ? '#94a3b8' : '#6b7280', label: 'offline / no SSE', count: offlineNodes.length },
4038
+ ];
4039
+ return rows;
4040
+ })().map(row => {
4041
+ // Round 61 / Loop: legend rows pin too — symmetric with the
4042
+ // R60 pressure-bar segments. R55 hover stays transient; the
4043
+ // new onClick toggles pinnedStatus so users can lock a
4044
+ // filter without holding the cursor still. Pinned row gets
4045
+ // an inset ring on the swatch (same vocab as R60).
4046
+ const isPinned = pinnedStatus === row.key;
4047
+ const isRowHovered = hoveredStatus === row.key;
4048
+ const isLifted = isRowHovered || isPinned;
4049
+ return (
4050
+ <g
4051
+ key={row.key}
4052
+ data-legend-status={row.key}
4053
+ data-legend-row-lifted={isLifted ? 'true' : 'false'}
4054
+ role="button"
4055
+ tabIndex={0}
4056
+ aria-pressed={isPinned}
4057
+ // R144: mirror of R143 — extend the row-lift idiom to the
4058
+ // legend panel rows for symmetry with the recent-signal
4059
+ // panel rows. Same translate-1px transform, same 150ms
4060
+ // cubic-bezier timing. R55 hovers the row (transient),
4061
+ // R61 pins it (sticky); both states earn the lift.
4062
+ // Composes with R105 row-bg-tint and R135 panel hover-
4063
+ // shadow → three layers of feedback at three nested
4064
+ // scopes (panel chrome → row text lift → bg tint).
4065
+ style={{
4066
+ cursor: 'pointer',
4067
+ transform: isLifted ? 'translateY(-1px)' : undefined,
4068
+ transition: 'transform 150ms cubic-bezier(0.4, 0, 0.2, 1)',
4069
+ }}
4070
+ // R61: stopPropagation on pointerdown so the SVG-level
4071
+ // pan handler (R103) doesn't setPointerCapture and
4072
+ // redirect the follow-up click away from this <g>.
4073
+ // Same trick the node <g> uses (and R52 hub).
4074
+ onPointerDown={(e) => e.stopPropagation()}
4075
+ onMouseEnter={() => setHoveredStatus(row.key)}
4076
+ onMouseLeave={() => setHoveredStatus(prev => prev === row.key ? null : prev)}
4077
+ onClick={() => setPinnedStatus(prev => prev === row.key ? null : row.key)}
4078
+ >
4079
+ {/* R149 / Loop: legend row gains a <title> tooltip
4080
+ symmetric with R148's recent-signal row tooltip.
4081
+ Same R97 idiom — anywhere the UI shows "N" should
4082
+ hover-explain WHICH N — applied to the legend
4083
+ panel which had been showing only the bucket
4084
+ count without alias context. Header line names
4085
+ the status bucket; body lists the matched aliases
4086
+ with 8-truncate + "+N more"; footer hint flips
4087
+ with pin state. Hot-lane convention doesn't apply
4088
+ here (status buckets aren't traffic counts), so
4089
+ no isHot suffix. */}
4090
+ <title>{(() => {
4091
+ const aliases = row.key === 'working'
4092
+ ? onlineNodes.filter(s => s.status === 'working').map(s => s.alias)
4093
+ : row.key === 'idle'
4094
+ ? onlineNodes.filter(s => s.status !== 'working').map(s => s.alias)
4095
+ : offlineNodes.map(s => s.alias);
4096
+ const preview = aliases.slice(0, 8).join(', ');
4097
+ const suffix = aliases.length > 8 ? ` + ${aliases.length - 8} more` : '';
4098
+ return [
4099
+ `${row.label} · ${row.count}`,
4100
+ aliases.length > 0 ? `${preview}${suffix}` : null,
4101
+ isPinned ? 'click to release pin (Esc to clear)' : 'click to pin · hover to preview',
4102
+ ].filter(Boolean).join('\n');
4103
+ })()}</title>
4104
+ {/* R55 hitbox covers the row so cursor doesn't need to
4105
+ be exactly on the 5-px swatch. R105 / Loop: the
4106
+ hitbox now also carries a subtle hover/pin tint —
4107
+ mirroring R104's recent-signal row treatment so
4108
+ both side panels share the list-item idiom. The
4109
+ tint borrows the ROW'S OWN swatch colour rather
4110
+ than legendAccent, so the user's eye associates
4111
+ the tinted row with its colour swatch instead of
4112
+ a generic accent. Pin is a stronger tint than
4113
+ hover; idle stays fully transparent. */}
4114
+ <rect
4115
+ x="6" y={row.y0 - 12}
4116
+ width="170" height="22" rx="3"
4117
+ fill={hoveredStatus === row.key || isPinned ? row.fill : 'transparent'}
4118
+ opacity={isPinned ? (isLight ? 0.14 : 0.18)
4119
+ : hoveredStatus === row.key ? (isLight ? 0.08 : 0.12)
4120
+ : 1}
4121
+ data-legend-row-tinted={isPinned ? 'pinned' : hoveredStatus === row.key ? 'hover' : 'none'}
4122
+ style={{ transition: 'fill 150ms ease-out, opacity 150ms ease-out' }}
4123
+ />
4124
+ <circle cx="16" cy={row.y0} r="5.5" fill={row.fill} />
4125
+ {/* R61 pinned-state ring — concentric stroke at r=8 in
4126
+ the row colour, draws OUTSIDE the swatch so it
4127
+ doesn't fight the fill colour the user is matching. */}
4128
+ {isPinned && (
4129
+ <circle cx="16" cy={row.y0} r="8" fill="none" stroke={row.fill} strokeWidth="1.5" />
4130
+ )}
4131
+ <text
4132
+ x="30" y={row.y1}
4133
+ fill={hoveredStatus === row.key || isPinned ? pal.legendHeadline : pal.legendText}
4134
+ fontSize="11"
4135
+ fontFamily="monospace"
4136
+ style={{ transition: 'fill 150ms ease-out' }}
4137
+ >{row.label}</text>
4138
+ {/* R95: live count anchored to the right edge of the
4139
+ panel (x=215, after the flow-arrow swatch). Same
4140
+ counts the chip-row shows ("3 working" etc.) but
4141
+ here next to the swatch the user is matching —
4142
+ saves crossing the canvas to the chip row for
4143
+ the number. text-anchor=end aligns the column
4144
+ visually like a table. pointerEvents:none so the
4145
+ count doesn't intercept the row hover hitbox. */}
4146
+ <text
4147
+ x="215" y={row.y1}
4148
+ textAnchor="end"
4149
+ fill={pal.legendText}
4150
+ fontSize="11"
4151
+ fontFamily="monospace"
4152
+ opacity={hoveredStatus === row.key || isPinned ? 0.95 : 0.65}
4153
+ data-legend-count={row.key}
4154
+ style={{ pointerEvents: 'none', transition: 'opacity 150ms ease-out' }}
4155
+ >{row.count}</text>
4156
+ </g>
4157
+ );
4158
+ })}
4159
+ {/* Flow-arrow swatch tracks the offline row — R106 shifted
4160
+ rows down by 8 px to make space for the panel header so
4161
+ this moves from y=72 to y=80. Drop its pointerEvents so
4162
+ the offline legend row stays hoverable (R55). It's
4163
+ decoration, no need to receive events. */}
4164
+ <path d="M140,80 Q164,56 196,80" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" style={{ pointerEvents: 'none' }} />
1370
4165
  </g>
1371
4166
  </svg>
1372
4167
 
4168
+ {/* Round 30 / Loop: minimap. Big fleets in fullscreen mode at high
4169
+ zoom let users lose their position — the minimap shows the
4170
+ whole topology miniaturised plus a viewport rectangle so the
4171
+ user always knows where they are. Click anywhere to recenter
4172
+ the canvas there. Only mounted when the view is non-default
4173
+ (zoomed or panned) since at 1× centered the minimap and the
4174
+ canvas show the same thing. HTML overlay so it stays fixed
4175
+ while the SVG transforms. */}
4176
+ {(() => {
4177
+ const isDefaultView = Math.abs(view.zoom - 1) < 0.01 && Math.abs(view.x) < 1 && Math.abs(view.y) < 1;
4178
+ if (isDefaultView || (onlineNodes.length + offlineNodes.length) === 0) return null;
4179
+ const MW = 120, MH = 82;
4180
+ const sx = MW / VIEWBOX_W, sy = MH / VIEWBOX_H;
4181
+ const rectX = (-view.x / view.zoom) * sx;
4182
+ const rectY = (-view.y / view.zoom) * sy;
4183
+ const rectW = (VIEWBOX_W / view.zoom) * sx;
4184
+ const rectH = (VIEWBOX_H / view.zoom) * sy;
4185
+ return (
4186
+ <div
4187
+ className="absolute right-3 rounded-md border shadow-lg shadow-black/30 overflow-hidden anet-fade-in"
4188
+ style={{ bottom: 56, background: pal.legendBox.fill, borderColor: pal.containerBorder, cursor: 'crosshair' }}
4189
+ role="img"
4190
+ aria-label="Topology minimap — click to recenter"
4191
+ title="Minimap · click to recenter"
4192
+ onClick={(e) => {
4193
+ const r = e.currentTarget.getBoundingClientRect();
4194
+ const fx = (e.clientX - r.left) / r.width;
4195
+ const fy = (e.clientY - r.top) / r.height;
4196
+ setView(prev => ({
4197
+ ...prev,
4198
+ x: VIEWBOX_W / 2 - fx * VIEWBOX_W * prev.zoom,
4199
+ y: VIEWBOX_H / 2 - fy * VIEWBOX_H * prev.zoom,
4200
+ }));
4201
+ }}
4202
+ data-topo-minimap
4203
+ >
4204
+ <svg width={MW} height={MH} viewBox={`0 0 ${MW} ${MH}`} style={{ display: 'block' }}>
4205
+ {[...onlineNodes, ...offlineNodes].map(s => {
4206
+ const p = nodePositions[s.alias];
4207
+ if (!p) return null;
4208
+ const sseN = (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
4209
+ const isOn = s.status !== 'offline' || !!sseN;
4210
+ const st = nodeStatus(s, isOn, isLight);
4211
+ 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} />;
4212
+ })}
4213
+ {/* viewport rectangle */}
4214
+ <rect
4215
+ x={Math.max(0, rectX)} y={Math.max(0, rectY)}
4216
+ width={Math.max(0, Math.min(MW - Math.max(0, rectX), rectW))}
4217
+ height={Math.max(0, Math.min(MH - Math.max(0, rectY), rectH))}
4218
+ fill="none" stroke={pal.legendAccent} strokeWidth="1" opacity="0.9"
4219
+ />
4220
+ </svg>
4221
+ </div>
4222
+ );
4223
+ })()}
1373
4224
  {/* Round 103 (issue #81): zoom / pan / fullscreen controls — HTML
1374
4225
  overlay so they stay fixed while the SVG content transforms.
1375
4226
  Round 104: Vincent 实测 — the reset action used to be hidden
1376
4227
  behind the "%" label (looked like an indicator, not a button).
1377
4228
  Split into a plain % readout + an explicit reset button with
1378
4229
  its own icon + tooltip. */}
1379
- <div className="absolute bottom-3 right-3 flex items-center gap-1.5 text-xs select-none">
1380
- {/* #113: node size — S / M / L segmented control (Vincent 4727). */}
4230
+ <div className="absolute bottom-3 right-3 flex items-center gap-1.5 text-xs select-none" data-topo-chrome>
4231
+ {/* #113: node size — S / M / L segmented control (Vincent 4727).
4232
+ R154: stable data-* hooks for tests + focus-visible ring so
4233
+ keyboard navigation lands somewhere visible against the
4234
+ dark canvas (browser default outline often vanishes on
4235
+ cyber theme). */}
1381
4236
  <div
1382
4237
  className="flex items-center rounded-md border overflow-hidden"
1383
4238
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder }}
@@ -1389,8 +4244,10 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1389
4244
  key={lbl}
1390
4245
  onClick={() => pickNodeScale(v)}
1391
4246
  aria-pressed={nodeScale === v}
4247
+ data-topo-chrome-nodesize={lbl}
4248
+ data-topo-chrome-nodesize-active={nodeScale === v ? 'true' : 'false'}
1392
4249
  title={`Node size: ${lbl === 'S' ? 'small' : lbl === 'M' ? 'medium' : 'large'}`}
1393
- className={`px-2 py-1 transition-colors ${idx > 0 ? 'border-l' : ''} ${nodeScale === v ? 'bg-cyan-500/15 text-cyan-300' : 'hover:bg-white/5'}`}
4250
+ className={`px-2 py-1 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset ${idx > 0 ? 'border-l' : ''} ${nodeScale === v ? 'bg-cyan-500/15 text-cyan-300' : 'hover:bg-white/5'}`}
1394
4251
  style={{ color: nodeScale === v ? undefined : pal.legendText, borderColor: pal.containerBorder }}
1395
4252
  >
1396
4253
  {lbl}
@@ -1403,14 +4260,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1403
4260
  >
1404
4261
  <button
1405
4262
  onClick={() => zoomBy(1 / 1.2)}
1406
- className="px-2 py-1 hover:bg-white/5 transition-colors"
4263
+ data-topo-chrome-zoom-out
4264
+ className="px-2 py-1 hover:bg-white/5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset"
1407
4265
  style={{ color: pal.legendText }}
1408
4266
  aria-label="Zoom out"
4267
+ title="Zoom out (−)"
1409
4268
  >
1410
4269
  <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>
1411
4270
  </button>
1412
4271
  <span
1413
4272
  className="px-2 py-1 tabular-nums border-x text-center"
4273
+ data-topo-chrome-zoom-level
1414
4274
  style={{ color: pal.legendText, borderColor: pal.containerBorder, minWidth: 46 }}
1415
4275
  title="Current zoom level"
1416
4276
  >
@@ -1418,19 +4278,22 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1418
4278
  </span>
1419
4279
  <button
1420
4280
  onClick={() => zoomBy(1.2)}
1421
- className="px-2 py-1 hover:bg-white/5 transition-colors"
4281
+ data-topo-chrome-zoom-in
4282
+ className="px-2 py-1 hover:bg-white/5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset"
1422
4283
  style={{ color: pal.legendText }}
1423
4284
  aria-label="Zoom in"
4285
+ title="Zoom in (+)"
1424
4286
  >
1425
4287
  <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>
1426
4288
  </button>
1427
4289
  </div>
1428
4290
  <button
1429
4291
  onClick={resetView}
1430
- className="p-1.5 rounded-md border hover:bg-white/5 transition-colors"
4292
+ data-topo-chrome-reset
4293
+ className="p-1.5 rounded-md border hover:bg-white/5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60"
1431
4294
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
1432
4295
  aria-label="Reset view"
1433
- title="Reset zoom + pan (or double-click the canvas)"
4296
+ title="Reset zoom + pan (0, or double-click the canvas)"
1434
4297
  >
1435
4298
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1436
4299
  <path d="M3 12a9 9 0 1 0 9-9 9 9 0 0 0-6.4 2.6L3 8" />
@@ -1439,7 +4302,9 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1439
4302
  </button>
1440
4303
  <button
1441
4304
  onClick={toggleFullscreen}
1442
- className="p-1.5 rounded-md border hover:bg-white/5 transition-colors"
4305
+ data-topo-chrome-fullscreen
4306
+ data-topo-chrome-fullscreen-active={isFullscreen ? 'true' : 'false'}
4307
+ className="p-1.5 rounded-md border hover:bg-white/5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60"
1443
4308
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
1444
4309
  aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
1445
4310
  title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}