@sleep2agi/agent-network-dashboard 0.4.6-preview.1 → 0.4.6-preview.100

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 (899) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +1 -0
  3. package/.next/build/chunks/{node_modules_13sb.px._.js → 00jm_02mlcew._.js} +167 -167
  4. package/.next/build/chunks/00jm_02mlcew._.js.map +47 -0
  5. package/.next/build/chunks/{[root-of-the-server]__0ubbtyl._.js → [root-of-the-server]__0tgc-8p._.js} +3 -3
  6. package/.next/build/chunks/{[root-of-the-server]__0d-m0h0._.js → [root-of-the-server]__13z64y.._.js} +7 -7
  7. package/.next/build/chunks/{[root-of-the-server]__0d-m0h0._.js.map → [root-of-the-server]__13z64y.._.js.map} +2 -2
  8. package/.next/build/chunks/[turbopack-node]_transforms_postcss_ts_0r4x5ys._.js +13 -0
  9. package/.next/build/chunks/[turbopack]_runtime.js +1 -1
  10. package/.next/build/postcss.js +4 -4
  11. package/.next/build-manifest.json +8 -8
  12. package/.next/diagnostics/route-bundle-stats.json +157 -143
  13. package/.next/fallback-build-manifest.json +3 -3
  14. package/.next/next-minimal-server.js.nft.json +1 -1
  15. package/.next/next-server.js.nft.json +1 -1
  16. package/.next/prerender-manifest.json +3 -3
  17. package/.next/required-server-files.js +4 -4
  18. package/.next/required-server-files.json +4 -4
  19. package/.next/routes-manifest.json +6 -0
  20. package/.next/server/app/_global-error/page/build-manifest.json +5 -5
  21. package/.next/server/app/_global-error/page.js +9 -9
  22. package/.next/server/app/_global-error/page.js.nft.json +1 -1
  23. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  24. package/.next/server/app/_global-error.html +1 -1
  25. package/.next/server/app/_global-error.rsc +8 -8
  26. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  27. package/.next/server/app/_global-error.segments/_full.segment.rsc +8 -8
  28. package/.next/server/app/_global-error.segments/_head.segment.rsc +4 -4
  29. package/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  30. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  31. package/.next/server/app/_not-found/page/build-manifest.json +5 -5
  32. package/.next/server/app/_not-found/page/next-font-manifest.json +1 -1
  33. package/.next/server/app/_not-found/page.js +12 -12
  34. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  35. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  36. package/.next/server/app/_not-found.html +2 -2
  37. package/.next/server/app/_not-found.rsc +12 -12
  38. package/.next/server/app/_not-found.segments/_full.segment.rsc +12 -12
  39. package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  40. package/.next/server/app/_not-found.segments/_index.segment.rsc +7 -7
  41. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  42. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  43. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  44. package/.next/server/app/admin/page/build-manifest.json +5 -5
  45. package/.next/server/app/admin/page/next-font-manifest.json +1 -1
  46. package/.next/server/app/admin/page.js +13 -13
  47. package/.next/server/app/admin/page.js.nft.json +1 -1
  48. package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
  49. package/.next/server/app/admin.html +2 -2
  50. package/.next/server/app/admin.rsc +14 -14
  51. package/.next/server/app/admin.segments/_full.segment.rsc +14 -14
  52. package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
  53. package/.next/server/app/admin.segments/_index.segment.rsc +7 -7
  54. package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
  55. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +4 -4
  56. package/.next/server/app/admin.segments/admin.segment.rsc +3 -3
  57. package/.next/server/app/api/anet/config/route.js +6 -6
  58. package/.next/server/app/api/anet/config/route.js.nft.json +1 -1
  59. package/.next/server/app/api/auth/login/route.js +7 -7
  60. package/.next/server/app/api/auth/login/route.js.nft.json +1 -1
  61. package/.next/server/app/api/auth/logout/route.js +7 -7
  62. package/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  63. package/.next/server/app/api/auth/v3/route.js +7 -7
  64. package/.next/server/app/api/auth/v3/route.js.nft.json +1 -1
  65. package/.next/server/app/api/hub/audit-log/route.js +6 -6
  66. package/.next/server/app/api/hub/audit-log/route.js.nft.json +1 -1
  67. package/.next/server/app/api/hub/auth/route.js +7 -7
  68. package/.next/server/app/api/hub/auth/route.js.nft.json +1 -1
  69. package/.next/server/app/api/hub/broadcast/route.js +6 -6
  70. package/.next/server/app/api/hub/broadcast/route.js.nft.json +1 -1
  71. package/.next/server/app/api/hub/events/route.js +6 -6
  72. package/.next/server/app/api/hub/events/route.js.nft.json +1 -1
  73. package/.next/server/app/api/hub/health/route.js +6 -6
  74. package/.next/server/app/api/hub/health/route.js.nft.json +1 -1
  75. package/.next/server/app/api/hub/inbox/route.js +6 -6
  76. package/.next/server/app/api/hub/inbox/route.js.nft.json +1 -1
  77. package/.next/server/app/api/hub/license/route.js +6 -6
  78. package/.next/server/app/api/hub/license/route.js.nft.json +1 -1
  79. package/.next/server/app/api/hub/messages/route.js +6 -6
  80. package/.next/server/app/api/hub/messages/route.js.nft.json +1 -1
  81. package/.next/server/app/api/hub/networks/route.js +6 -6
  82. package/.next/server/app/api/hub/networks/route.js.nft.json +1 -1
  83. package/.next/server/app/api/hub/nodes/route.js +6 -6
  84. package/.next/server/app/api/hub/nodes/route.js.nft.json +1 -1
  85. package/.next/server/app/api/hub/register/route.js +6 -6
  86. package/.next/server/app/api/hub/register/route.js.nft.json +1 -1
  87. package/.next/server/app/api/hub/send/route.js +6 -6
  88. package/.next/server/app/api/hub/send/route.js.nft.json +1 -1
  89. package/.next/server/app/api/hub/server-logs/route.js +6 -6
  90. package/.next/server/app/api/hub/server-logs/route.js.nft.json +1 -1
  91. package/.next/server/app/api/hub/servers/route/app-paths-manifest.json +3 -0
  92. package/.next/server/app/api/hub/servers/route/build-manifest.json +9 -0
  93. package/.next/server/app/api/hub/servers/route/server-reference-manifest.json +4 -0
  94. package/.next/server/app/api/hub/servers/route.js +7 -0
  95. package/.next/server/app/api/hub/servers/route.js.map +5 -0
  96. package/.next/server/app/api/hub/servers/route.js.nft.json +1 -0
  97. package/.next/server/app/api/hub/servers/route_client-reference-manifest.js +3 -0
  98. package/.next/server/app/api/hub/session/route.js +6 -6
  99. package/.next/server/app/api/hub/session/route.js.nft.json +1 -1
  100. package/.next/server/app/api/hub/stats/route.js +6 -6
  101. package/.next/server/app/api/hub/stats/route.js.nft.json +1 -1
  102. package/.next/server/app/api/hub/status/route.js +6 -6
  103. package/.next/server/app/api/hub/status/route.js.nft.json +1 -1
  104. package/.next/server/app/api/hub/task-events/route.js +6 -6
  105. package/.next/server/app/api/hub/task-events/route.js.nft.json +1 -1
  106. package/.next/server/app/api/hub/tasks/route.js +6 -6
  107. package/.next/server/app/api/hub/tasks/route.js.nft.json +1 -1
  108. package/.next/server/app/api/hub/tmux/route.js +6 -6
  109. package/.next/server/app/api/hub/tmux/route.js.nft.json +1 -1
  110. package/.next/server/app/api/hub/tokens/route.js +6 -6
  111. package/.next/server/app/api/hub/tokens/route.js.nft.json +1 -1
  112. package/.next/server/app/favicon.ico/route.js +6 -6
  113. package/.next/server/app/favicon.ico/route.js.nft.json +1 -1
  114. package/.next/server/app/index.html +2 -2
  115. package/.next/server/app/index.rsc +14 -14
  116. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  117. package/.next/server/app/index.segments/_full.segment.rsc +14 -14
  118. package/.next/server/app/index.segments/_head.segment.rsc +4 -4
  119. package/.next/server/app/index.segments/_index.segment.rsc +7 -7
  120. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  121. package/.next/server/app/login/page/build-manifest.json +5 -5
  122. package/.next/server/app/login/page/next-font-manifest.json +1 -1
  123. package/.next/server/app/login/page.js +13 -13
  124. package/.next/server/app/login/page.js.nft.json +1 -1
  125. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  126. package/.next/server/app/login.html +2 -2
  127. package/.next/server/app/login.rsc +14 -14
  128. package/.next/server/app/login.segments/_full.segment.rsc +14 -14
  129. package/.next/server/app/login.segments/_head.segment.rsc +4 -4
  130. package/.next/server/app/login.segments/_index.segment.rsc +7 -7
  131. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  132. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
  133. package/.next/server/app/login.segments/login.segment.rsc +3 -3
  134. package/.next/server/app/logs/page/build-manifest.json +5 -5
  135. package/.next/server/app/logs/page/next-font-manifest.json +1 -1
  136. package/.next/server/app/logs/page.js +13 -13
  137. package/.next/server/app/logs/page.js.nft.json +1 -1
  138. package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
  139. package/.next/server/app/logs.html +2 -2
  140. package/.next/server/app/logs.rsc +14 -14
  141. package/.next/server/app/logs.segments/_full.segment.rsc +14 -14
  142. package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
  143. package/.next/server/app/logs.segments/_index.segment.rsc +7 -7
  144. package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
  145. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
  146. package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
  147. package/.next/server/app/messages/page/build-manifest.json +5 -5
  148. package/.next/server/app/messages/page/next-font-manifest.json +1 -1
  149. package/.next/server/app/messages/page.js +13 -13
  150. package/.next/server/app/messages/page.js.nft.json +1 -1
  151. package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
  152. package/.next/server/app/messages.html +2 -2
  153. package/.next/server/app/messages.rsc +14 -14
  154. package/.next/server/app/messages.segments/_full.segment.rsc +14 -14
  155. package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
  156. package/.next/server/app/messages.segments/_index.segment.rsc +7 -7
  157. package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
  158. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
  159. package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
  160. package/.next/server/app/node/page/build-manifest.json +5 -5
  161. package/.next/server/app/node/page/next-font-manifest.json +1 -1
  162. package/.next/server/app/node/page.js +13 -13
  163. package/.next/server/app/node/page.js.nft.json +1 -1
  164. package/.next/server/app/node/page_client-reference-manifest.js +1 -1
  165. package/.next/server/app/node.html +2 -2
  166. package/.next/server/app/node.rsc +14 -14
  167. package/.next/server/app/node.segments/_full.segment.rsc +14 -14
  168. package/.next/server/app/node.segments/_head.segment.rsc +4 -4
  169. package/.next/server/app/node.segments/_index.segment.rsc +7 -7
  170. package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
  171. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +4 -4
  172. package/.next/server/app/node.segments/node.segment.rsc +3 -3
  173. package/.next/server/app/nodes/page/build-manifest.json +5 -5
  174. package/.next/server/app/nodes/page/next-font-manifest.json +1 -1
  175. package/.next/server/app/nodes/page.js +13 -13
  176. package/.next/server/app/nodes/page.js.nft.json +1 -1
  177. package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
  178. package/.next/server/app/nodes.html +2 -2
  179. package/.next/server/app/nodes.rsc +14 -14
  180. package/.next/server/app/nodes.segments/_full.segment.rsc +14 -14
  181. package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
  182. package/.next/server/app/nodes.segments/_index.segment.rsc +7 -7
  183. package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
  184. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
  185. package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
  186. package/.next/server/app/page/build-manifest.json +5 -5
  187. package/.next/server/app/page/next-font-manifest.json +1 -1
  188. package/.next/server/app/page.js +13 -13
  189. package/.next/server/app/page.js.nft.json +1 -1
  190. package/.next/server/app/page_client-reference-manifest.js +1 -1
  191. package/.next/server/app/server-logs/page/build-manifest.json +5 -5
  192. package/.next/server/app/server-logs/page/next-font-manifest.json +1 -1
  193. package/.next/server/app/server-logs/page.js +13 -13
  194. package/.next/server/app/server-logs/page.js.nft.json +1 -1
  195. package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
  196. package/.next/server/app/server-logs.html +2 -2
  197. package/.next/server/app/server-logs.rsc +14 -14
  198. package/.next/server/app/server-logs.segments/_full.segment.rsc +14 -14
  199. package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
  200. package/.next/server/app/server-logs.segments/_index.segment.rsc +7 -7
  201. package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
  202. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +4 -4
  203. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
  204. package/.next/server/app/settings/networks/page/build-manifest.json +5 -5
  205. package/.next/server/app/settings/networks/page/next-font-manifest.json +1 -1
  206. package/.next/server/app/settings/networks/page.js +13 -13
  207. package/.next/server/app/settings/networks/page.js.nft.json +1 -1
  208. package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
  209. package/.next/server/app/settings/networks.html +2 -2
  210. package/.next/server/app/settings/networks.rsc +14 -14
  211. package/.next/server/app/settings/networks.segments/_full.segment.rsc +14 -14
  212. package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
  213. package/.next/server/app/settings/networks.segments/_index.segment.rsc +7 -7
  214. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
  215. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +4 -4
  216. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
  217. package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
  218. package/.next/server/app/settings/page/build-manifest.json +5 -5
  219. package/.next/server/app/settings/page/next-font-manifest.json +1 -1
  220. package/.next/server/app/settings/page.js +13 -13
  221. package/.next/server/app/settings/page.js.nft.json +1 -1
  222. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  223. package/.next/server/app/settings/tokens/page/build-manifest.json +5 -5
  224. package/.next/server/app/settings/tokens/page/next-font-manifest.json +1 -1
  225. package/.next/server/app/settings/tokens/page.js +13 -13
  226. package/.next/server/app/settings/tokens/page.js.nft.json +1 -1
  227. package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
  228. package/.next/server/app/settings/tokens.html +2 -2
  229. package/.next/server/app/settings/tokens.rsc +14 -14
  230. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +14 -14
  231. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
  232. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +7 -7
  233. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
  234. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +4 -4
  235. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
  236. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
  237. package/.next/server/app/settings.html +2 -2
  238. package/.next/server/app/settings.rsc +14 -14
  239. package/.next/server/app/settings.segments/_full.segment.rsc +14 -14
  240. package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
  241. package/.next/server/app/settings.segments/_index.segment.rsc +7 -7
  242. package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  243. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
  244. package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
  245. package/.next/server/app/tasks/[id]/page/build-manifest.json +5 -5
  246. package/.next/server/app/tasks/[id]/page/next-font-manifest.json +1 -1
  247. package/.next/server/app/tasks/[id]/page.js +13 -13
  248. package/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
  249. package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  250. package/.next/server/app/tasks/page/build-manifest.json +5 -5
  251. package/.next/server/app/tasks/page/next-font-manifest.json +1 -1
  252. package/.next/server/app/tasks/page.js +13 -13
  253. package/.next/server/app/tasks/page.js.nft.json +1 -1
  254. package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
  255. package/.next/server/app/tasks.html +2 -2
  256. package/.next/server/app/tasks.rsc +14 -14
  257. package/.next/server/app/tasks.segments/_full.segment.rsc +14 -14
  258. package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
  259. package/.next/server/app/tasks.segments/_index.segment.rsc +7 -7
  260. package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
  261. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
  262. package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
  263. package/.next/server/app-paths-manifest.json +1 -0
  264. package/.next/server/chunks/00jm_next_04f3gac._.js +10 -0
  265. package/.next/server/chunks/00jm_next_04f3gac._.js.map +1 -0
  266. package/.next/server/chunks/00jm_next_dist_esm_build_templates_app-route_02f430o.js +3 -0
  267. package/.next/server/chunks/00jm_next_dist_esm_build_templates_app-route_02f430o.js.map +1 -0
  268. package/.next/server/chunks/0ci9_nt-network-dashboard__next-internal_server_app_api_auth_v3_route_actions_0f5nbzr.js +3 -0
  269. package/.next/server/chunks/0ci9_nt-network-dashboard__next-internal_server_app_favicon_ico_route_actions_0t0eu9r.js +3 -0
  270. package/.next/server/chunks/0ykm__next-internal_server_app_api_anet_config_route_actions_0a~v55_.js +3 -0
  271. package/.next/server/chunks/0ykm__next-internal_server_app_api_auth_login_route_actions_0xnxfs4.js +3 -0
  272. package/.next/server/chunks/0ykm__next-internal_server_app_api_auth_logout_route_actions_0p8p0rn.js +3 -0
  273. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_audit-log_route_actions_06uquwi.js +3 -0
  274. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_auth_route_actions_0f_y1ey.js +3 -0
  275. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_broadcast_route_actions_0ao3j43.js +3 -0
  276. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_events_route_actions_0_5ndld.js +3 -0
  277. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_health_route_actions_0mpzl74.js +3 -0
  278. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_inbox_route_actions_0stoyto.js +3 -0
  279. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_license_route_actions_0a4.fuh.js +3 -0
  280. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_messages_route_actions_059rw1o.js +3 -0
  281. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_networks_route_actions_03sehpn.js +3 -0
  282. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_nodes_route_actions_11fbp28.js +3 -0
  283. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_register_route_actions_0l.d7~n.js +3 -0
  284. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_send_route_actions_0.p82vy.js +3 -0
  285. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_server-logs_route_actions_05~c5lb.js +3 -0
  286. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_servers_route_actions_08-m-cs.js +3 -0
  287. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_session_route_actions_0b9ef2i.js +3 -0
  288. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_stats_route_actions_03u65u0.js +3 -0
  289. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_status_route_actions_0_71ear.js +3 -0
  290. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_task-events_route_actions_07md_ee.js +3 -0
  291. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_tasks_route_actions_0er3uvk.js +3 -0
  292. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_tmux_route_actions_0o_1-8u.js +3 -0
  293. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_tokens_route_actions_09doluz.js +3 -0
  294. package/.next/server/chunks/[root-of-the-server]__0-2le8-._.js +3 -0
  295. package/.next/server/chunks/[root-of-the-server]__0-2le8-._.js.map +1 -0
  296. package/.next/server/chunks/[root-of-the-server]__0-g~15e._.js +3 -0
  297. package/.next/server/chunks/[root-of-the-server]__0-g~15e._.js.map +1 -0
  298. package/.next/server/chunks/[root-of-the-server]__00194.t._.js +3 -0
  299. package/.next/server/chunks/{[root-of-the-server]__03ueci3._.js.map → [root-of-the-server]__00194.t._.js.map} +1 -1
  300. package/.next/server/chunks/[root-of-the-server]__02p2n3n._.js +3 -0
  301. package/.next/server/chunks/[root-of-the-server]__02p2n3n._.js.map +1 -0
  302. package/.next/server/chunks/[root-of-the-server]__02y-i-p._.js +6 -0
  303. package/.next/server/chunks/[root-of-the-server]__02y-i-p._.js.map +1 -0
  304. package/.next/server/chunks/[root-of-the-server]__043tnx2._.js +3 -0
  305. package/.next/server/chunks/{[root-of-the-server]__0o4qfn9._.js.map → [root-of-the-server]__043tnx2._.js.map} +1 -1
  306. package/.next/server/chunks/[root-of-the-server]__04z62zk._.js +3 -0
  307. package/.next/server/chunks/[root-of-the-server]__04z62zk._.js.map +1 -0
  308. package/.next/server/chunks/[root-of-the-server]__06p6kkp._.js +3 -0
  309. package/.next/server/chunks/[root-of-the-server]__06p6kkp._.js.map +1 -0
  310. package/.next/server/chunks/[root-of-the-server]__07ztet2._.js +3 -0
  311. package/.next/server/chunks/[root-of-the-server]__07ztet2._.js.map +1 -0
  312. package/.next/server/chunks/[root-of-the-server]__08uyvdq._.js +20 -0
  313. package/.next/server/chunks/[root-of-the-server]__08uyvdq._.js.map +1 -0
  314. package/.next/server/chunks/[root-of-the-server]__0b7v51y._.js +3 -0
  315. package/.next/server/chunks/[root-of-the-server]__0b7v51y._.js.map +1 -0
  316. package/.next/server/chunks/[root-of-the-server]__0e3ucl1._.js +3 -0
  317. package/.next/server/chunks/{[root-of-the-server]__0rg9ifx._.js.map → [root-of-the-server]__0e3ucl1._.js.map} +1 -1
  318. package/.next/server/chunks/[root-of-the-server]__0e6yail._.js +3 -0
  319. package/.next/server/chunks/[root-of-the-server]__0e6yail._.js.map +1 -0
  320. package/.next/server/chunks/[root-of-the-server]__0gx2_6a._.js +3 -0
  321. package/.next/server/chunks/[root-of-the-server]__0gx2_6a._.js.map +1 -0
  322. package/.next/server/chunks/[root-of-the-server]__0hcnyp.._.js +3 -0
  323. package/.next/server/chunks/{[root-of-the-server]__0o29v8c._.js.map → [root-of-the-server]__0hcnyp.._.js.map} +1 -1
  324. package/.next/server/chunks/[root-of-the-server]__0hg3_8r._.js +3 -0
  325. package/.next/server/chunks/[root-of-the-server]__0hg3_8r._.js.map +1 -0
  326. package/.next/server/chunks/[root-of-the-server]__0mxw4vb._.js +3 -0
  327. package/.next/server/chunks/[root-of-the-server]__0mxw4vb._.js.map +1 -0
  328. package/.next/server/chunks/[root-of-the-server]__0mzopco._.js +3 -0
  329. package/.next/server/chunks/[root-of-the-server]__0mzopco._.js.map +1 -0
  330. package/.next/server/chunks/[root-of-the-server]__0o45a4q._.js +3 -0
  331. package/.next/server/chunks/{[root-of-the-server]__0p6tutt._.js.map → [root-of-the-server]__0o45a4q._.js.map} +1 -1
  332. package/.next/server/chunks/[root-of-the-server]__0q-teo6._.js +3 -0
  333. package/.next/server/chunks/[root-of-the-server]__0q-teo6._.js.map +1 -0
  334. package/.next/server/chunks/[root-of-the-server]__0qcxxzf._.js +3 -0
  335. package/.next/server/chunks/[root-of-the-server]__0qcxxzf._.js.map +1 -0
  336. package/.next/server/chunks/[root-of-the-server]__0rovr5-._.js +3 -0
  337. package/.next/server/chunks/[root-of-the-server]__0rovr5-._.js.map +1 -0
  338. package/.next/server/chunks/[root-of-the-server]__0tx8s8i._.js +3 -0
  339. package/.next/server/chunks/{[root-of-the-server]__0~h2uqi._.js.map → [root-of-the-server]__0tx8s8i._.js.map} +1 -1
  340. package/.next/server/chunks/[root-of-the-server]__0xtp-et._.js +3 -0
  341. package/.next/server/chunks/[root-of-the-server]__0xtp-et._.js.map +1 -0
  342. package/.next/server/chunks/[root-of-the-server]__10pvabe._.js +3 -0
  343. package/.next/server/chunks/[root-of-the-server]__10pvabe._.js.map +1 -0
  344. package/.next/server/chunks/[root-of-the-server]__10t11zq._.js +3 -0
  345. package/.next/server/chunks/[root-of-the-server]__10t11zq._.js.map +1 -0
  346. package/.next/server/chunks/[root-of-the-server]__12r~omy._.js +3 -0
  347. package/.next/server/chunks/[root-of-the-server]__12r~omy._.js.map +1 -0
  348. package/.next/server/chunks/[root-of-the-server]__1389~cs._.js +3 -0
  349. package/.next/server/chunks/[root-of-the-server]__1389~cs._.js.map +1 -0
  350. package/.next/server/chunks/[root-of-the-server]__13n8t..._.js +3 -0
  351. package/.next/server/chunks/[root-of-the-server]__13n8t..._.js.map +1 -0
  352. package/.next/server/chunks/[turbopack]_runtime.js +1 -1
  353. package/.next/server/chunks/ssr/{node_modules_0r_.s40._.js → 00jm_03rn9vn._.js} +2 -2
  354. package/.next/server/chunks/ssr/00jm_03rn9vn._.js.map +1 -0
  355. package/.next/server/chunks/ssr/{node_modules_09w7yel._.js → 00jm_059ns68._.js} +3 -3
  356. package/.next/server/chunks/ssr/00jm_059ns68._.js.map +1 -0
  357. package/.next/server/chunks/ssr/00jm_09ae-q0._.js +3 -0
  358. package/.next/server/chunks/ssr/00jm_09ae-q0._.js.map +1 -0
  359. package/.next/server/chunks/ssr/00jm_0j7__z.._.js +3 -0
  360. package/.next/server/chunks/ssr/00jm_0j7__z.._.js.map +1 -0
  361. package/.next/server/chunks/ssr/00jm_next_01-0urn._.js +6 -0
  362. package/.next/server/chunks/ssr/00jm_next_01-0urn._.js.map +1 -0
  363. package/.next/server/chunks/ssr/00jm_next_dist_05rlh5d._.js +6 -0
  364. package/.next/server/chunks/ssr/00jm_next_dist_05rlh5d._.js.map +1 -0
  365. package/.next/server/chunks/ssr/00jm_next_dist_client_components_0gkudcq._.js +3 -0
  366. package/.next/server/chunks/ssr/00jm_next_dist_client_components_0gkudcq._.js.map +1 -0
  367. package/.next/server/chunks/ssr/00jm_next_dist_client_components_builtin_forbidden_0ecus3e.js +3 -0
  368. package/.next/server/chunks/ssr/00jm_next_dist_client_components_builtin_forbidden_0ecus3e.js.map +1 -0
  369. package/.next/server/chunks/ssr/00jm_next_dist_client_components_builtin_global-error_07n8pg2.js +3 -0
  370. package/.next/server/chunks/ssr/00jm_next_dist_client_components_builtin_global-error_07n8pg2.js.map +1 -0
  371. package/.next/server/chunks/ssr/00jm_next_dist_client_components_builtin_unauthorized_0r0uq4j.js +3 -0
  372. package/.next/server/chunks/ssr/00jm_next_dist_client_components_builtin_unauthorized_0r0uq4j.js.map +1 -0
  373. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_002l7yi.js → 00jm_next_dist_esm_build_templates_app-page_0..ddwm.js} +3 -3
  374. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0..ddwm.js.map +1 -0
  375. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_0qm1-a1.js → 00jm_next_dist_esm_build_templates_app-page_02bxi6j.js} +3 -3
  376. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_02bxi6j.js.map +1 -0
  377. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_02suzhc.js → 00jm_next_dist_esm_build_templates_app-page_05oassd.js} +3 -3
  378. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_05oassd.js.map +1 -0
  379. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_0dm~kj8.js → 00jm_next_dist_esm_build_templates_app-page_05vxtby.js} +3 -3
  380. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_05vxtby.js.map +1 -0
  381. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_0uccf9z.js → 00jm_next_dist_esm_build_templates_app-page_05~eml4.js} +3 -3
  382. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_05~eml4.js.map +1 -0
  383. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_0l-fh0i.js → 00jm_next_dist_esm_build_templates_app-page_0_-g_q2.js} +3 -3
  384. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0_-g_q2.js.map +1 -0
  385. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_0uv6jyc.js → 00jm_next_dist_esm_build_templates_app-page_0_ec0xu.js} +3 -3
  386. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0_ec0xu.js.map +1 -0
  387. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0czb6wz.js +4 -0
  388. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0czb6wz.js.map +1 -0
  389. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_13s07li.js → 00jm_next_dist_esm_build_templates_app-page_0e-cn.p.js} +3 -3
  390. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0e-cn.p.js.map +1 -0
  391. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_0o9vt1n.js → 00jm_next_dist_esm_build_templates_app-page_0k8_sr8.js} +3 -3
  392. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0k8_sr8.js.map +1 -0
  393. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_0nsg22r.js → 00jm_next_dist_esm_build_templates_app-page_0lt_3bw.js} +3 -3
  394. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0lt_3bw.js.map +1 -0
  395. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_0d2yq5t.js → 00jm_next_dist_esm_build_templates_app-page_0uyh3yt.js} +3 -3
  396. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0uyh3yt.js.map +1 -0
  397. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_0foejvu.js → 00jm_next_dist_esm_build_templates_app-page_0v-a2ps.js} +3 -3
  398. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_0v-a2ps.js.map +1 -0
  399. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_12.4r9~.js +4 -0
  400. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_12.4r9~.js.map +1 -0
  401. package/.next/server/chunks/ssr/{node_modules_next_dist_esm_build_templates_app-page_07uu-xq.js → 00jm_next_dist_esm_build_templates_app-page_13mqkay.js} +3 -3
  402. package/.next/server/chunks/ssr/00jm_next_dist_esm_build_templates_app-page_13mqkay.js.map +1 -0
  403. package/.next/server/chunks/ssr/0dwh_ent-network-dashboard__next-internal_server_app_server-logs_page_actions_0hkk815.js +3 -0
  404. package/.next/server/chunks/ssr/0yas_gent-network-dashboard__next-internal_server_app__not-found_page_actions_0u.f-rz.js +3 -0
  405. package/.next/server/chunks/ssr/0yas_gent-network-dashboard__next-internal_server_app_tasks_[id]_page_actions_0r3~lho.js +3 -0
  406. package/.next/server/chunks/ssr/0ykm__next-internal_server_app__global-error_page_actions_100fxls.js +3 -0
  407. package/.next/server/chunks/ssr/0ykm__next-internal_server_app_settings_networks_page_actions_0jgy57m.js +3 -0
  408. package/.next/server/chunks/ssr/0ykm__next-internal_server_app_settings_tokens_page_actions_0l5h.k~.js +3 -0
  409. package/.next/server/chunks/ssr/[root-of-the-server]__030vg4n._.js +3 -0
  410. package/.next/server/chunks/ssr/[root-of-the-server]__030vg4n._.js.map +1 -0
  411. package/.next/server/chunks/ssr/[root-of-the-server]__03b.f76._.js +3 -0
  412. package/.next/server/chunks/ssr/[root-of-the-server]__03b.f76._.js.map +1 -0
  413. package/.next/server/chunks/ssr/[root-of-the-server]__063~dul._.js +3 -0
  414. package/.next/server/chunks/ssr/[root-of-the-server]__063~dul._.js.map +1 -0
  415. package/.next/server/chunks/ssr/[root-of-the-server]__07io.d8._.js +3 -0
  416. package/.next/server/chunks/ssr/[root-of-the-server]__07io.d8._.js.map +1 -0
  417. package/.next/server/chunks/ssr/[root-of-the-server]__08d6lp4._.js +3 -0
  418. package/.next/server/chunks/ssr/[root-of-the-server]__08d6lp4._.js.map +1 -0
  419. package/.next/server/chunks/ssr/[root-of-the-server]__09f1wn6._.js +3 -0
  420. package/.next/server/chunks/ssr/[root-of-the-server]__09f1wn6._.js.map +1 -0
  421. package/.next/server/chunks/ssr/[root-of-the-server]__0c4xtkg._.js +3 -0
  422. package/.next/server/chunks/ssr/[root-of-the-server]__0c4xtkg._.js.map +1 -0
  423. package/.next/server/chunks/ssr/[root-of-the-server]__0erfzjn._.js +3 -0
  424. package/.next/server/chunks/ssr/[root-of-the-server]__0erfzjn._.js.map +1 -0
  425. package/.next/server/chunks/ssr/[root-of-the-server]__0ezjwje._.js +3 -0
  426. package/.next/server/chunks/ssr/[root-of-the-server]__0ezjwje._.js.map +1 -0
  427. package/.next/server/chunks/ssr/[root-of-the-server]__0fhoq8i._.js +3 -0
  428. package/.next/server/chunks/ssr/[root-of-the-server]__0fhoq8i._.js.map +1 -0
  429. package/.next/server/chunks/ssr/{[root-of-the-server]__08qr2ji._.js → [root-of-the-server]__0gc.4uk._.js} +3 -3
  430. package/.next/server/chunks/ssr/[root-of-the-server]__0gc.4uk._.js.map +1 -0
  431. package/.next/server/chunks/ssr/[root-of-the-server]__0h_p~-4._.js +19 -0
  432. package/.next/server/chunks/ssr/[root-of-the-server]__0h_p~-4._.js.map +1 -0
  433. package/.next/server/chunks/ssr/[root-of-the-server]__0jdr1e5._.js +3 -0
  434. package/.next/server/chunks/ssr/[root-of-the-server]__0jdr1e5._.js.map +1 -0
  435. package/.next/server/chunks/ssr/[root-of-the-server]__0lw16mu._.js +3 -0
  436. package/.next/server/chunks/ssr/[root-of-the-server]__0lw16mu._.js.map +1 -0
  437. package/.next/server/chunks/ssr/[root-of-the-server]__0mjly~-._.js +33 -0
  438. package/.next/server/chunks/ssr/[root-of-the-server]__0mjly~-._.js.map +1 -0
  439. package/.next/server/chunks/ssr/[root-of-the-server]__0nw~zhp._.js +3 -0
  440. package/.next/server/chunks/ssr/[root-of-the-server]__0nw~zhp._.js.map +1 -0
  441. package/.next/server/chunks/ssr/[root-of-the-server]__0p-95r_._.js +3 -0
  442. package/.next/server/chunks/ssr/[root-of-the-server]__0p-95r_._.js.map +1 -0
  443. package/.next/server/chunks/ssr/[root-of-the-server]__0u5aqkk._.js +8 -0
  444. package/.next/server/chunks/ssr/[root-of-the-server]__0u5aqkk._.js.map +1 -0
  445. package/.next/server/chunks/ssr/[root-of-the-server]__0ue5.fy._.js +3 -0
  446. package/.next/server/chunks/ssr/[root-of-the-server]__0ue5.fy._.js.map +1 -0
  447. package/.next/server/chunks/ssr/[root-of-the-server]__0uggxvx._.js +3 -0
  448. package/.next/server/chunks/ssr/[root-of-the-server]__0uggxvx._.js.map +1 -0
  449. package/.next/server/chunks/ssr/[root-of-the-server]__0zjk5ro._.js +3 -0
  450. package/.next/server/chunks/ssr/[root-of-the-server]__0zjk5ro._.js.map +1 -0
  451. package/.next/server/chunks/ssr/[root-of-the-server]__0zz3v7a._.js +3 -0
  452. package/.next/server/chunks/ssr/[root-of-the-server]__0zz3v7a._.js.map +1 -0
  453. package/.next/server/chunks/ssr/[root-of-the-server]__0~y12ef._.js +3 -0
  454. package/.next/server/chunks/ssr/[root-of-the-server]__0~y12ef._.js.map +1 -0
  455. package/.next/server/chunks/ssr/[root-of-the-server]__129sc_9._.js +3 -0
  456. package/.next/server/chunks/ssr/[root-of-the-server]__129sc_9._.js.map +1 -0
  457. package/.next/server/chunks/ssr/[turbopack]_runtime.js +1 -1
  458. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js +4 -0
  459. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js.map +1 -0
  460. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_admin_page_actions_0i~.x~j.js +3 -0
  461. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_login_page_actions_0frwl15.js +3 -0
  462. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_logs_page_actions_0xa2ox_.js +3 -0
  463. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_messages_page_actions_10j3tg4.js +3 -0
  464. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_node_page_actions_0sl4ntd.js +3 -0
  465. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_nodes_page_actions_0cpgkfs.js +3 -0
  466. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_page_actions_08nuo36.js +3 -0
  467. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_settings_page_actions_0iv.lmk.js +3 -0
  468. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_tasks_page_actions_08c7qlx.js +3 -0
  469. package/.next/server/chunks/ssr/agent-network-dashboard__next-internal_server_app_tasks_page_actions_08c7qlx.js.map +1 -0
  470. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js +3 -0
  471. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js.map +1 -0
  472. package/.next/server/chunks/ssr/agent-network-dashboard_app_057q.ne._.js +3 -0
  473. package/.next/server/chunks/ssr/agent-network-dashboard_app_057q.ne._.js.map +1 -0
  474. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js +3 -0
  475. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js.map +1 -0
  476. package/.next/server/chunks/ssr/agent-network-dashboard_app_0_d45-d._.js +3 -0
  477. package/.next/server/chunks/ssr/agent-network-dashboard_app_0_d45-d._.js.map +1 -0
  478. package/.next/server/chunks/ssr/agent-network-dashboard_app_0i3759l._.js +3 -0
  479. package/.next/server/chunks/ssr/agent-network-dashboard_app_0i3759l._.js.map +1 -0
  480. package/.next/server/chunks/ssr/agent-network-dashboard_app_0xgney8._.js +3 -0
  481. package/.next/server/chunks/ssr/agent-network-dashboard_app_0xgney8._.js.map +1 -0
  482. package/.next/server/chunks/ssr/agent-network-dashboard_app_10hjgv4._.js +3 -0
  483. package/.next/server/chunks/ssr/agent-network-dashboard_app_10hjgv4._.js.map +1 -0
  484. package/.next/server/chunks/ssr/agent-network-dashboard_app_1153xeb._.js +9 -0
  485. package/.next/server/chunks/ssr/agent-network-dashboard_app_1153xeb._.js.map +1 -0
  486. package/.next/server/chunks/ssr/agent-network-dashboard_app_12l4oto._.js +3 -0
  487. package/.next/server/chunks/ssr/agent-network-dashboard_app_12l4oto._.js.map +1 -0
  488. package/.next/server/chunks/ssr/{app_components_08ys5y3._.js → agent-network-dashboard_app_components_0mvyi-4._.js} +3 -3
  489. package/.next/server/chunks/ssr/agent-network-dashboard_app_components_0mvyi-4._.js.map +1 -0
  490. package/.next/server/chunks/ssr/{app_server-logs_page_tsx_0_kpp1p._.js → agent-network-dashboard_app_server-logs_page_tsx_0dg.l_8._.js} +2 -2
  491. package/.next/server/chunks/ssr/agent-network-dashboard_app_server-logs_page_tsx_0dg.l_8._.js.map +1 -0
  492. package/.next/server/chunks/ssr/agent-network-dashboard_app_tasks_page_tsx_0mwxy4z._.js +3 -0
  493. package/.next/server/chunks/ssr/agent-network-dashboard_app_tasks_page_tsx_0mwxy4z._.js.map +1 -0
  494. package/.next/server/middleware-build-manifest.js +8 -8
  495. package/.next/server/middleware.js +3 -3
  496. package/.next/server/middleware.js.nft.json +1 -1
  497. package/.next/server/next-font-manifest.js +1 -1
  498. package/.next/server/next-font-manifest.json +14 -14
  499. package/.next/server/pages/404.html +2 -2
  500. package/.next/server/pages/500.html +1 -1
  501. package/.next/server/server-reference-manifest.js +1 -1
  502. package/.next/server/server-reference-manifest.json +1 -1
  503. package/.next/static/chunks/0-mpa_947ipeq.js +1 -0
  504. package/.next/static/chunks/{0gtbwcilfu6b7.js → 0.66f3.rtcybb.js} +1 -1
  505. package/.next/static/chunks/01i3dw_l_utr4.js +2 -0
  506. package/.next/static/chunks/01lmbsd37fybu.js +1 -0
  507. package/.next/static/chunks/039-b.v_9-_rm.js +1 -0
  508. package/.next/static/chunks/03o.h6kvmw4l_.js +1 -0
  509. package/.next/static/chunks/03~~oirxz7~vc.js +1 -0
  510. package/.next/static/chunks/05oi25hh7b81r.js +31 -0
  511. package/.next/static/chunks/05uk96gc~9mni.js +1 -0
  512. package/.next/static/chunks/072so8rfx2tt-.css +2 -0
  513. package/.next/static/chunks/07~8sx3_5uiox.js +1 -0
  514. package/.next/static/chunks/08kf-.bgs10ze.js +1 -0
  515. package/.next/static/chunks/{0pqt~8bl3ukh4.js → 0_pxg.4pebw__.js} +2 -2
  516. package/.next/static/chunks/0b8wnt4put-5n.js +2 -0
  517. package/.next/static/chunks/{01xlw8hd842-c.js → 0eov~nkwaf-qa.js} +1 -1
  518. package/.next/static/chunks/0glmrdj9112tc.js +7 -0
  519. package/.next/static/chunks/0h18r~c2scsl3.js +1 -0
  520. package/.next/static/chunks/{0w1zwt8kq_7nb.js → 0ku0fjqlm9mca.js} +1 -1
  521. package/.next/static/chunks/0rxrez_cjn.eh.js +1 -0
  522. package/.next/static/chunks/0tufo8a3rcp0n.js +1 -0
  523. package/.next/static/chunks/0tvn2l1pc.h65.js +1 -0
  524. package/.next/static/chunks/0u3b93reo9xci.js +1 -0
  525. package/.next/static/chunks/0x8x.29hkju5a.js +1 -0
  526. package/.next/static/chunks/121d8yvfl8l_..js +7 -0
  527. package/.next/static/chunks/174re5ar~kxne.js +1 -0
  528. package/.next/static/chunks/{turbopack-01f1xs~cq_~si.js → turbopack-0.7dvsf9rllt0.js} +1 -1
  529. package/.next/trace +2 -2
  530. package/.next/trace-build +1 -1
  531. package/.next/types/routes.d.ts +2 -1
  532. package/.next/types/validator.ts +9 -0
  533. package/app/api/hub/servers/route.ts +39 -0
  534. package/app/components/AppShell.tsx +2 -0
  535. package/app/components/ChatPopover.tsx +43 -7
  536. package/app/components/CommandPalette.tsx +67 -0
  537. package/app/components/HelpOverlay.tsx +71 -0
  538. package/app/components/ServersDrawer.tsx +206 -0
  539. package/app/components/TaskChatPanel.tsx +39 -39
  540. package/app/components/TopoGraph.tsx +2159 -182
  541. package/app/components/types.ts +7 -0
  542. package/app/components/utils.ts +8 -7
  543. package/app/globals.css +19 -1
  544. package/app/lib/time.ts +36 -0
  545. package/app/lib/vendorIdentity.ts +145 -0
  546. package/app/page.tsx +18 -1
  547. package/package.json +1 -1
  548. package/scripts/chat-popover-test.mjs +91 -0
  549. package/scripts/chat-theme-test.mjs +98 -0
  550. package/scripts/help-overlay-topo-test.mjs +44 -0
  551. package/scripts/servers-drawer-live-test.mjs +96 -0
  552. package/scripts/topo-active-filter-pill-test.mjs +101 -0
  553. package/scripts/topo-active-links-chip-hover-test.mjs +103 -0
  554. package/scripts/topo-active-links-chip-test.mjs +73 -0
  555. package/scripts/topo-arrival-ping-test.mjs +87 -0
  556. package/scripts/topo-arrow-tier-test.mjs +61 -0
  557. package/scripts/topo-auto-fit-test.mjs +73 -0
  558. package/scripts/topo-chat-focus-ring-test.mjs +77 -0
  559. package/scripts/topo-chip-pin-mirror-test.mjs +113 -0
  560. package/scripts/topo-click-ripple-test.mjs +66 -0
  561. package/scripts/topo-cmdk-layout-view-test.mjs +108 -0
  562. package/scripts/topo-cmdk-pin-test.mjs +123 -0
  563. package/scripts/topo-cmdk-vendor-clear-test.mjs +133 -0
  564. package/scripts/topo-cwd-tooltip-test.mjs +64 -0
  565. package/scripts/topo-dblclick-reset-test.mjs +74 -0
  566. package/scripts/topo-dispatch-pulse-test.mjs +83 -0
  567. package/scripts/topo-edge-endpoint-highlight-test.mjs +111 -0
  568. package/scripts/topo-edge-focus-test.mjs +113 -0
  569. package/scripts/topo-edge-hover-test.mjs +93 -0
  570. package/scripts/topo-edge-tooltip-test.mjs +70 -0
  571. package/scripts/topo-esc-clears-pin-test.mjs +98 -0
  572. package/scripts/topo-filter-pill-body-click-test.mjs +112 -0
  573. package/scripts/topo-filter-pill-count-test.mjs +110 -0
  574. package/scripts/topo-filter-pill-mobile-test.mjs +90 -0
  575. package/scripts/topo-fit-key-test.mjs +106 -0
  576. package/scripts/topo-freshness-chip-test.mjs +74 -0
  577. package/scripts/topo-group-box-pinned-state-test.mjs +95 -0
  578. package/scripts/topo-group-count-test.mjs +68 -0
  579. package/scripts/topo-group-hover-test.mjs +82 -0
  580. package/scripts/topo-group-label-hover-test.mjs +119 -0
  581. package/scripts/topo-group-pin-test.mjs +119 -0
  582. package/scripts/topo-group-status-pips-test.mjs +103 -0
  583. package/scripts/topo-groupbox-live-test.mjs +100 -0
  584. package/scripts/topo-groupbox-test.mjs +120 -0
  585. package/scripts/topo-help-overlay-r59-test.mjs +67 -0
  586. package/scripts/topo-help-overlay-r65-test.mjs +64 -0
  587. package/scripts/topo-help-overlay-r70-test.mjs +63 -0
  588. package/scripts/topo-help-overlay-r78-test.mjs +59 -0
  589. package/scripts/topo-help-overlay-r81-test.mjs +58 -0
  590. package/scripts/topo-help-overlay-r87-test.mjs +64 -0
  591. package/scripts/topo-help-overlay-r91-test.mjs +65 -0
  592. package/scripts/topo-hub-busyness-test.mjs +100 -0
  593. package/scripts/topo-hub-click-fit-test.mjs +137 -0
  594. package/scripts/topo-hub-title-test.mjs +93 -0
  595. package/scripts/topo-ingroup-edge-test.mjs +103 -0
  596. package/scripts/topo-label-lift-test.mjs +119 -0
  597. package/scripts/topo-label-scale-test.mjs +78 -0
  598. package/scripts/topo-lastseen-tooltip-test.mjs +59 -0
  599. package/scripts/topo-layout-key-test.mjs +91 -0
  600. package/scripts/topo-layout-test.mjs +113 -0
  601. package/scripts/topo-legend-hover-test.mjs +113 -0
  602. package/scripts/topo-legend-pin-test.mjs +128 -0
  603. package/scripts/topo-minimap-test.mjs +68 -0
  604. package/scripts/topo-mobile-chips-test.mjs +103 -0
  605. package/scripts/topo-node-hover-lift-test.mjs +85 -0
  606. package/scripts/topo-offline-dim-test.mjs +70 -0
  607. package/scripts/topo-overlap-test.mjs +164 -0
  608. package/scripts/topo-p0round-test.mjs +166 -0
  609. package/scripts/topo-pan-cursor-test.mjs +62 -0
  610. package/scripts/topo-panel-elevation-test.mjs +76 -0
  611. package/scripts/topo-pin-persistence-test.mjs +105 -0
  612. package/scripts/topo-popover-meta-test.mjs +78 -0
  613. package/scripts/topo-pressure-bar-test.mjs +69 -0
  614. package/scripts/topo-pressure-hover-test.mjs +123 -0
  615. package/scripts/topo-pressure-pin-test.mjs +119 -0
  616. package/scripts/topo-pulse-rate-test.mjs +71 -0
  617. package/scripts/topo-recent-row-hover-test.mjs +122 -0
  618. package/scripts/topo-recent-signal-empty-test.mjs +63 -0
  619. package/scripts/topo-reduced-motion-test.mjs +77 -0
  620. package/scripts/topo-rename-test.mjs +107 -0
  621. package/scripts/topo-status-chip-hover-test.mjs +101 -0
  622. package/scripts/topo-tier-ring-occupancy-test.mjs +87 -0
  623. package/scripts/topo-tier-ring-test.mjs +69 -0
  624. package/scripts/topo-tier-ring-tint-test.mjs +110 -0
  625. package/scripts/topo-tier-stagger-test.mjs +89 -0
  626. package/scripts/topo-tz-ghost-test.mjs +69 -0
  627. package/scripts/topo-vendor-chip-hover-test.mjs +111 -0
  628. package/scripts/topo-vendor-pill-test.mjs +125 -0
  629. package/scripts/topo-vendor-pin-test.mjs +125 -0
  630. package/scripts/topo-vendor-test.mjs +97 -0
  631. package/scripts/topo-wheel-zoom-test.mjs +79 -0
  632. package/scripts/topo-zoom-keys-test.mjs +89 -0
  633. package/.next/build/chunks/[turbopack-node]_transforms_postcss_ts_06e.r3r._.js +0 -13
  634. package/.next/build/chunks/node_modules_13sb.px._.js.map +0 -47
  635. package/.next/server/chunks/[root-of-the-server]__0-mykaf._.js +0 -3
  636. package/.next/server/chunks/[root-of-the-server]__0-mykaf._.js.map +0 -1
  637. package/.next/server/chunks/[root-of-the-server]__0.d_kqy._.js +0 -3
  638. package/.next/server/chunks/[root-of-the-server]__0.d_kqy._.js.map +0 -1
  639. package/.next/server/chunks/[root-of-the-server]__02pocyg._.js +0 -3
  640. package/.next/server/chunks/[root-of-the-server]__02pocyg._.js.map +0 -1
  641. package/.next/server/chunks/[root-of-the-server]__03ueci3._.js +0 -3
  642. package/.next/server/chunks/[root-of-the-server]__04aud7x._.js +0 -3
  643. package/.next/server/chunks/[root-of-the-server]__04aud7x._.js.map +0 -1
  644. package/.next/server/chunks/[root-of-the-server]__05fnt3n._.js +0 -3
  645. package/.next/server/chunks/[root-of-the-server]__05fnt3n._.js.map +0 -1
  646. package/.next/server/chunks/[root-of-the-server]__077tsd.._.js +0 -20
  647. package/.next/server/chunks/[root-of-the-server]__077tsd.._.js.map +0 -1
  648. package/.next/server/chunks/[root-of-the-server]__08wy1jr._.js +0 -3
  649. package/.next/server/chunks/[root-of-the-server]__08wy1jr._.js.map +0 -1
  650. package/.next/server/chunks/[root-of-the-server]__0adhu1m._.js +0 -3
  651. package/.next/server/chunks/[root-of-the-server]__0adhu1m._.js.map +0 -1
  652. package/.next/server/chunks/[root-of-the-server]__0aqv02v._.js +0 -3
  653. package/.next/server/chunks/[root-of-the-server]__0aqv02v._.js.map +0 -1
  654. package/.next/server/chunks/[root-of-the-server]__0cawhfq._.js +0 -3
  655. package/.next/server/chunks/[root-of-the-server]__0cawhfq._.js.map +0 -1
  656. package/.next/server/chunks/[root-of-the-server]__0d_-_k.._.js +0 -3
  657. package/.next/server/chunks/[root-of-the-server]__0d_-_k.._.js.map +0 -1
  658. package/.next/server/chunks/[root-of-the-server]__0f6bxfl._.js +0 -3
  659. package/.next/server/chunks/[root-of-the-server]__0f6bxfl._.js.map +0 -1
  660. package/.next/server/chunks/[root-of-the-server]__0h15edq._.js +0 -3
  661. package/.next/server/chunks/[root-of-the-server]__0h15edq._.js.map +0 -1
  662. package/.next/server/chunks/[root-of-the-server]__0k.vegl._.js +0 -3
  663. package/.next/server/chunks/[root-of-the-server]__0k.vegl._.js.map +0 -1
  664. package/.next/server/chunks/[root-of-the-server]__0kltbce._.js +0 -3
  665. package/.next/server/chunks/[root-of-the-server]__0kltbce._.js.map +0 -1
  666. package/.next/server/chunks/[root-of-the-server]__0mn04fw._.js +0 -3
  667. package/.next/server/chunks/[root-of-the-server]__0mn04fw._.js.map +0 -1
  668. package/.next/server/chunks/[root-of-the-server]__0o29v8c._.js +0 -3
  669. package/.next/server/chunks/[root-of-the-server]__0o4qfn9._.js +0 -3
  670. package/.next/server/chunks/[root-of-the-server]__0o7ya7r._.js +0 -3
  671. package/.next/server/chunks/[root-of-the-server]__0o7ya7r._.js.map +0 -1
  672. package/.next/server/chunks/[root-of-the-server]__0p3fo6l._.js +0 -6
  673. package/.next/server/chunks/[root-of-the-server]__0p3fo6l._.js.map +0 -1
  674. package/.next/server/chunks/[root-of-the-server]__0p6tutt._.js +0 -3
  675. package/.next/server/chunks/[root-of-the-server]__0pn3wsx._.js +0 -3
  676. package/.next/server/chunks/[root-of-the-server]__0pn3wsx._.js.map +0 -1
  677. package/.next/server/chunks/[root-of-the-server]__0rg9ifx._.js +0 -3
  678. package/.next/server/chunks/[root-of-the-server]__0x.-194._.js +0 -3
  679. package/.next/server/chunks/[root-of-the-server]__0x.-194._.js.map +0 -1
  680. package/.next/server/chunks/[root-of-the-server]__0x.a_yh._.js +0 -3
  681. package/.next/server/chunks/[root-of-the-server]__0x.a_yh._.js.map +0 -1
  682. package/.next/server/chunks/[root-of-the-server]__0~h2uqi._.js +0 -3
  683. package/.next/server/chunks/[root-of-the-server]__139~d6a._.js +0 -3
  684. package/.next/server/chunks/[root-of-the-server]__139~d6a._.js.map +0 -1
  685. package/.next/server/chunks/_next-internal_server_app_api_anet_config_route_actions_0xzy73j.js +0 -3
  686. package/.next/server/chunks/_next-internal_server_app_api_auth_login_route_actions_0zukc38.js +0 -3
  687. package/.next/server/chunks/_next-internal_server_app_api_auth_logout_route_actions_0fm~3ij.js +0 -3
  688. package/.next/server/chunks/_next-internal_server_app_api_auth_v3_route_actions_0ndzs5l.js +0 -3
  689. package/.next/server/chunks/_next-internal_server_app_api_hub_audit-log_route_actions_11k8fqt.js +0 -3
  690. package/.next/server/chunks/_next-internal_server_app_api_hub_auth_route_actions_0gs7ttk.js +0 -3
  691. package/.next/server/chunks/_next-internal_server_app_api_hub_broadcast_route_actions_0ads-kd.js +0 -3
  692. package/.next/server/chunks/_next-internal_server_app_api_hub_events_route_actions_0opktby.js +0 -3
  693. package/.next/server/chunks/_next-internal_server_app_api_hub_health_route_actions_0tzzt~x.js +0 -3
  694. package/.next/server/chunks/_next-internal_server_app_api_hub_inbox_route_actions_0xvslrg.js +0 -3
  695. package/.next/server/chunks/_next-internal_server_app_api_hub_license_route_actions_0i3tlr3.js +0 -3
  696. package/.next/server/chunks/_next-internal_server_app_api_hub_messages_route_actions_08-gi-s.js +0 -3
  697. package/.next/server/chunks/_next-internal_server_app_api_hub_networks_route_actions_02vejq2.js +0 -3
  698. package/.next/server/chunks/_next-internal_server_app_api_hub_nodes_route_actions_0nx8ryn.js +0 -3
  699. package/.next/server/chunks/_next-internal_server_app_api_hub_register_route_actions_086wi_3.js +0 -3
  700. package/.next/server/chunks/_next-internal_server_app_api_hub_send_route_actions_0-ga_ug.js +0 -3
  701. package/.next/server/chunks/_next-internal_server_app_api_hub_server-logs_route_actions_00euexv.js +0 -3
  702. package/.next/server/chunks/_next-internal_server_app_api_hub_session_route_actions_041wa62.js +0 -3
  703. package/.next/server/chunks/_next-internal_server_app_api_hub_stats_route_actions_1242~od.js +0 -3
  704. package/.next/server/chunks/_next-internal_server_app_api_hub_status_route_actions_0aml09a.js +0 -3
  705. package/.next/server/chunks/_next-internal_server_app_api_hub_task-events_route_actions_02v1tmm.js +0 -3
  706. package/.next/server/chunks/_next-internal_server_app_api_hub_tasks_route_actions_0nl77v4.js +0 -3
  707. package/.next/server/chunks/_next-internal_server_app_api_hub_tmux_route_actions_0u~aaza.js +0 -3
  708. package/.next/server/chunks/_next-internal_server_app_api_hub_tokens_route_actions_09hzf_1.js +0 -3
  709. package/.next/server/chunks/_next-internal_server_app_favicon_ico_route_actions_095lj93.js +0 -3
  710. package/.next/server/chunks/node_modules_next_11synfn._.js +0 -10
  711. package/.next/server/chunks/node_modules_next_11synfn._.js.map +0 -1
  712. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_0yubv2d.js +0 -3
  713. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_0yubv2d.js.map +0 -1
  714. package/.next/server/chunks/ssr/[root-of-the-server]__014uja_._.js +0 -3
  715. package/.next/server/chunks/ssr/[root-of-the-server]__014uja_._.js.map +0 -1
  716. package/.next/server/chunks/ssr/[root-of-the-server]__02hi65f._.js +0 -3
  717. package/.next/server/chunks/ssr/[root-of-the-server]__02hi65f._.js.map +0 -1
  718. package/.next/server/chunks/ssr/[root-of-the-server]__02xqa5z._.js +0 -3
  719. package/.next/server/chunks/ssr/[root-of-the-server]__02xqa5z._.js.map +0 -1
  720. package/.next/server/chunks/ssr/[root-of-the-server]__04ph1de._.js +0 -3
  721. package/.next/server/chunks/ssr/[root-of-the-server]__04ph1de._.js.map +0 -1
  722. package/.next/server/chunks/ssr/[root-of-the-server]__05wc5-z._.js +0 -3
  723. package/.next/server/chunks/ssr/[root-of-the-server]__05wc5-z._.js.map +0 -1
  724. package/.next/server/chunks/ssr/[root-of-the-server]__06-2p1a._.js +0 -33
  725. package/.next/server/chunks/ssr/[root-of-the-server]__06-2p1a._.js.map +0 -1
  726. package/.next/server/chunks/ssr/[root-of-the-server]__08qr2ji._.js.map +0 -1
  727. package/.next/server/chunks/ssr/[root-of-the-server]__09l8xaa._.js +0 -3
  728. package/.next/server/chunks/ssr/[root-of-the-server]__09l8xaa._.js.map +0 -1
  729. package/.next/server/chunks/ssr/[root-of-the-server]__09z7o2x._.js +0 -19
  730. package/.next/server/chunks/ssr/[root-of-the-server]__09z7o2x._.js.map +0 -1
  731. package/.next/server/chunks/ssr/[root-of-the-server]__0a0ffmv._.js +0 -3
  732. package/.next/server/chunks/ssr/[root-of-the-server]__0a0ffmv._.js.map +0 -1
  733. package/.next/server/chunks/ssr/[root-of-the-server]__0b7uu-_._.js +0 -3
  734. package/.next/server/chunks/ssr/[root-of-the-server]__0b7uu-_._.js.map +0 -1
  735. package/.next/server/chunks/ssr/[root-of-the-server]__0bl_wqk._.js +0 -3
  736. package/.next/server/chunks/ssr/[root-of-the-server]__0bl_wqk._.js.map +0 -1
  737. package/.next/server/chunks/ssr/[root-of-the-server]__0ft0rpu._.js +0 -3
  738. package/.next/server/chunks/ssr/[root-of-the-server]__0ft0rpu._.js.map +0 -1
  739. package/.next/server/chunks/ssr/[root-of-the-server]__0i~g-2t._.js +0 -3
  740. package/.next/server/chunks/ssr/[root-of-the-server]__0i~g-2t._.js.map +0 -1
  741. package/.next/server/chunks/ssr/[root-of-the-server]__0jl4ukn._.js +0 -3
  742. package/.next/server/chunks/ssr/[root-of-the-server]__0jl4ukn._.js.map +0 -1
  743. package/.next/server/chunks/ssr/[root-of-the-server]__0mook08._.js +0 -3
  744. package/.next/server/chunks/ssr/[root-of-the-server]__0mook08._.js.map +0 -1
  745. package/.next/server/chunks/ssr/[root-of-the-server]__0n46s~k._.js +0 -3
  746. package/.next/server/chunks/ssr/[root-of-the-server]__0n46s~k._.js.map +0 -1
  747. package/.next/server/chunks/ssr/[root-of-the-server]__0qchqb~._.js +0 -3
  748. package/.next/server/chunks/ssr/[root-of-the-server]__0qchqb~._.js.map +0 -1
  749. package/.next/server/chunks/ssr/[root-of-the-server]__0rrm~-q._.js +0 -3
  750. package/.next/server/chunks/ssr/[root-of-the-server]__0rrm~-q._.js.map +0 -1
  751. package/.next/server/chunks/ssr/[root-of-the-server]__0w2ay2h._.js +0 -3
  752. package/.next/server/chunks/ssr/[root-of-the-server]__0w2ay2h._.js.map +0 -1
  753. package/.next/server/chunks/ssr/[root-of-the-server]__0xjish~._.js +0 -3
  754. package/.next/server/chunks/ssr/[root-of-the-server]__0xjish~._.js.map +0 -1
  755. package/.next/server/chunks/ssr/[root-of-the-server]__0xm3h9x._.js +0 -3
  756. package/.next/server/chunks/ssr/[root-of-the-server]__0xm3h9x._.js.map +0 -1
  757. package/.next/server/chunks/ssr/[root-of-the-server]__0ywihy_._.js +0 -8
  758. package/.next/server/chunks/ssr/[root-of-the-server]__0ywihy_._.js.map +0 -1
  759. package/.next/server/chunks/ssr/[root-of-the-server]__12ad1n.._.js +0 -3
  760. package/.next/server/chunks/ssr/[root-of-the-server]__12ad1n.._.js.map +0 -1
  761. package/.next/server/chunks/ssr/_0nne7mz._.js +0 -3
  762. package/.next/server/chunks/ssr/_0nne7mz._.js.map +0 -1
  763. package/.next/server/chunks/ssr/_next-internal_server_app__global-error_page_actions_0k77kol.js +0 -3
  764. package/.next/server/chunks/ssr/_next-internal_server_app__not-found_page_actions_0eq97pa.js +0 -3
  765. package/.next/server/chunks/ssr/_next-internal_server_app_admin_page_actions_0ycnu8f.js +0 -3
  766. package/.next/server/chunks/ssr/_next-internal_server_app_login_page_actions_02kefem.js +0 -3
  767. package/.next/server/chunks/ssr/_next-internal_server_app_logs_page_actions_0rvsl~d.js +0 -3
  768. package/.next/server/chunks/ssr/_next-internal_server_app_messages_page_actions_0gsqep_.js +0 -3
  769. package/.next/server/chunks/ssr/_next-internal_server_app_node_page_actions_0wd8cgl.js +0 -3
  770. package/.next/server/chunks/ssr/_next-internal_server_app_nodes_page_actions_0-.rv67.js +0 -3
  771. package/.next/server/chunks/ssr/_next-internal_server_app_page_actions_09-gtaw.js +0 -3
  772. package/.next/server/chunks/ssr/_next-internal_server_app_server-logs_page_actions_0g1gb5c.js +0 -3
  773. package/.next/server/chunks/ssr/_next-internal_server_app_settings_networks_page_actions_09uw.sw.js +0 -3
  774. package/.next/server/chunks/ssr/_next-internal_server_app_settings_page_actions_0mr68ai.js +0 -3
  775. package/.next/server/chunks/ssr/_next-internal_server_app_settings_tokens_page_actions_0yi54x8.js +0 -3
  776. package/.next/server/chunks/ssr/_next-internal_server_app_tasks_[id]_page_actions_0k_hm9g.js +0 -3
  777. package/.next/server/chunks/ssr/_next-internal_server_app_tasks_page_actions_0_2o_cr.js +0 -3
  778. package/.next/server/chunks/ssr/app_0.zbtn5._.js +0 -9
  779. package/.next/server/chunks/ssr/app_0.zbtn5._.js.map +0 -1
  780. package/.next/server/chunks/ssr/app_01y-~mp._.js +0 -3
  781. package/.next/server/chunks/ssr/app_01y-~mp._.js.map +0 -1
  782. package/.next/server/chunks/ssr/app_04n78pr._.js +0 -3
  783. package/.next/server/chunks/ssr/app_04n78pr._.js.map +0 -1
  784. package/.next/server/chunks/ssr/app_0_agk0r._.js +0 -3
  785. package/.next/server/chunks/ssr/app_0_agk0r._.js.map +0 -1
  786. package/.next/server/chunks/ssr/app_0aenxef._.js +0 -3
  787. package/.next/server/chunks/ssr/app_0aenxef._.js.map +0 -1
  788. package/.next/server/chunks/ssr/app_0aum_g5._.js +0 -3
  789. package/.next/server/chunks/ssr/app_0aum_g5._.js.map +0 -1
  790. package/.next/server/chunks/ssr/app_0emvkn2._.js +0 -3
  791. package/.next/server/chunks/ssr/app_0emvkn2._.js.map +0 -1
  792. package/.next/server/chunks/ssr/app_0esuljp._.js +0 -3
  793. package/.next/server/chunks/ssr/app_0esuljp._.js.map +0 -1
  794. package/.next/server/chunks/ssr/app_0izi28j._.js +0 -3
  795. package/.next/server/chunks/ssr/app_0izi28j._.js.map +0 -1
  796. package/.next/server/chunks/ssr/app_components_08ys5y3._.js.map +0 -1
  797. package/.next/server/chunks/ssr/app_server-logs_page_tsx_0_kpp1p._.js.map +0 -1
  798. package/.next/server/chunks/ssr/app_tasks_page_tsx_0z-d6mp._.js +0 -3
  799. package/.next/server/chunks/ssr/app_tasks_page_tsx_0z-d6mp._.js.map +0 -1
  800. package/.next/server/chunks/ssr/node_modules_09w7yel._.js.map +0 -1
  801. package/.next/server/chunks/ssr/node_modules_0ck2~9g._.js +0 -3
  802. package/.next/server/chunks/ssr/node_modules_0ck2~9g._.js.map +0 -1
  803. package/.next/server/chunks/ssr/node_modules_0r_.s40._.js.map +0 -1
  804. package/.next/server/chunks/ssr/node_modules_0vtdjw7._.js +0 -3
  805. package/.next/server/chunks/ssr/node_modules_0vtdjw7._.js.map +0 -1
  806. package/.next/server/chunks/ssr/node_modules_next_0w2y7fz._.js +0 -6
  807. package/.next/server/chunks/ssr/node_modules_next_0w2y7fz._.js.map +0 -1
  808. package/.next/server/chunks/ssr/node_modules_next_dist_0e1izl_._.js +0 -6
  809. package/.next/server/chunks/ssr/node_modules_next_dist_0e1izl_._.js.map +0 -1
  810. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_0inhx6q._.js +0 -3
  811. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_0inhx6q._.js.map +0 -1
  812. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_forbidden_0ghu-f7.js +0 -3
  813. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_forbidden_0ghu-f7.js.map +0 -1
  814. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_global-error_0lgvd_..js +0 -3
  815. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_global-error_0lgvd_..js.map +0 -1
  816. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_unauthorized_0cjv-23.js +0 -3
  817. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_unauthorized_0cjv-23.js.map +0 -1
  818. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_002l7yi.js.map +0 -1
  819. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_02suzhc.js.map +0 -1
  820. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_07uu-xq.js.map +0 -1
  821. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0d2yq5t.js.map +0 -1
  822. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0dm~kj8.js.map +0 -1
  823. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0foejvu.js.map +0 -1
  824. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0l-fh0i.js.map +0 -1
  825. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0nsg22r.js.map +0 -1
  826. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0o9vt1n.js.map +0 -1
  827. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0qm1-a1.js.map +0 -1
  828. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0uccf9z.js.map +0 -1
  829. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0uv6jyc.js.map +0 -1
  830. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_10~~o-h.js +0 -4
  831. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_10~~o-h.js.map +0 -1
  832. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_13leyhk.js +0 -4
  833. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_13leyhk.js.map +0 -1
  834. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_13s07li.js.map +0 -1
  835. package/.next/static/chunks/0-hlcuoemhe7w.js +0 -7
  836. package/.next/static/chunks/0257pdz1-imal.js +0 -2
  837. package/.next/static/chunks/02_zpo7taex9o.js +0 -7
  838. package/.next/static/chunks/06n3d~fbbtagk.js +0 -1
  839. package/.next/static/chunks/07eba1lz2u3p4.js +0 -1
  840. package/.next/static/chunks/0av2~0.96~d~g.js +0 -1
  841. package/.next/static/chunks/0bazlsfn4c~8b.js +0 -1
  842. package/.next/static/chunks/0c1bp~g7.8ru..js +0 -1
  843. package/.next/static/chunks/0e-.zgw81gela.js +0 -31
  844. package/.next/static/chunks/0g9aof0ob_fcj.js +0 -1
  845. package/.next/static/chunks/0s8jo6jypzkx5.js +0 -1
  846. package/.next/static/chunks/0u359llqm1_01.js +0 -1
  847. package/.next/static/chunks/0ubqqhhpsaez4.js +0 -1
  848. package/.next/static/chunks/0ux36eo4a-0ry.css +0 -2
  849. package/.next/static/chunks/0yi50lip8s7cb.js +0 -1
  850. package/.next/static/chunks/0~qw9kxksr1v2.js +0 -1
  851. package/.next/static/chunks/12gprw3l0kvvf.js +0 -1
  852. package/.next/static/chunks/12yuo353giomx.js +0 -1
  853. package/.next/static/chunks/14bsg3_-taiw4.js +0 -1
  854. package/.next/static/chunks/160ungf2_xdzs.js +0 -1
  855. /package/.next/build/chunks/{[root-of-the-server]__0ubbtyl._.js.map → [root-of-the-server]__0tgc-8p._.js.map} +0 -0
  856. /package/.next/build/chunks/{[turbopack-node]_transforms_postcss_ts_06e.r3r._.js.map → [turbopack-node]_transforms_postcss_ts_0r4x5ys._.js.map} +0 -0
  857. /package/.next/server/chunks/{_next-internal_server_app_api_anet_config_route_actions_0xzy73j.js.map → 0ci9_nt-network-dashboard__next-internal_server_app_api_auth_v3_route_actions_0f5nbzr.js.map} +0 -0
  858. /package/.next/server/chunks/{_next-internal_server_app_api_auth_login_route_actions_0zukc38.js.map → 0ci9_nt-network-dashboard__next-internal_server_app_favicon_ico_route_actions_0t0eu9r.js.map} +0 -0
  859. /package/.next/server/chunks/{_next-internal_server_app_api_auth_logout_route_actions_0fm~3ij.js.map → 0ykm__next-internal_server_app_api_anet_config_route_actions_0a~v55_.js.map} +0 -0
  860. /package/.next/server/chunks/{_next-internal_server_app_api_auth_v3_route_actions_0ndzs5l.js.map → 0ykm__next-internal_server_app_api_auth_login_route_actions_0xnxfs4.js.map} +0 -0
  861. /package/.next/server/chunks/{_next-internal_server_app_api_hub_audit-log_route_actions_11k8fqt.js.map → 0ykm__next-internal_server_app_api_auth_logout_route_actions_0p8p0rn.js.map} +0 -0
  862. /package/.next/server/chunks/{_next-internal_server_app_api_hub_auth_route_actions_0gs7ttk.js.map → 0ykm__next-internal_server_app_api_hub_audit-log_route_actions_06uquwi.js.map} +0 -0
  863. /package/.next/server/chunks/{_next-internal_server_app_api_hub_broadcast_route_actions_0ads-kd.js.map → 0ykm__next-internal_server_app_api_hub_auth_route_actions_0f_y1ey.js.map} +0 -0
  864. /package/.next/server/chunks/{_next-internal_server_app_api_hub_events_route_actions_0opktby.js.map → 0ykm__next-internal_server_app_api_hub_broadcast_route_actions_0ao3j43.js.map} +0 -0
  865. /package/.next/server/chunks/{_next-internal_server_app_api_hub_health_route_actions_0tzzt~x.js.map → 0ykm__next-internal_server_app_api_hub_events_route_actions_0_5ndld.js.map} +0 -0
  866. /package/.next/server/chunks/{_next-internal_server_app_api_hub_inbox_route_actions_0xvslrg.js.map → 0ykm__next-internal_server_app_api_hub_health_route_actions_0mpzl74.js.map} +0 -0
  867. /package/.next/server/chunks/{_next-internal_server_app_api_hub_license_route_actions_0i3tlr3.js.map → 0ykm__next-internal_server_app_api_hub_inbox_route_actions_0stoyto.js.map} +0 -0
  868. /package/.next/server/chunks/{_next-internal_server_app_api_hub_messages_route_actions_08-gi-s.js.map → 0ykm__next-internal_server_app_api_hub_license_route_actions_0a4.fuh.js.map} +0 -0
  869. /package/.next/server/chunks/{_next-internal_server_app_api_hub_networks_route_actions_02vejq2.js.map → 0ykm__next-internal_server_app_api_hub_messages_route_actions_059rw1o.js.map} +0 -0
  870. /package/.next/server/chunks/{_next-internal_server_app_api_hub_nodes_route_actions_0nx8ryn.js.map → 0ykm__next-internal_server_app_api_hub_networks_route_actions_03sehpn.js.map} +0 -0
  871. /package/.next/server/chunks/{_next-internal_server_app_api_hub_register_route_actions_086wi_3.js.map → 0ykm__next-internal_server_app_api_hub_nodes_route_actions_11fbp28.js.map} +0 -0
  872. /package/.next/server/chunks/{_next-internal_server_app_api_hub_send_route_actions_0-ga_ug.js.map → 0ykm__next-internal_server_app_api_hub_register_route_actions_0l.d7~n.js.map} +0 -0
  873. /package/.next/server/chunks/{_next-internal_server_app_api_hub_server-logs_route_actions_00euexv.js.map → 0ykm__next-internal_server_app_api_hub_send_route_actions_0.p82vy.js.map} +0 -0
  874. /package/.next/server/chunks/{_next-internal_server_app_api_hub_session_route_actions_041wa62.js.map → 0ykm__next-internal_server_app_api_hub_server-logs_route_actions_05~c5lb.js.map} +0 -0
  875. /package/.next/server/chunks/{_next-internal_server_app_api_hub_stats_route_actions_1242~od.js.map → 0ykm__next-internal_server_app_api_hub_servers_route_actions_08-m-cs.js.map} +0 -0
  876. /package/.next/server/chunks/{_next-internal_server_app_api_hub_status_route_actions_0aml09a.js.map → 0ykm__next-internal_server_app_api_hub_session_route_actions_0b9ef2i.js.map} +0 -0
  877. /package/.next/server/chunks/{_next-internal_server_app_api_hub_task-events_route_actions_02v1tmm.js.map → 0ykm__next-internal_server_app_api_hub_stats_route_actions_03u65u0.js.map} +0 -0
  878. /package/.next/server/chunks/{_next-internal_server_app_api_hub_tasks_route_actions_0nl77v4.js.map → 0ykm__next-internal_server_app_api_hub_status_route_actions_0_71ear.js.map} +0 -0
  879. /package/.next/server/chunks/{_next-internal_server_app_api_hub_tmux_route_actions_0u~aaza.js.map → 0ykm__next-internal_server_app_api_hub_task-events_route_actions_07md_ee.js.map} +0 -0
  880. /package/.next/server/chunks/{_next-internal_server_app_api_hub_tokens_route_actions_09hzf_1.js.map → 0ykm__next-internal_server_app_api_hub_tasks_route_actions_0er3uvk.js.map} +0 -0
  881. /package/.next/server/chunks/{_next-internal_server_app_favicon_ico_route_actions_095lj93.js.map → 0ykm__next-internal_server_app_api_hub_tmux_route_actions_0o_1-8u.js.map} +0 -0
  882. /package/.next/server/chunks/{ssr/_next-internal_server_app__global-error_page_actions_0k77kol.js.map → 0ykm__next-internal_server_app_api_hub_tokens_route_actions_09doluz.js.map} +0 -0
  883. /package/.next/server/chunks/ssr/{_next-internal_server_app__not-found_page_actions_0eq97pa.js.map → 0dwh_ent-network-dashboard__next-internal_server_app_server-logs_page_actions_0hkk815.js.map} +0 -0
  884. /package/.next/server/chunks/ssr/{_next-internal_server_app_admin_page_actions_0ycnu8f.js.map → 0yas_gent-network-dashboard__next-internal_server_app__not-found_page_actions_0u.f-rz.js.map} +0 -0
  885. /package/.next/server/chunks/ssr/{_next-internal_server_app_login_page_actions_02kefem.js.map → 0yas_gent-network-dashboard__next-internal_server_app_tasks_[id]_page_actions_0r3~lho.js.map} +0 -0
  886. /package/.next/server/chunks/ssr/{_next-internal_server_app_logs_page_actions_0rvsl~d.js.map → 0ykm__next-internal_server_app__global-error_page_actions_100fxls.js.map} +0 -0
  887. /package/.next/server/chunks/ssr/{_next-internal_server_app_messages_page_actions_0gsqep_.js.map → 0ykm__next-internal_server_app_settings_networks_page_actions_0jgy57m.js.map} +0 -0
  888. /package/.next/server/chunks/ssr/{_next-internal_server_app_node_page_actions_0wd8cgl.js.map → 0ykm__next-internal_server_app_settings_tokens_page_actions_0l5h.k~.js.map} +0 -0
  889. /package/.next/server/chunks/ssr/{_next-internal_server_app_nodes_page_actions_0-.rv67.js.map → agent-network-dashboard__next-internal_server_app_admin_page_actions_0i~.x~j.js.map} +0 -0
  890. /package/.next/server/chunks/ssr/{_next-internal_server_app_page_actions_09-gtaw.js.map → agent-network-dashboard__next-internal_server_app_login_page_actions_0frwl15.js.map} +0 -0
  891. /package/.next/server/chunks/ssr/{_next-internal_server_app_server-logs_page_actions_0g1gb5c.js.map → agent-network-dashboard__next-internal_server_app_logs_page_actions_0xa2ox_.js.map} +0 -0
  892. /package/.next/server/chunks/ssr/{_next-internal_server_app_settings_networks_page_actions_09uw.sw.js.map → agent-network-dashboard__next-internal_server_app_messages_page_actions_10j3tg4.js.map} +0 -0
  893. /package/.next/server/chunks/ssr/{_next-internal_server_app_settings_page_actions_0mr68ai.js.map → agent-network-dashboard__next-internal_server_app_node_page_actions_0sl4ntd.js.map} +0 -0
  894. /package/.next/server/chunks/ssr/{_next-internal_server_app_settings_tokens_page_actions_0yi54x8.js.map → agent-network-dashboard__next-internal_server_app_nodes_page_actions_0cpgkfs.js.map} +0 -0
  895. /package/.next/server/chunks/ssr/{_next-internal_server_app_tasks_[id]_page_actions_0k_hm9g.js.map → agent-network-dashboard__next-internal_server_app_page_actions_08nuo36.js.map} +0 -0
  896. /package/.next/server/chunks/ssr/{_next-internal_server_app_tasks_page_actions_0_2o_cr.js.map → agent-network-dashboard__next-internal_server_app_settings_page_actions_0iv.lmk.js.map} +0 -0
  897. /package/.next/static/{dTHZkhRTLxCqMY86_0Ln9 → JHK0J7_QlNFyBKmLR_1Tt}/_buildManifest.js +0 -0
  898. /package/.next/static/{dTHZkhRTLxCqMY86_0Ln9 → JHK0J7_QlNFyBKmLR_1Tt}/_clientMiddlewareManifest.js +0 -0
  899. /package/.next/static/{dTHZkhRTLxCqMY86_0Ln9 → JHK0J7_QlNFyBKmLR_1Tt}/_ssgManifest.js +0 -0
@@ -1,9 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { Session } from './types';
5
5
  import { aliasAvatarColors, aliasInitial } from './AliasAvatar';
6
6
  import { ChatPopover } from './ChatPopover';
7
+ import { vendorForModel, runtimeIdentity, identityLine } from '../lib/vendorIdentity';
8
+ import { parseHubTime, relativeAgo } from '../lib/time';
7
9
 
8
10
  interface MessageFlow {
9
11
  from_alias: string;
@@ -15,6 +17,9 @@ interface MessageFlow {
15
17
  interface TopoGraphProps {
16
18
  sessions: Session[];
17
19
  sseSessions: Record<string, number>;
20
+ // #84: a node.renamed event from the Overview's SSE listener. When the
21
+ // currently open chat popover targets `from`, it follows the rename to `to`.
22
+ renameSignal?: { from: string; to: string; ts: number } | null;
18
23
  }
19
24
 
20
25
  interface Point {
@@ -28,6 +33,9 @@ interface FlowLink {
28
33
  to: string;
29
34
  count: number;
30
35
  content: string;
36
+ /** ISO timestamp of the most recent message on this edge — drives
37
+ * the Round 10 freshness fade so dormant links recede visually. */
38
+ last_at: string;
31
39
  }
32
40
 
33
41
  const cx = 500;
@@ -81,36 +89,118 @@ function truncate(value: string, max: number) {
81
89
  return value.length > max ? `${value.slice(0, max - 1)}...` : value;
82
90
  }
83
91
 
92
+ /** Round 46 / Loop: SWR-freshness chip.
93
+ *
94
+ * TopoGraph's data refreshes every 5 s via SWR but the user has no
95
+ * visible signal that the canvas they're looking at is current. The
96
+ * flow particles and chips (R42 active-links, R43 hub) tell you when
97
+ * the FLEET last did something, not when the DATA last syncing. This
98
+ * chip ticks `live · Ns` against a 1-second interval so the operator
99
+ * can trust freshness without doing internal math.
100
+ *
101
+ * Owns its own setInterval so the parent topology doesn't re-render
102
+ * every second (only this small chip does). lastSyncRef is updated
103
+ * whenever the `sessions` prop reference changes — that's the SWR
104
+ * refresh signal. */
105
+ function FreshnessChip({ sessions }: { sessions: unknown }) {
106
+ const lastSyncRef = useRef<number>(Date.now());
107
+ const [now, setNow] = useState(() => Date.now());
108
+ useEffect(() => {
109
+ // sessions reference changed → SWR just delivered a new payload.
110
+ lastSyncRef.current = Date.now();
111
+ }, [sessions]);
112
+ useEffect(() => {
113
+ const id = setInterval(() => setNow(Date.now()), 1000);
114
+ return () => clearInterval(id);
115
+ }, []);
116
+ const sec = Math.max(0, Math.floor((now - lastSyncRef.current) / 1000));
117
+ // Tint the chip warmer when the data goes stale (>10s since last sync).
118
+ // SWR's default refreshInterval here is 5s, so anything past ~10s is
119
+ // either a poll miss or a network hiccup.
120
+ const stale = sec > 10;
121
+ return (
122
+ <span
123
+ className={stale
124
+ ? "hidden sm:inline px-2.5 py-1 rounded-md bg-amber-500/10 text-amber-300 border border-amber-500/20 font-mono"
125
+ : "hidden sm:inline px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20 font-mono"}
126
+ title={stale ? `Last sync ${sec}s ago — SWR refresh may be lagging` : `Live data · refreshes every 5s · last sync ${sec}s ago`}
127
+ data-freshness-chip
128
+ >
129
+ live · {sec}s
130
+ </span>
131
+ );
132
+ }
133
+
134
+ /** Round 36 / Loop: prefers-reduced-motion hook.
135
+ *
136
+ * Round 29's a11y sweep zeroed CSS animations via media query, but SVG
137
+ * SMIL `<animate>` / `<animateMotion>` elements aren't reachable from CSS
138
+ * — they need JS to opt out. This hook reads the media query and listens
139
+ * for changes so the topology's flow particles, pulses, click ripple
140
+ * and hub breath honour the OS-level preference. */
141
+ function useReducedMotion(): boolean {
142
+ const [reduced, setReduced] = useState(false);
143
+ useEffect(() => {
144
+ if (typeof window === 'undefined' || !window.matchMedia) return;
145
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
146
+ setReduced(mq.matches);
147
+ const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
148
+ mq.addEventListener('change', onChange);
149
+ return () => mq.removeEventListener('change', onChange);
150
+ }, []);
151
+ return reduced;
152
+ }
153
+
154
+ // Round 38 / Loop: parseHubTime + relativeAgo factored to app/lib/time.ts
155
+ // — the Round 35 fix lives there now alongside the Round 37 mirror so
156
+ // any future TZ-safe parse update happens in one place.
157
+
158
+ /** Round 12 / Loop: status trio audit.
159
+ *
160
+ * Each (status × theme) cell returns a {primary, halo, text} trio. The trio
161
+ * invariant — keep it when adding states or tweaking shades:
162
+ *
163
+ * light: primary = <hue>-600 halo = <hue>-100 * text = <hue>-800/900
164
+ * dark: primary = <hue>-400/500 halo = <hue>-900 text = <hue>-100
165
+ *
166
+ * * offline.halo light deviates intentionally to slate-200 (#e2e8f0):
167
+ * slate-100 (#f1f5f9) is too close to the panel bg (#f8fafc) and the
168
+ * halo would vanish. The other three rows keep the 100-shade.
169
+ *
170
+ * Audit caught one cross-family drift before this round: online-other halo
171
+ * light was #dbeafe (blue-100) while its primary (#0284c7 sky-600) and text
172
+ * (#0c4a6e sky-900) were sky-family — now sky-100 (#e0f2fe). Tiny visual
173
+ * difference; large hygiene win — every trio is now mono-hue. */
84
174
  function nodeStatus(session: Session, isOnline: boolean, isLight: boolean) {
85
175
  if (!isOnline) {
86
176
  return {
87
177
  label: 'offline',
88
- primary: isLight ? '#94a3b8' : '#6b7280',
89
- halo: isLight ? '#e2e8f0' : '#111827',
90
- text: isLight ? '#475569' : '#9ca3af',
178
+ primary: isLight ? '#94a3b8' : '#6b7280', // slate-400 / gray-500
179
+ halo: isLight ? '#e2e8f0' : '#111827', // slate-200* / gray-900
180
+ text: isLight ? '#475569' : '#9ca3af', // slate-600 / gray-400
91
181
  };
92
182
  }
93
183
  if (session.status === 'working') {
94
184
  return {
95
185
  label: 'working',
96
- primary: isLight ? '#059669' : '#22c55e',
97
- halo: isLight ? '#d1fae5' : '#14532d',
98
- text: isLight ? '#065f46' : '#dcfce7',
186
+ primary: isLight ? '#059669' : '#22c55e', // emerald-600 / green-500
187
+ halo: isLight ? '#d1fae5' : '#14532d', // emerald-100 / green-900
188
+ text: isLight ? '#065f46' : '#dcfce7', // emerald-800 / green-100
99
189
  };
100
190
  }
101
191
  if (session.status === 'idle') {
102
192
  return {
103
193
  label: 'idle',
104
- primary: isLight ? '#0d9488' : '#2dd4bf',
105
- halo: isLight ? '#ccfbf1' : '#134e4a',
106
- text: isLight ? '#115e59' : '#ccfbf1',
194
+ primary: isLight ? '#0d9488' : '#2dd4bf', // teal-600 / teal-400
195
+ halo: isLight ? '#ccfbf1' : '#134e4a', // teal-100 / teal-900
196
+ text: isLight ? '#115e59' : '#ccfbf1', // teal-800 / teal-100
107
197
  };
108
198
  }
109
199
  return {
110
200
  label: session.status || 'online',
111
- primary: isLight ? '#0284c7' : '#38bdf8',
112
- halo: isLight ? '#dbeafe' : '#0c4a6e',
113
- text: isLight ? '#0c4a6e' : '#e0f2fe',
201
+ primary: isLight ? '#0284c7' : '#38bdf8', // sky-600 / sky-400
202
+ halo: isLight ? '#e0f2fe' : '#0c4a6e', // sky-100 / sky-900 (was blue-100 — drift fixed Round 12)
203
+ text: isLight ? '#0c4a6e' : '#e0f2fe', // sky-900 / sky-100
114
204
  };
115
205
  }
116
206
 
@@ -236,22 +326,59 @@ function commonPrefix(a: string, b: string): string {
236
326
  return a.slice(0, i);
237
327
  }
238
328
 
239
- function computeGroupKeys(aliases: string[]): Record<string, string> {
240
- const sorted = [...aliases].sort((a, b) => a.localeCompare(b));
329
+ /** #83 + #111: group nodes that share a ≥2-char alias prefix OR a project_dir
330
+ * ("either criterion → same group", Vincent 4724). Union-find over the
331
+ * sessions; the component label prefers the shared project_dir's basename,
332
+ * else the common alias prefix. Returns alias → groupKey. */
333
+ function computeGroups(sessions: { alias: string; project_dir?: string | null }[]): Record<string, string> {
334
+ const n = sessions.length;
335
+ const parent = sessions.map((_, i) => i);
336
+ const find = (i: number): number => {
337
+ while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; }
338
+ return i;
339
+ };
340
+ const union = (a: number, b: number) => {
341
+ const ra = find(a), rb = find(b);
342
+ if (ra !== rb) parent[ra] = rb;
343
+ };
344
+
345
+ // union by shared project_dir
346
+ const byDir: Record<string, number[]> = {};
347
+ sessions.forEach((s, i) => {
348
+ const d = s.project_dir?.trim();
349
+ if (d) (byDir[d] ||= []).push(i);
350
+ });
351
+ for (const idxs of Object.values(byDir)) {
352
+ for (let k = 1; k < idxs.length; k++) union(idxs[0], idxs[k]);
353
+ }
354
+
355
+ // union by shared ≥2-char alias prefix — sort, link adjacent pairs
356
+ const order = sessions.map((_, i) => i).sort((a, b) => sessions[a].alias.localeCompare(sessions[b].alias));
357
+ for (let k = 0; k + 1 < order.length; k++) {
358
+ if (commonPrefix(sessions[order[k]].alias, sessions[order[k + 1]].alias).length >= 2) {
359
+ union(order[k], order[k + 1]);
360
+ }
361
+ }
362
+
363
+ // label each connected component
364
+ const comps: Record<number, number[]> = {};
365
+ for (let i = 0; i < n; i++) (comps[find(i)] ||= []).push(i);
241
366
  const keys: Record<string, string> = {};
242
- let i = 0;
243
- while (i < sorted.length) {
244
- let j = i;
245
- let prefix = sorted[i];
246
- while (j + 1 < sorted.length) {
247
- const lcp = commonPrefix(prefix, sorted[j + 1]);
248
- if (lcp.length < 2) break;
249
- prefix = lcp;
250
- j++;
367
+ for (const members of Object.values(comps)) {
368
+ let label: string;
369
+ if (members.length === 1) {
370
+ label = sessions[members[0]].alias;
371
+ } else {
372
+ const dirs = new Set(members.map(i => sessions[i].project_dir?.trim()).filter(Boolean) as string[]);
373
+ if (dirs.size === 1) {
374
+ const d = [...dirs][0];
375
+ label = d.split('/').filter(Boolean).pop() || d;
376
+ } else {
377
+ label = members.map(i => sessions[i].alias).reduce((a, b) => commonPrefix(a, b));
378
+ if (label.length < 2) label = sessions[members[0]].alias;
379
+ }
251
380
  }
252
- const key = j > i ? prefix : sorted[i];
253
- for (let k = i; k <= j; k++) keys[sorted[k]] = key;
254
- i = j + 1;
381
+ for (const i of members) keys[sessions[i].alias] = label;
255
382
  }
256
383
  return keys;
257
384
  }
@@ -271,25 +398,64 @@ function buildFlowLinks(messages: MessageFlow[], positions: Record<string, Point
271
398
  const key = `${message.from_alias}->${message.to_alias}`;
272
399
  const current = links.get(key);
273
400
 
401
+ // Keep the most-recent timestamp per pair so the render can fade
402
+ // dormant edges (Round 10 freshness fade).
403
+ const incoming = message.created_at || '';
404
+ const last_at = !current
405
+ ? incoming
406
+ : (incoming > current.last_at ? incoming : current.last_at);
407
+
274
408
  links.set(key, {
275
409
  key,
276
410
  from: message.from_alias,
277
411
  to: message.to_alias,
278
412
  count: (current?.count || 0) + 1,
279
413
  content: current?.content || message.content,
414
+ last_at,
280
415
  });
281
416
  });
282
417
 
283
418
  return [...links.values()].slice(0, 18);
284
419
  }
285
420
 
286
- export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
421
+ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProps) {
287
422
  const theme = useTheme();
288
423
  const isLight = theme === 'light';
424
+ const reducedMotion = useReducedMotion();
289
425
  const pal = isLight ? LIGHT_PALETTE : DARK_PALETTE;
290
426
  const brand = useBrand();
291
427
  const isIntern = brand === 'intern';
292
428
  const [messages, setMessages] = useState<MessageFlow[]>([]);
429
+ // Issue #87: ring | grid layout toggle. Ring is the tiered-radial default;
430
+ // grid arranges nodes in an N×M grid (better for 30+ nodes). Persisted to
431
+ // localStorage like the zoom/pan view state. Declared above nodePositions
432
+ // since that useMemo branches on it.
433
+ const [layout, setLayout] = useState<'ring' | 'grid'>('ring');
434
+ useEffect(() => {
435
+ try {
436
+ const saved = localStorage.getItem('anet-topo-layout');
437
+ if (saved === 'grid' || saved === 'ring') setLayout(saved);
438
+ } catch {}
439
+ }, []);
440
+ const toggleLayout = () => setLayout(prev => {
441
+ const next = prev === 'ring' ? 'grid' : 'ring';
442
+ try { localStorage.setItem('anet-topo-layout', next); } catch {}
443
+ return next;
444
+ });
445
+ // Issue #113: node size scale (Vincent 4727 — nodes too big / crowded at
446
+ // ~22 nodes). S/M/L → 0.7/0.84/1.0; default M (one notch down from the old
447
+ // fixed size). Persisted like the layout toggle.
448
+ const [nodeScale, setNodeScale] = useState(0.84);
449
+ useEffect(() => {
450
+ try {
451
+ const saved = parseFloat(localStorage.getItem('anet-topo-nodescale') || '');
452
+ if (saved === 0.7 || saved === 0.84 || saved === 1) setNodeScale(saved);
453
+ } catch {}
454
+ }, []);
455
+ const pickNodeScale = (v: number) => {
456
+ setNodeScale(v);
457
+ try { localStorage.setItem('anet-topo-nodescale', String(v)); } catch {}
458
+ };
293
459
 
294
460
  useEffect(() => {
295
461
  const fetchMessages = async () => {
@@ -316,6 +482,8 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
316
482
  flowLinks,
317
483
  activeAliases,
318
484
  groupKeys,
485
+ groupBoxes,
486
+ gridContentBottom,
319
487
  } = useMemo(() => {
320
488
  const sseCount = (s: { alias: string; network_id?: string }) =>
321
489
  (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
@@ -325,10 +493,178 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
325
493
  // index, so contiguous-in-array becomes contiguous-in-ring, i.e.
326
494
  // each team visually clusters. localeCompare keeps CJK ordering sane.
327
495
  const byAlias = (a: Session, b: Session) => a.alias.localeCompare(b.alias);
496
+ // #112 umbrella: ghost age-out — an offline node not seen recently is
497
+ // almost certainly a deleted node the server `/api/status` still
498
+ // returns (#74 root cause is server-side; this is the dashboard-side
499
+ // fallback so stale ghosts stop cluttering the topology). A missing
500
+ // last_seen_at is kept (conservative — could be a legitimately new node).
501
+ // Round 27 / P0: the 24h threshold was too lenient — Vincent's preview.29
502
+ // screenshot showed B站马 nodes deleted ~4 h earlier still visible. A
503
+ // healthy agent heartbeats every few seconds; if it's been silent for
504
+ // an hour it's effectively gone. 1 h gives a fresh disconnect time to
505
+ // come back while removing dead nodes well within an operator session.
506
+ const GHOST_MS = 60 * 60 * 1000;
507
+ const now = Date.now();
508
+ const isGhost = (s: Session) => {
509
+ if (s.status !== 'offline' || sseCount(s) || !s.last_seen_at) return false;
510
+ // Round 35 / Loop: parseHubTime normalises SQL-style timestamps to UTC
511
+ // before parsing so non-UTC browsers don't see a phantom 8-hour skew
512
+ // and ghost freshly-disconnected nodes.
513
+ const t = parseHubTime(s.last_seen_at);
514
+ return t !== null && now - t > GHOST_MS;
515
+ };
328
516
  const online = sessions.filter(s => s.status !== 'offline' || sseCount(s)).sort(byAlias);
329
- const offline = sessions.filter(s => s.status === 'offline' && !sseCount(s)).sort(byAlias);
517
+ const offline = sessions.filter(s => s.status === 'offline' && !sseCount(s) && !isGhost(s)).sort(byAlias);
330
518
  const positions: Record<string, Point> = {};
331
519
 
520
+ if (layout === 'grid') {
521
+ // Issue #87 + #111: group-banded grid. Nodes are sorted by alias so
522
+ // same-prefix aliases are adjacent; each multi-member prefix group then
523
+ // gets its OWN row(s) starting at column 0, while singletons pack into
524
+ // shared rows. Group-banded placement keeps every group's bounding box
525
+ // (#111) a clean rectangle — no box ever overlaps another group's.
526
+ const all = [...online, ...offline];
527
+ const groupKeys = computeGroups(all);
528
+ const cols = Math.max(1, Math.ceil(Math.sqrt(all.length)));
529
+ // #112: gy0 starts below the (now compact) recent-signal / legend
530
+ // overlay panels — their max bottom edge is y≈112 — so grid row 0 is
531
+ // never occluded; gy1 extends near the canvas bottom for breathing room.
532
+ const gx0 = 150, gx1 = 850, gy0 = 126, gy1 = 652;
533
+ const cellW = (gx1 - gx0) / cols;
534
+ // largest node radius at the current size scale — drives the group
535
+ // label band + the row-height floor so nothing ever overlaps.
536
+ const nodeR = Math.round(26 * nodeScale);
537
+
538
+ // ordered runs of consecutive same-group-key nodes (≥2 = real group)
539
+ const runs: { key: string; members: Session[] }[] = [];
540
+ for (const s of all) {
541
+ const gk = groupKeys[s.alias];
542
+ const last = runs[runs.length - 1];
543
+ if (last && last.key === gk) last.members.push(s);
544
+ else runs.push({ key: gk, members: [s] });
545
+ }
546
+
547
+ // Pass 1 — assign each run to a band: a multi-member group owns its
548
+ // rows (left-aligned, so its bounding box is a tidy rect); contiguous
549
+ // singletons pack into shared rows (centred). Collect total row count.
550
+ type Band = { members: Session[]; startRow: number; centred: boolean; isGroup: boolean };
551
+ const bands: Band[] = [];
552
+ let row = 0;
553
+ let i = 0;
554
+ while (i < runs.length) {
555
+ if (runs[i].members.length >= 2) {
556
+ bands.push({ members: runs[i].members, startRow: row, centred: false, isGroup: true });
557
+ row += Math.ceil(runs[i].members.length / cols);
558
+ i++;
559
+ } else {
560
+ const singles: Session[] = [];
561
+ while (i < runs.length && runs[i].members.length < 2) {
562
+ singles.push(runs[i].members[0]);
563
+ i++;
564
+ }
565
+ bands.push({ members: singles, startRow: row, centred: true, isGroup: false });
566
+ row += Math.ceil(singles.length / cols);
567
+ }
568
+ }
569
+ const totalRows = Math.max(1, row);
570
+ // #112: the group label sits in a band ABOVE the topmost node, so the
571
+ // band must clear the node radius — GROUP_TOP is node-relative, never
572
+ // cellH-derived (cellH-derived was the label↔node overlap bug Vincent
573
+ // hit). cellH then floors at GROUP_TOP + 30 so the label band + bottom
574
+ // padding always fit and stacked group boxes never touch; past the
575
+ // floor the grid overflows and zoom/pan handles it.
576
+ const GROUP_TOP = nodeR + 20;
577
+ // Round 27 / P0 (Vincent screenshot preview.29): dense plain-text
578
+ // node labels (`pos.y + radius + denseDrop`, with a 3 px containerBg
579
+ // stroke halo for readability) at the BOTTOM row of band N would
580
+ // paint OVER the start of band N+1's group label, creating the
581
+ // "blueleap → eleap", "agent-network-dashboard → t-network / board"
582
+ // visual chopping. Geometry of the collision:
583
+ // dense label visual bottom = node_N.y + radius + denseDrop + halo
584
+ // group label glyph top = boxY + 4 (text y=boxY+14, ~10px ascent)
585
+ // = node_N+1.y - GROUP_TOP + 4
586
+ // node_N+1.y - node_N.y = cellH for consecutive rows, so no-collide:
587
+ // cellH ≥ radius + denseDrop + halo + GROUP_TOP - 4 + buffer
588
+ // With halo=3 buffer=4: cellH ≥ nodeR + denseDrop + GROUP_TOP + 3.
589
+ const denseDrop = nodeScale < 0.8 ? 12 : 14;
590
+ const cellH = Math.max(
591
+ 2 * nodeR + 22, // node + dense label within band
592
+ GROUP_TOP + 12, // group-label band + box padding
593
+ nodeR + denseDrop + GROUP_TOP + 3, // round-27 dense↔group label clearance
594
+ Math.min(100, (gy1 - gy0) / totalRows),
595
+ );
596
+
597
+ // Pass 2 — place each band's members.
598
+ for (const band of bands) {
599
+ band.members.forEach((s, idx) => {
600
+ const rowInBand = Math.floor(idx / cols);
601
+ const c = idx % cols;
602
+ const inRow = Math.min(cols, band.members.length - rowInBand * cols);
603
+ const inset = band.centred ? ((cols - inRow) * cellW) / 2 : 0;
604
+ positions[s.alias] = {
605
+ x: gx0 + inset + (c + 0.5) * cellW,
606
+ y: gy0 + (band.startRow + rowInBand + 0.5) * cellH,
607
+ };
608
+ });
609
+ }
610
+
611
+ const links = buildFlowLinks(messages, positions);
612
+ const active = new Set<string>();
613
+ links.forEach(link => { active.add(link.from); active.add(link.to); });
614
+ // #111: one bounding box per multi-member group (Vincent 4722). Each
615
+ // group owns its rows; GROUP_PAD fills the row space left below the
616
+ // nodes after the label band, so stacked group boxes always have a
617
+ // gap between them (GROUP_TOP is defined above, with cellH).
618
+ const GROUP_PAD = Math.max(8, Math.min(26, cellH - GROUP_TOP - 8)); // side/bottom
619
+ const groupBoxes = bands
620
+ .filter(b => b.isGroup)
621
+ .map(band => {
622
+ const pts = band.members.map(s => positions[s.alias]).filter(Boolean);
623
+ const xs = pts.map(p => p.x);
624
+ const ys = pts.map(p => p.y);
625
+ const minX = Math.min(...xs), minY = Math.min(...ys);
626
+ // Round 58 / Loop: per-group status mix for the label pip strip.
627
+ // Working = status==='working'. Idle = online but not working.
628
+ // Offline = !isOnline (either status==='offline' AND no SSE, or
629
+ // ghost-purged elsewhere — but ghosts never reach groupBoxes
630
+ // since they're filtered out upstream). Counts feed the label
631
+ // tspans directly so the strip stays inside the label's bbox,
632
+ // preserving the node↔label overlap-test guarantee from R19.
633
+ let w = 0, i = 0, o = 0;
634
+ for (const s of band.members) {
635
+ const isOn = s.status !== 'offline' || !!sseCount(s);
636
+ if (s.status === 'working') w++;
637
+ else if (isOn) i++;
638
+ else o++;
639
+ }
640
+ return {
641
+ key: band.members.length ? groupKeys[band.members[0].alias] : '',
642
+ count: band.members.length,
643
+ statuses: { working: w, idle: i, offline: o },
644
+ x: minX - GROUP_PAD,
645
+ y: minY - GROUP_TOP,
646
+ w: Math.max(...xs) - minX + GROUP_PAD * 2,
647
+ h: Math.max(...ys) - minY + GROUP_TOP + GROUP_PAD,
648
+ };
649
+ });
650
+ // Round 28 / Loop: surface the grid's natural content bottom so the
651
+ // mount effect can auto-fit zoom when the layout would overflow the
652
+ // viewBox. = bottom of the last node row + its label drop + a small
653
+ // breathing buffer. Round 27's cellH bump makes this overflow more
654
+ // common (30-node fleets reach ~774 px, viewBox is 680).
655
+ const gridContentBottom = gy0 + totalRows * cellH + 8;
656
+ return {
657
+ onlineNodes: online,
658
+ offlineNodes: offline,
659
+ nodePositions: positions,
660
+ flowLinks: links,
661
+ activeAliases: active,
662
+ groupKeys,
663
+ groupBoxes,
664
+ gridContentBottom,
665
+ };
666
+ }
667
+
332
668
  // Round 97 (issue #50) + 98 (issue #61): three layout modes by N.
333
669
  // N ≤ 8 → single ring (r=220)
334
670
  // 8 < N ≤ 14 → two rings (inner r=175, outer r=260, half-step rot)
@@ -396,7 +732,7 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
396
732
  });
397
733
 
398
734
  // Round 106 (issue #83): group key per alias → shared hue per team.
399
- const groupKeys = computeGroupKeys([...online, ...offline].map(s => s.alias));
735
+ const groupKeys = computeGroups([...online, ...offline]);
400
736
 
401
737
  return {
402
738
  onlineNodes: online,
@@ -405,15 +741,199 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
405
741
  flowLinks: links,
406
742
  activeAliases: active,
407
743
  groupKeys,
744
+ // #111: group boxes are a grid-layout feature only — radially scattered
745
+ // ring nodes can't be cleanly boxed. Ring keeps the #83 prefix hue.
746
+ groupBoxes: [] as { key: string; count: number; statuses: { working: number; idle: number; offline: number }; x: number; y: number; w: number; h: number }[],
747
+ // ring fits within VIEWBOX_H by construction (offlineRadius=325 + centre at y=330)
748
+ gridContentBottom: 0,
408
749
  };
409
- }, [messages, sessions, sseSessions]);
750
+ }, [messages, sessions, sseSessions, layout, nodeScale]);
410
751
 
411
752
  const workingCount = onlineNodes.filter(s => s.status === 'working').length;
753
+ // Round 6 / Loop: vendor distribution for the header chip — at a glance
754
+ // "what's in the fleet" (A:5 M:2 O:8 书:12 …) without opening a node.
755
+ // Sorted by count desc; "unknown" vendors collapse into a "?" bucket.
756
+ const vendorDist = useMemo(() => {
757
+ const tally = new Map<string, { initial: string; count: number; color: string }>();
758
+ for (const s of [...onlineNodes, ...offlineNodes]) {
759
+ const v = vendorForModel(s.model);
760
+ const key = v.id === 'unknown' ? '?' : v.initial;
761
+ const cur = tally.get(key);
762
+ if (cur) cur.count++;
763
+ else tally.set(key, { initial: key, count: 1, color: v.mono.text });
764
+ }
765
+ return [...tally.values()].sort((a, b) => b.count - a.count);
766
+ }, [onlineNodes, offlineNodes]);
412
767
  // Round 109 (Vincent 4582 P0): hover-gated labels above this node count
413
768
  // so dense fleets show clean avatars instead of a wall of overlapping
414
769
  // label cards. 16 ≈ where the triple-tier rings start to crowd.
415
770
  const denseLayout = onlineNodes.length + offlineNodes.length > 16;
416
771
  const [hoveredAlias, setHoveredAlias] = useState<string | null>(null);
772
+ // Round 86 / Loop: pointer-over-label preview. R63 wired the group
773
+ // label to click-pin but hover gave no preview — the same hover/click
774
+ // gap R83 closed for pressure-bar segments. This state lets the
775
+ // label trigger the same dim mechanism a node hover already drives,
776
+ // independently of whether the cursor happens to be over a member
777
+ // node. ORs into hoveredGroup below so the existing derivation logic
778
+ // (which composes with hoveredAlias, activeGroup, R85 marching ants)
779
+ // keeps working unchanged.
780
+ const [hoveredGroupLabel, setHoveredGroupLabel] = useState<string | null>(null);
781
+ // Round 8 / Loop: which group is currently focused. Nodes outside this
782
+ // group + other group boxes fade so the eye locks onto the team you're
783
+ // pointing at. Singletons use their own alias as the group key.
784
+ const hoveredGroup = hoveredGroupLabel
785
+ ?? (hoveredAlias ? (groupKeys[hoveredAlias] ?? hoveredAlias) : null);
786
+ // Round 63 / Loop: sticky group focus. R8 dims non-group nodes while
787
+ // a member is hovered, but releasing the hover lets the focus fade.
788
+ // Clicking the group label pins the group key here; activeGroup =
789
+ // hoveredGroup ?? pinnedGroup so hover transiently overrides the
790
+ // pin (handy for spot-comparing teams). Same compose pattern as
791
+ // R60/R61 pinnedStatus.
792
+ const [pinnedGroup, setPinnedGroup] = useState<string | null>(() => {
793
+ // R66: same per-tab persistence treatment as pinnedStatus above.
794
+ // The group key is opaque text (could be any prefix), so the init
795
+ // reader can't validate it against a known enum — it just reads
796
+ // whatever's stored. A useEffect below clears it if the value no
797
+ // longer matches any current group (sessions changed since save).
798
+ if (typeof window === 'undefined') return null;
799
+ try { return sessionStorage.getItem('anet-topo-pinned-group'); } catch { return null; }
800
+ });
801
+ const activeGroup = hoveredGroup ?? pinnedGroup;
802
+ // R66: sync pinnedGroup into sessionStorage when it changes; clear
803
+ // it if it no longer matches any current group (the session set
804
+ // can change between loads). The pinnedStatus sync sits next to
805
+ // its declaration further down.
806
+ useEffect(() => {
807
+ try {
808
+ if (pinnedGroup) sessionStorage.setItem('anet-topo-pinned-group', pinnedGroup);
809
+ else sessionStorage.removeItem('anet-topo-pinned-group');
810
+ } catch {}
811
+ }, [pinnedGroup]);
812
+ useEffect(() => {
813
+ if (!pinnedGroup) return;
814
+ const known = new Set(Object.values(groupKeys));
815
+ if (!known.has(pinnedGroup)) setPinnedGroup(null);
816
+ }, [pinnedGroup, groupKeys]);
817
+ // Round 49 / Loop: reverse-direction of R40's edge-on-node-hover linkage.
818
+ // R48 widened the flow hitbox to 16 px, so edges are precise enough to
819
+ // serve as a state trigger. When the user hovers a flow edge, light up its
820
+ // two endpoint nodes and dim the rest — "who is this edge between" becomes
821
+ // visible without reading the tooltip. The set is the link's two aliases
822
+ // (null when no edge hovered); node opacity composes this after inFocus.
823
+ const [hoveredEdgeKey, setHoveredEdgeKey] = useState<string | null>(null);
824
+ // Round 77 / Loop: hovering the "N active links" header chip globally
825
+ // brightens every flow edge (1.5× opacity). Transient affordance — the
826
+ // chip already says HOW MANY active flows exist; this answers WHERE
827
+ // they are in one glance, without the user scanning the canvas for
828
+ // moving particles. Reset on mouse leave.
829
+ const [hoveredActiveLinks, setHoveredActiveLinks] = useState(false);
830
+ // Round 80 / Loop: vendor-letter hover in the distribution chip. The
831
+ // chip already names vendor mix (`C:5 G:3 ?:1`); hover a letter and
832
+ // every node from OTHER vendors dims. Surfaces the breakdown spatially
833
+ // without inventing a new pin slot. Stores the vendor `initial`
834
+ // (single char or "?") that matches the tally key.
835
+ const [hoveredVendor, setHoveredVendor] = useState<string | null>(null);
836
+ // Round 88 / Loop: vendor filter pin. R80 added hover-to-dim on the
837
+ // vendor letters but the filter released as soon as the cursor left
838
+ // — vendor was the only filter dimension without a sticky variant
839
+ // (R60 status, R63 group, R69 Cmd+K all support pin). This state
840
+ // closes the gap. Same pattern as pinnedStatus / pinnedGroup:
841
+ // per-tab sessionStorage persistence, Esc clears, activeVendor =
842
+ // hoveredVendor ?? pinnedVendor so hover still wins for spot-
843
+ // comparison while a pin is active.
844
+ const [pinnedVendor, setPinnedVendor] = useState<string | null>(() => {
845
+ if (typeof window === 'undefined') return null;
846
+ try { return sessionStorage.getItem('anet-topo-pinned-vendor'); } catch { return null; }
847
+ });
848
+ const activeVendor = hoveredVendor ?? pinnedVendor;
849
+ useEffect(() => {
850
+ try {
851
+ if (pinnedVendor) sessionStorage.setItem('anet-topo-pinned-vendor', pinnedVendor);
852
+ else sessionStorage.removeItem('anet-topo-pinned-vendor');
853
+ } catch {}
854
+ }, [pinnedVendor]);
855
+ // R89: stale-purge. If the fleet's vendor distribution changes such
856
+ // that a previously-pinned vendor is gone (last node of that
857
+ // vendor disconnected), clear the pin so the chip row doesn't
858
+ // show "filter: A · 0" forever. Matches the same defensive purge
859
+ // pattern pinnedGroup uses higher up — sessionStorage survives
860
+ // reloads, but a stored value that no longer matches reality
861
+ // would paint with an impossible filter.
862
+ useEffect(() => {
863
+ if (pinnedVendor && !vendorDist.some(v => v.initial === pinnedVendor)) {
864
+ setPinnedVendor(null);
865
+ }
866
+ }, [pinnedVendor, vendorDist]);
867
+ // Round 55 / Loop: hovering a legend status row dims nodes whose status
868
+ // doesn't match. The legend was passive — "what does this colour mean".
869
+ // Now it answers "show me all of these" the same way R8 group-focus
870
+ // answers "show me this team". Three values match the legend rows.
871
+ const [hoveredStatus, setHoveredStatus] = useState<'working' | 'idle' | 'offline' | null>(null);
872
+ // Round 60 / Loop: sticky variant of `hoveredStatus`. R55 only filters
873
+ // while the user is actively hovering the legend; for sweeping a fleet
874
+ // you want to LOCK the filter. Each segment of the R31 pressure bar
875
+ // toggles a pin — click again on the same segment to release. The node
876
+ // opacity formula reads `activeStatus = hoveredStatus ?? pinnedStatus`
877
+ // so hover transiently overrides a pin (handy for spot-comparison)
878
+ // without nuking it.
879
+ // Round 66 / Loop: pin survives a page reload via sessionStorage —
880
+ // per-tab not per-browser (a new tab starts clean, intentionally).
881
+ // The init reader validates against the known status set so a stale
882
+ // / corrupt value can't paint the canvas with an impossible filter.
883
+ const [pinnedStatus, setPinnedStatus] = useState<'working' | 'idle' | 'offline' | null>(() => {
884
+ if (typeof window === 'undefined') return null;
885
+ try {
886
+ const v = sessionStorage.getItem('anet-topo-pinned-status');
887
+ return (v === 'working' || v === 'idle' || v === 'offline') ? v : null;
888
+ } catch { return null; }
889
+ });
890
+ const activeStatus = hoveredStatus ?? pinnedStatus;
891
+ // R66: sync pinnedStatus into sessionStorage when it changes. Paired
892
+ // with the matching effect for pinnedGroup higher up.
893
+ useEffect(() => {
894
+ try {
895
+ if (pinnedStatus) sessionStorage.setItem('anet-topo-pinned-status', pinnedStatus);
896
+ else sessionStorage.removeItem('anet-topo-pinned-status');
897
+ } catch {}
898
+ }, [pinnedStatus]);
899
+ // R69: listen for Cmd+K palette pin actions. The palette can't reach
900
+ // this component's state directly so it dispatches a CustomEvent;
901
+ // we react by setting pinnedStatus / pinnedGroup. The palette also
902
+ // writes to sessionStorage in lockstep, so the R66 init readers
903
+ // would pick it up on reload — the event handler is what keeps the
904
+ // currently-mounted canvas in sync without one.
905
+ useEffect(() => {
906
+ const onPin = (e: Event) => {
907
+ const detail = (e as CustomEvent).detail || {};
908
+ if (detail.kind === 'clear') {
909
+ // R90: extend the universal clear to vendor. R69 only cleared
910
+ // status + group because pinnedVendor didn't exist yet; R88
911
+ // shipped the third pin and R90 closes the palette gap so
912
+ // "Clear topology filters" really means all of them.
913
+ setPinnedStatus(null);
914
+ setPinnedGroup(null);
915
+ setPinnedVendor(null);
916
+ } else if (detail.kind === 'clear-vendor') {
917
+ // R90: granular vendor-only clear so power users can release
918
+ // just the vendor without disturbing status/group pins.
919
+ setPinnedVendor(null);
920
+ } else if (detail.kind === 'status') {
921
+ const v = detail.value;
922
+ if (v === 'working' || v === 'idle' || v === 'offline') setPinnedStatus(v);
923
+ } else if (detail.kind === 'group' && typeof detail.value === 'string') {
924
+ setPinnedGroup(detail.value);
925
+ }
926
+ };
927
+ window.addEventListener('anet:topo-pin', onPin);
928
+ return () => window.removeEventListener('anet:topo-pin', onPin);
929
+ }, []);
930
+ // R74 listener for layout + view palette commands lives below
931
+ // fitView's declaration — see further down in the file.
932
+ const hoveredEdgeEndpoints = useMemo<Set<string> | null>(() => {
933
+ if (!hoveredEdgeKey) return null;
934
+ const link = flowLinks.find(l => l.key === hoveredEdgeKey);
935
+ return link ? new Set([link.from, link.to]) : null;
936
+ }, [hoveredEdgeKey, flowLinks]);
417
937
 
418
938
  // --- Round 103 (issue #81): fullscreen + zoom + pan interaction layer ---
419
939
  // DIY native (no d3 / svg-pan-zoom): wrap the topology content in a single
@@ -433,6 +953,25 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
433
953
  // Issue #100: singleton chat popover. One alias at a time — clicking
434
954
  // another node swaps the target and the conversation switches in place.
435
955
  const [chatAlias, setChatAlias] = useState<string | null>(null);
956
+ // Round 14 / Loop: one-shot click ripple — fades outward from the clicked
957
+ // node so the click registers physically before the popover snaps in.
958
+ // Pairs with the Round 11 chat-focus ring: ripple expands, ring locks on.
959
+ // Keyed by ts so re-clicking the same node remounts the <circle> and the
960
+ // SMIL <animate> replays. Cleared 600ms after click (longer than the
961
+ // 500ms animation so a final frame at opacity 0 is still in the tree).
962
+ const [clickRipple, setClickRipple] = useState<{
963
+ ts: number; x: number; y: number; r0: number; color: string;
964
+ } | null>(null);
965
+ // #84: when a node is renamed while its chat popover is open, follow the
966
+ // rename so the conversation keeps targeting a live alias.
967
+ useEffect(() => {
968
+ if (renameSignal && renameSignal.from === chatAlias) {
969
+ setChatAlias(renameSignal.to);
970
+ }
971
+ // chatAlias intentionally omitted — only react to a new rename signal,
972
+ // not to the user opening/closing the popover.
973
+ // eslint-disable-next-line react-hooks/exhaustive-deps
974
+ }, [renameSignal]);
436
975
 
437
976
  useEffect(() => { viewRef.current = view; }, [view]);
438
977
 
@@ -453,6 +992,42 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
453
992
  } catch {}
454
993
  }, []);
455
994
 
995
+ // Round 28 / Loop: first-paint auto-fit. Round 27's cellH bump means
996
+ // dense grids (≥6-7 rows) overflow the 680-px viewBox at the natural
997
+ // 100% zoom — a new user sees the topology with its bottom rows
998
+ // clipped and no hint that there's more below. Auto-fit on first
999
+ // paint (and only if the user has no persisted view) sets the
1000
+ // initial zoom so all content fits. Subsequent zoom changes persist
1001
+ // and override the auto-fit on reload; explicit reset (0 key) still
1002
+ // goes back to 100% so the gesture's "reset to natural size" semantic
1003
+ // is preserved.
1004
+ //
1005
+ // Capture pre-mount persistence in a useState initializer — the
1006
+ // existing `persist` effect (declared below) runs on first render
1007
+ // with the default {1,0,0} view and writes it to localStorage before
1008
+ // the auto-fit effect (deps on async-arriving sessions) gets a turn.
1009
+ // Reading localStorage from the effect would see that write and
1010
+ // skip the fit. The useState snapshot fires once, before any effects.
1011
+ const [hadPersistedViewOnMount] = useState<boolean>(
1012
+ () => typeof window !== 'undefined' && !!localStorage.getItem('anet-topo-view'),
1013
+ );
1014
+ const autoFitDoneRef = useRef(false);
1015
+ useEffect(() => {
1016
+ if (autoFitDoneRef.current) return;
1017
+ if (hadPersistedViewOnMount) {
1018
+ autoFitDoneRef.current = true;
1019
+ return;
1020
+ }
1021
+ if (layout !== 'grid' || sessions.length === 0 || !gridContentBottom) return;
1022
+ if (gridContentBottom <= VIEWBOX_H) {
1023
+ autoFitDoneRef.current = true; // no overflow → no fit needed
1024
+ return;
1025
+ }
1026
+ const fitZoom = Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
1027
+ setView({ zoom: fitZoom, x: 0, y: 0 });
1028
+ autoFitDoneRef.current = true;
1029
+ }, [layout, sessions.length, gridContentBottom, hadPersistedViewOnMount]);
1030
+
456
1031
  // persist view
457
1032
  useEffect(() => {
458
1033
  try { localStorage.setItem('anet-topo-view', JSON.stringify(view)); } catch {}
@@ -472,6 +1047,14 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
472
1047
  const svg = svgRef.current;
473
1048
  if (!svg) return;
474
1049
  const onWheel = (e: WheelEvent) => {
1050
+ // Round 23 / Loop: only zoom on Ctrl/Meta+wheel when inline; plain
1051
+ // wheel over the canvas keeps scrolling the page (the topo sits at
1052
+ // the top of /; trapping page scroll mid-page is the classic
1053
+ // "scroll-jail" anti-pattern). In fullscreen the canvas owns the
1054
+ // viewport so any wheel zooms — no page scroll to preserve. Pinch-
1055
+ // zoom on a trackpad surfaces as ctrlKey=true natively, which is
1056
+ // also what we want (zoom the canvas, not the browser).
1057
+ if (!isFullscreen && !e.ctrlKey && !e.metaKey) return;
475
1058
  e.preventDefault();
476
1059
  const rect = svg.getBoundingClientRect();
477
1060
  const mx = ((e.clientX - rect.left) / rect.width) * VIEWBOX_W;
@@ -492,8 +1075,17 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
492
1075
  };
493
1076
  svg.addEventListener('wheel', onWheel, { passive: false });
494
1077
  return () => svg.removeEventListener('wheel', onWheel);
495
- }, []);
496
-
1078
+ // Re-attach when the fullscreen flag toggles so the handler's
1079
+ // closure picks up the new value (capture-by-closure means we'd
1080
+ // otherwise read the stale boolean forever).
1081
+ }, [isFullscreen]);
1082
+
1083
+ // Round 21 / Loop: pan-aware cursor. Static `grab` gave no tactile cue
1084
+ // that the canvas was actually being dragged — "grabbing" while
1085
+ // dragRef.current.active = true tells the hand it's moving the
1086
+ // viewport. Mirroring dragRef.active into state is the only way to
1087
+ // re-render the SVG style; the ref itself doesn't trigger renders.
1088
+ const [isPanning, setIsPanning] = useState(false);
497
1089
  const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
498
1090
  if (e.button !== 0) return;
499
1091
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
@@ -504,6 +1096,7 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
504
1096
  baseX: viewRef.current.x,
505
1097
  baseY: viewRef.current.y,
506
1098
  };
1099
+ setIsPanning(true);
507
1100
  };
508
1101
  const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
509
1102
  const d = dragRef.current;
@@ -518,6 +1111,7 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
518
1111
  const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
519
1112
  if (!dragRef.current.active) return;
520
1113
  dragRef.current.active = false;
1114
+ setIsPanning(false);
521
1115
  try {
522
1116
  (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
523
1117
  } catch {}
@@ -535,6 +1129,90 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
535
1129
  };
536
1130
  const resetView = () => setView({ zoom: 1, x: 0, y: 0 });
537
1131
 
1132
+ // Round 29 / Loop: `f` = fit-to-content. Shared by the Round 28
1133
+ // first-paint auto-fit effect and the keyboard handler so the math is
1134
+ // in one place. When content already fits at natural zoom, this is
1135
+ // effectively a "recenter" — `f` always lands on a known good view.
1136
+ const fitView = useCallback(() => {
1137
+ const zoom = !gridContentBottom || gridContentBottom <= VIEWBOX_H
1138
+ ? 1
1139
+ : Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
1140
+ setView({ zoom, x: 0, y: 0 });
1141
+ }, [gridContentBottom]);
1142
+
1143
+ // R74: listen for layout + view palette commands. Sister to R69's
1144
+ // pin listener — palette dispatches a CustomEvent, the reducer here
1145
+ // calls toggleLayout / fitView. Sits below fitView's declaration so
1146
+ // the deps list resolves cleanly.
1147
+ useEffect(() => {
1148
+ const onLayout = (e: Event) => {
1149
+ const detail = (e as CustomEvent).detail || {};
1150
+ if (detail.kind === 'toggle') toggleLayout();
1151
+ };
1152
+ const onView = (e: Event) => {
1153
+ const detail = (e as CustomEvent).detail || {};
1154
+ if (detail.kind === 'fit') fitView();
1155
+ };
1156
+ window.addEventListener('anet:topo-layout', onLayout);
1157
+ window.addEventListener('anet:topo-view', onView);
1158
+ return () => {
1159
+ window.removeEventListener('anet:topo-layout', onLayout);
1160
+ window.removeEventListener('anet:topo-view', onView);
1161
+ };
1162
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1163
+ }, [fitView]);
1164
+
1165
+ // Round 22 / Loop: keyboard zoom — +/= zoom in, - zoom out, 0 reset.
1166
+ // Round 29 / Loop: +f to fit content.
1167
+ // Listen on window so the user doesn't need to focus the SVG first,
1168
+ // but only act when no text input has focus (otherwise typing "-" in
1169
+ // a chat would zoom the topology — surprising and bad). Modifier keys
1170
+ // pass through so Cmd/Ctrl combos still hit their owners. The button
1171
+ // titles below document these shortcuts so users discover them.
1172
+ useEffect(() => {
1173
+ const onKey = (e: KeyboardEvent) => {
1174
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
1175
+ const ae = document.activeElement as HTMLElement | null;
1176
+ if (ae) {
1177
+ const tag = ae.tagName;
1178
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || ae.isContentEditable) return;
1179
+ }
1180
+ if (e.key === '+' || e.key === '=') { zoomBy(1.2); e.preventDefault(); }
1181
+ else if (e.key === '-' || e.key === '_') { zoomBy(1 / 1.2); e.preventDefault(); }
1182
+ else if (e.key === '0') { resetView(); e.preventDefault(); }
1183
+ else if (e.key === 'f' || e.key === 'F') { fitView(); e.preventDefault(); }
1184
+ // Round 32 / Loop: `l` toggles ring|grid. The vim-style `g l` route
1185
+ // (Audit Log) requires a preceding `g` within 1500ms; a bare `l`
1186
+ // outside that window is free for topology use.
1187
+ else if (e.key === 'l' || e.key === 'L') { toggleLayout(); e.preventDefault(); }
1188
+ // Round 62 / Loop: Esc clears the R60/R61 pinned status filter so
1189
+ // users have a universal-cancel keyboard out. Esc on an open chat
1190
+ // is owned by ChatPopover (which only mounts when chatAlias is
1191
+ // set), so this handler is effectively scoped to "no chat open".
1192
+ // We additionally guard on chatAlias to be explicit — if the chat
1193
+ // closed mid-cycle, the pin can still be cleared on the next Esc.
1194
+ // R63: extends to pinnedGroup too. Clears WHATEVER pin is active
1195
+ // (one Esc collapses all topology pins) so the keyboard escape
1196
+ // route stays a single key, not "Esc maybe Esc again".
1197
+ else if (e.key === 'Escape' && !chatAlias && (pinnedStatus || pinnedGroup || pinnedVendor)) {
1198
+ // R88: extend the universal-cancel to pinnedVendor too. One Esc
1199
+ // collapses every topology pin (matches R62/R63's "single key,
1200
+ // not Esc-maybe-Esc-again" promise).
1201
+ if (pinnedStatus) setPinnedStatus(null);
1202
+ if (pinnedGroup) setPinnedGroup(null);
1203
+ if (pinnedVendor) setPinnedVendor(null);
1204
+ e.preventDefault();
1205
+ }
1206
+ };
1207
+ window.addEventListener('keydown', onKey);
1208
+ return () => window.removeEventListener('keydown', onKey);
1209
+ // zoomBy / resetView are stable wrt setView callback; fitView changes
1210
+ // with gridContentBottom so deps list catches it. R62 adds chatAlias
1211
+ // and pinnedStatus so the Escape branch reads fresh state (re-binding
1212
+ // the listener on these state changes is sub-ms — cheaper than refs).
1213
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1214
+ }, [fitView, chatAlias, pinnedStatus, pinnedGroup, pinnedVendor]);
1215
+
538
1216
  const toggleFullscreen = () => {
539
1217
  const el = containerRef.current;
540
1218
  if (!el) return;
@@ -555,16 +1233,360 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
555
1233
  <div className="text-xs uppercase text-gray-600 tracking-wider">Network Topology</div>
556
1234
  <h2 className="text-lg text-white font-semibold">Command mesh</h2>
557
1235
  </div>
558
- <div className="flex flex-wrap gap-2 text-xs">
559
- <span className="px-2.5 py-1 rounded-md bg-green-500/10 text-green-300 border border-green-500/20">
1236
+ <div className="flex flex-wrap items-center gap-2 text-xs">
1237
+ {/* Issue #87: ring | grid layout toggle — segmented control,
1238
+ persisted to localStorage anet-topo-layout. */}
1239
+ <div className="inline-flex rounded-md border border-gray-500/25 overflow-hidden" role="group" aria-label="Topology layout">
1240
+ <button
1241
+ onClick={() => { if (layout !== 'ring') toggleLayout(); }}
1242
+ aria-pressed={layout === 'ring'}
1243
+ title="Ring layout (l to toggle)"
1244
+ className={`px-2.5 py-1 transition-colors ${layout === 'ring' ? 'bg-cyan-500/15 text-cyan-300' : 'text-gray-500 hover:text-gray-400'}`}
1245
+ >
1246
+ Ring
1247
+ </button>
1248
+ <button
1249
+ onClick={() => { if (layout !== 'grid') toggleLayout(); }}
1250
+ aria-pressed={layout === 'grid'}
1251
+ title="Grid layout (l to toggle)"
1252
+ 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'}`}
1253
+ >
1254
+ Grid
1255
+ </button>
1256
+ </div>
1257
+ {/* R79: working + online count chips become hover affordances —
1258
+ extends R77's chip-hover pattern to status counts. Hover the
1259
+ working chip → setHoveredStatus('working') so the R55 dim
1260
+ mechanic kicks in (same as hovering the SVG legend "working"
1261
+ row, just a different surface for the same gesture).
1262
+ The online chip uses the same setHoveredStatus('idle') path
1263
+ when there's idle but no working — falling back to working
1264
+ if any working node exists; "online" without a single bucket
1265
+ isn't part of the R55 type set, so this chip routes to the
1266
+ dominant online sub-tier instead of inventing a new state.
1267
+ Cursor only flips when there's anything to highlight. */}
1268
+ {/* R82: pin-mirror. R60 pressure-bar segments visualise the
1269
+ pinned status via an inset boxShadow; R61 legend rows via a
1270
+ concentric r=8 ring; the chip-row chips next to them did
1271
+ not — so a user who pinned via Cmd+K (R69) or the legend
1272
+ had no chip-row signal that "working" was the current
1273
+ filter. Mirror the pressure-bar treatment here so all
1274
+ status surfaces sing in unison. The online chip mirrors
1275
+ for idle pins (working ⊆ online; the routing in
1276
+ onMouseEnter already treats online as the idle fallback). */}
1277
+ <span
1278
+ className="px-2.5 py-1 rounded-md bg-green-500/10 text-green-300 border border-green-500/20"
1279
+ data-working-chip
1280
+ data-pin-mirror={pinnedStatus === 'working' ? 'true' : 'false'}
1281
+ title={workingCount > 0 ? (pinnedStatus === 'working' ? 'Pinned — Esc to clear' : 'Hover to highlight working nodes') : undefined}
1282
+ style={{
1283
+ cursor: workingCount > 0 ? 'pointer' : undefined,
1284
+ boxShadow: pinnedStatus === 'working' ? 'inset 0 0 0 1px #4ade80, inset 0 0 0 2px rgba(255,255,255,0.45)' : undefined,
1285
+ }}
1286
+ onMouseEnter={() => { if (workingCount > 0) setHoveredStatus('working'); }}
1287
+ onMouseLeave={() => setHoveredStatus(prev => prev === 'working' ? null : prev)}
1288
+ >
560
1289
  {workingCount} working
561
1290
  </span>
562
- <span className="px-2.5 py-1 rounded-md bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
1291
+ <span
1292
+ className="px-2.5 py-1 rounded-md bg-cyan-500/10 text-cyan-300 border border-cyan-500/20"
1293
+ data-online-chip
1294
+ data-pin-mirror={pinnedStatus === 'idle' ? 'true' : 'false'}
1295
+ title={onlineNodes.length > 0 ? (pinnedStatus === 'idle' ? 'Pinned — Esc to clear' : 'Hover to highlight online nodes') : undefined}
1296
+ style={{
1297
+ cursor: onlineNodes.length > 0 ? 'pointer' : undefined,
1298
+ boxShadow: pinnedStatus === 'idle' ? 'inset 0 0 0 1px #67e8f9, inset 0 0 0 2px rgba(255,255,255,0.45)' : undefined,
1299
+ }}
1300
+ onMouseEnter={() => {
1301
+ // If a working filter would isolate nothing, route to idle.
1302
+ const idleCount = onlineNodes.length - workingCount;
1303
+ if (workingCount > 0) setHoveredStatus('working');
1304
+ else if (idleCount > 0) setHoveredStatus('idle');
1305
+ }}
1306
+ onMouseLeave={() => setHoveredStatus(prev => prev === 'working' || prev === 'idle' ? null : prev)}
1307
+ >
563
1308
  {onlineNodes.length} online
564
1309
  </span>
565
- <span className="px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20">
566
- {flowLinks.length} active links
567
- </span>
1310
+ {/* Round 31 / Loop: fleet-health pressure bar. The "X working /
1311
+ Y online" chips already carry the raw counts; the bar lets
1312
+ the eye get the working/idle/offline RATIO in one glance
1313
+ without mental math. Stacked 3-segment chip, ~64 px wide. */}
1314
+ {(() => {
1315
+ const w = workingCount;
1316
+ const i = onlineNodes.length - workingCount; // idle = online - working
1317
+ const o = offlineNodes.length;
1318
+ const total = w + i + o;
1319
+ if (total === 0) return null;
1320
+ // Round 60 / Loop: each segment toggles a sticky filter via
1321
+ // `pinnedStatus`. Click the working segment → all non-working
1322
+ // nodes dim; click again → release. Segments share width with
1323
+ // their proportion of `total`, so a 1-node working share is
1324
+ // ~3 px wide on a 64-px bar. We pad the click target with a
1325
+ // negative-margin overlay wrapper to give thin slices a
1326
+ // 14-px minimum hit area without disturbing the rendered
1327
+ // chip width. cursor:pointer + a one-line title hint at the
1328
+ // affordance.
1329
+ const seg = (n: number, color: string, key: 'working' | 'idle' | 'offline', label: string) => {
1330
+ if (n === 0) return null;
1331
+ const isPinned = pinnedStatus === key;
1332
+ return (
1333
+ <span
1334
+ key={key}
1335
+ data-pressure-seg={key}
1336
+ role="button"
1337
+ tabIndex={0}
1338
+ aria-pressed={isPinned}
1339
+ title={`${n} ${label} — click to ${isPinned ? 'release filter' : 'highlight'}`}
1340
+ style={{
1341
+ width: `${(n / total) * 100}%`,
1342
+ background: color,
1343
+ height: '100%',
1344
+ cursor: 'pointer',
1345
+ boxShadow: isPinned ? `inset 0 0 0 1px ${color}, inset 0 0 0 2px rgba(255,255,255,0.6)` : undefined,
1346
+ }}
1347
+ onClick={(e) => {
1348
+ e.stopPropagation();
1349
+ setPinnedStatus(prev => prev === key ? null : key);
1350
+ }}
1351
+ onKeyDown={(e) => {
1352
+ if (e.key === 'Enter' || e.key === ' ') {
1353
+ e.preventDefault();
1354
+ setPinnedStatus(prev => prev === key ? null : key);
1355
+ }
1356
+ }}
1357
+ // R83: hover preview — segments now match the R55 legend,
1358
+ // R79 working/online chips, and R80 vendor letters in
1359
+ // offering a hover-transient highlight before the click
1360
+ // commits to a pin. Users get to feel what the filter
1361
+ // does before they lock it in. Same activeStatus =
1362
+ // hoveredStatus ?? pinnedStatus formula; releasing the
1363
+ // pointer falls back to the pin if one is set, or to
1364
+ // baseline. Thin (3-px) segments still hit-test fine
1365
+ // because the chip itself is a flex row — span doesn't
1366
+ // need any extra hit padding for hover.
1367
+ onMouseEnter={() => setHoveredStatus(key)}
1368
+ onMouseLeave={() => setHoveredStatus(prev => prev === key ? null : prev)}
1369
+ />
1370
+ );
1371
+ };
1372
+ // Round 47 / Loop: hidden on mobile — at <640px the chip row
1373
+ // wraps to multiple lines and crowds the topology header;
1374
+ // pressure ratio is best read with the working+online raw
1375
+ // counts (kept visible) anyway.
1376
+ return (
1377
+ <span
1378
+ 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"
1379
+ title={`${w} working · ${i} idle · ${o} offline`}
1380
+ data-fleet-pressure
1381
+ >
1382
+ <span className="text-[10px] tracking-wide">pressure</span>
1383
+ <span className="inline-flex h-1.5 w-16 rounded-full overflow-hidden" style={{ background: 'rgb(75 85 99 / 0.25)' }}>
1384
+ {seg(w, isLight ? '#059669' : '#22c55e', 'working', 'working')}
1385
+ {seg(i, isLight ? '#0d9488' : '#2dd4bf', 'idle', 'idle')}
1386
+ {seg(o, isLight ? '#94a3b8' : '#6b7280', 'offline', 'offline')}
1387
+ </span>
1388
+ </span>
1389
+ );
1390
+ })()}
1391
+ {/* Round 64 / Loop: active-filter pills. When pinnedStatus or
1392
+ pinnedGroup is set, show a small "filter: <key> ×" pill so
1393
+ the user can see the pin from the chip row even if they
1394
+ scrolled the canvas off-screen. × button clears the
1395
+ specific pin (Esc still clears all — both paths are
1396
+ valid). pinnedStatus and pinnedGroup pin independently so
1397
+ both pills may render simultaneously. */}
1398
+ {/* R67: pills enter via anet-fade-in so they appear softly
1399
+ instead of popping. The "filter: " prefix collapses below
1400
+ sm — at narrow viewports the chip row is precious real
1401
+ estate (R47 already hides pressure / vendor / freshness),
1402
+ so dropping the redundant label keeps the working/idle/
1403
+ alpha keys readable without overflow. */}
1404
+ {/* R71: each pill picks up a "· N" match count tail. Tells the
1405
+ user at a glance how many sessions the active filter
1406
+ matches without scanning the canvas. Counts come from the
1407
+ already-computed workingCount + onlineNodes + offlineNodes
1408
+ for status, and groupKeys for group. */}
1409
+ {/* R73: entire pill body is a click-to-clear target — matches
1410
+ the Notion / Linear tag UX (the whole chip releases). The
1411
+ × keeps its dedicated <button> + aria-label for screen
1412
+ readers; the outer span just adds an extra mouse-friendly
1413
+ hit area with a title hint. ×'s onClick stopPropagation
1414
+ so the redundant outer onClick doesn't double-fire (no
1415
+ functional difference since both clear, but cleaner
1416
+ event flow). */}
1417
+ {pinnedStatus && (() => {
1418
+ const matchCount = pinnedStatus === 'working' ? workingCount
1419
+ : pinnedStatus === 'idle' ? (onlineNodes.length - workingCount)
1420
+ : offlineNodes.length;
1421
+ return (
1422
+ <span
1423
+ data-active-filter="status"
1424
+ data-filter-match-count={matchCount}
1425
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in"
1426
+ title="Click to clear filter"
1427
+ onClick={() => setPinnedStatus(null)}
1428
+ style={{
1429
+ background: pinnedStatus === 'working' ? (isLight ? '#05966914' : '#22c55e1f')
1430
+ : pinnedStatus === 'idle' ? (isLight ? '#0d948814' : '#2dd4bf1f')
1431
+ : (isLight ? '#94a3b814' : '#6b72801f'),
1432
+ color: pinnedStatus === 'working' ? (isLight ? '#047857' : '#86efac')
1433
+ : pinnedStatus === 'idle' ? (isLight ? '#0f766e' : '#5eead4')
1434
+ : (isLight ? '#475569' : '#9ca3af'),
1435
+ borderColor: 'currentColor',
1436
+ cursor: 'pointer',
1437
+ }}
1438
+ >
1439
+ <span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{pinnedStatus}<span className="opacity-70"> · {matchCount}</span></span>
1440
+ <button
1441
+ type="button"
1442
+ aria-label={`Clear ${pinnedStatus} filter`}
1443
+ onClick={(e) => { e.stopPropagation(); setPinnedStatus(null); }}
1444
+ className="ml-0.5 leading-none hover:opacity-70"
1445
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1446
+ >×</button>
1447
+ </span>
1448
+ );
1449
+ })()}
1450
+ {pinnedGroup && (() => {
1451
+ const matchCount = Object.values(groupKeys).filter(k => k === pinnedGroup).length;
1452
+ return (
1453
+ <span
1454
+ data-active-filter="group"
1455
+ data-filter-match-count={matchCount}
1456
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in"
1457
+ title="Click to clear filter"
1458
+ onClick={() => setPinnedGroup(null)}
1459
+ style={{
1460
+ background: isLight ? '#67e8f914' : '#67e8f91f',
1461
+ color: pal.legendAccent,
1462
+ borderColor: 'currentColor',
1463
+ cursor: 'pointer',
1464
+ }}
1465
+ >
1466
+ <span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{pinnedGroup}<span className="opacity-70"> · {matchCount}</span></span>
1467
+ <button
1468
+ type="button"
1469
+ aria-label={`Clear group filter ${pinnedGroup}`}
1470
+ onClick={(e) => { e.stopPropagation(); setPinnedGroup(null); }}
1471
+ className="ml-0.5 leading-none hover:opacity-70"
1472
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1473
+ >×</button>
1474
+ </span>
1475
+ );
1476
+ })()}
1477
+ {/* R89: vendor pin gets its own filter pill, matching the R64
1478
+ status + group pattern. R88 added the pin but only the
1479
+ letter itself carried the state; the chip-row had no
1480
+ "filter: A · 2 ×" affordance the other two pins have. The
1481
+ pill colour borrows the vendor's own swatch so each pin
1482
+ still reads in its native hue (A green, O cyan, 书 blue,
1483
+ ? slate). Same body-click-clears + × button pattern as
1484
+ R64. */}
1485
+ {pinnedVendor && (() => {
1486
+ const matchEntry = vendorDist.find(v => v.initial === pinnedVendor);
1487
+ const matchCount = matchEntry?.count ?? 0;
1488
+ const vendorColor = matchEntry?.color ?? pal.legendText;
1489
+ return (
1490
+ <span
1491
+ data-active-filter="vendor"
1492
+ data-filter-match-count={matchCount}
1493
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in"
1494
+ title="Click to clear vendor filter"
1495
+ onClick={() => setPinnedVendor(null)}
1496
+ style={{
1497
+ background: `${vendorColor}1f`,
1498
+ color: vendorColor,
1499
+ borderColor: 'currentColor',
1500
+ cursor: 'pointer',
1501
+ }}
1502
+ >
1503
+ <span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{pinnedVendor}<span className="opacity-70"> · {matchCount}</span></span>
1504
+ <button
1505
+ type="button"
1506
+ aria-label={`Clear vendor filter ${pinnedVendor}`}
1507
+ onClick={(e) => { e.stopPropagation(); setPinnedVendor(null); }}
1508
+ className="ml-0.5 leading-none hover:opacity-70"
1509
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1510
+ >×</button>
1511
+ </span>
1512
+ );
1513
+ })()}
1514
+ {vendorDist.length > 1 && (
1515
+ <span
1516
+ 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"
1517
+ title="Hover to highlight; click to pin"
1518
+ >
1519
+ {vendorDist.map(v => {
1520
+ const isPinned = pinnedVendor === v.initial;
1521
+ // R88: click toggles a sticky filter the same way R60
1522
+ // pressure-bar segments toggle pinnedStatus. Visual
1523
+ // mirror = inset boxShadow using the vendor's own
1524
+ // colour, so each pinned letter sings in its own hue
1525
+ // (Anthropic green / OpenAI cyan / 书 blue / ?).
1526
+ return (
1527
+ <span
1528
+ key={v.initial}
1529
+ role="button"
1530
+ tabIndex={0}
1531
+ aria-pressed={isPinned}
1532
+ className="inline-flex items-baseline gap-0.5 px-1 rounded"
1533
+ data-vendor-letter={v.initial}
1534
+ data-vendor-pinned={isPinned ? 'true' : 'false'}
1535
+ title={isPinned
1536
+ ? `Vendor "${v.initial}" pinned — click again or Esc to clear`
1537
+ : `Hover to highlight "${v.initial}"; click to pin`}
1538
+ style={{
1539
+ cursor: 'pointer',
1540
+ boxShadow: isPinned
1541
+ ? `inset 0 0 0 1px ${v.color}, inset 0 0 0 2px rgba(255,255,255,0.45)`
1542
+ : undefined,
1543
+ }}
1544
+ onMouseEnter={() => setHoveredVendor(v.initial)}
1545
+ onMouseLeave={() => setHoveredVendor(prev => prev === v.initial ? null : prev)}
1546
+ onClick={() => setPinnedVendor(prev => prev === v.initial ? null : v.initial)}
1547
+ onKeyDown={(e) => {
1548
+ if (e.key === 'Enter' || e.key === ' ') {
1549
+ e.preventDefault();
1550
+ setPinnedVendor(prev => prev === v.initial ? null : v.initial);
1551
+ }
1552
+ }}
1553
+ >
1554
+ <span style={{ color: v.color }}>{v.initial}</span>
1555
+ <span className="text-gray-500">:{v.count}</span>
1556
+ </span>
1557
+ );
1558
+ })}
1559
+ </span>
1560
+ )}
1561
+ {/* Round 42 / Loop: extend active-links chip with the timestamp
1562
+ of the most-recent flow event. Tells the operator at a glance
1563
+ whether the topology is currently humming (last 30s) or has
1564
+ been quiet for a while — the visual flow particles and
1565
+ edge brightness only show that there IS traffic, not when
1566
+ the last one was. Reuses Round 38's relativeAgo. */}
1567
+ {(() => {
1568
+ const recent = flowLinks.reduce<number | null>((acc, l) => {
1569
+ if (!l.last_at) return acc;
1570
+ const t = parseHubTime(l.last_at);
1571
+ if (t === null) return acc;
1572
+ return acc === null || t > acc ? t : acc;
1573
+ }, null);
1574
+ const rel = recent !== null ? relativeAgo(new Date(recent).toISOString()) : null;
1575
+ return (
1576
+ <span
1577
+ className="hidden sm:inline px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20"
1578
+ data-active-links-chip
1579
+ title={flowLinks.length > 0 ? 'Hover to brighten all active flows' : undefined}
1580
+ style={{ cursor: flowLinks.length > 0 ? 'pointer' : undefined }}
1581
+ onMouseEnter={() => { if (flowLinks.length > 0) setHoveredActiveLinks(true); }}
1582
+ onMouseLeave={() => setHoveredActiveLinks(false)}
1583
+ >
1584
+ {flowLinks.length} active link{flowLinks.length === 1 ? '' : 's'}
1585
+ {rel ? <span className="text-gray-500"> · last {rel}</span> : null}
1586
+ </span>
1587
+ );
1588
+ })()}
1589
+ <FreshnessChip sessions={sessions} />
568
1590
  </div>
569
1591
  </div>
570
1592
 
@@ -584,7 +1606,18 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
584
1606
  onPointerMove={onPointerMove}
585
1607
  onPointerUp={onPointerUp}
586
1608
  onPointerLeave={onPointerUp}
587
- style={{ cursor: 'grab', touchAction: 'none' }}
1609
+ // Round 41 / Loop: the reset-button title (R22) and the Help
1610
+ // overlay (R25) both advertise "double-click the canvas to
1611
+ // reset", but the handler was never actually wired. The text
1612
+ // was lying. Wire it here, guarded so dbl-clicking a node
1613
+ // (which would also trigger the SVG-level handler via event
1614
+ // bubbling) doesn't unexpectedly reset the view on the user.
1615
+ onDoubleClick={(e) => {
1616
+ const t = e.target as Element | null;
1617
+ if (t?.closest('g[data-node]')) return;
1618
+ resetView();
1619
+ }}
1620
+ style={{ cursor: isPanning ? 'grabbing' : 'grab', touchAction: 'none' }}
588
1621
  >
589
1622
  <defs>
590
1623
  <linearGradient id="topo-panel" x1="0" x2="1" y1="0" y2="1">
@@ -606,9 +1639,37 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
606
1639
  </feMerge>
607
1640
  </filter>
608
1641
  )}
609
- <marker id="topo-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
610
- <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
611
- </marker>
1642
+ {/* Round 16 / Loop: 3-tier flow-link arrow markers.
1643
+ The single marker had `markerUnits` defaulting to
1644
+ `strokeWidth`, so heavy edges (stroke=7) rendered 35-user-
1645
+ unit arrowheads — visually dominant, the head outweighed
1646
+ the line. Switching to `userSpaceOnUse` decouples arrow
1647
+ size from stroke; binning by count gives a clearer
1648
+ hierarchy than continuous linear scaling:
1649
+ s (count 1-2) → 12 user units
1650
+ m (count 3-4) → 16 user units (alias for `topo-arrow`)
1651
+ l (count 5+) → 22 user units
1652
+ `topo-arrow` stays bound to the medium tier so the legend
1653
+ swatch (line ~1500) renders without change. */}
1654
+ {[
1655
+ { id: 'topo-arrow-s', size: 12 },
1656
+ { id: 'topo-arrow', size: 16 },
1657
+ { id: 'topo-arrow-l', size: 22 },
1658
+ ].map(m => (
1659
+ <marker
1660
+ key={m.id}
1661
+ id={m.id}
1662
+ viewBox="0 0 10 10"
1663
+ refX="8"
1664
+ refY="5"
1665
+ markerWidth={m.size}
1666
+ markerHeight={m.size}
1667
+ markerUnits="userSpaceOnUse"
1668
+ orient="auto-start-reverse"
1669
+ >
1670
+ <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
1671
+ </marker>
1672
+ ))}
612
1673
  {/* Round 45: radial radar sweep gradient — bright at center
613
1674
  (where it meets the hub) fading to leading edge. */}
614
1675
  <radialGradient id="topo-sweep" cx="0%" cy="50%" r="100%">
@@ -624,13 +1685,21 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
624
1685
  {/* Round 103 (issue #81): everything inside this <g> zooms + pans
625
1686
  together. transform order = translate then scale. */}
626
1687
  <g transform={`translate(${view.x} ${view.y}) scale(${view.zoom})`}>
627
- <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" />
1688
+ {/* Issue #87: radar/ring ambiance renders only in ring layout —
1689
+ grid mode drops it so the concentric rings don't sit behind a
1690
+ rectangular grid. */}
1691
+ {layout === 'ring' && (<>
1692
+ {/* R52: radar bg is pure decoration — drop its pointer events so
1693
+ the hub <g> under it (and any future inner-disk affordances)
1694
+ can receive clicks. Previously the r=330 disc intercepted
1695
+ the hub click outright. */}
1696
+ <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" style={{ pointerEvents: 'none' }} />
628
1697
 
629
1698
  {/* Round 45: subtle star field — 24 deterministic dots scattered
630
1699
  across the canvas give the radar bg some depth. Skipped on
631
1700
  light theme so the white surface stays clean. */}
632
1701
  {!isLight && (
633
- <g opacity="0.5">
1702
+ <g opacity="0.5" style={{ pointerEvents: 'none' }}>
634
1703
  {Array.from({ length: 28 }).map((_, i) => {
635
1704
  // Deterministic pseudo-random scatter so positions are
636
1705
  // stable between renders (no JS hydration mismatch).
@@ -652,6 +1721,7 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
652
1721
  style={{
653
1722
  transformOrigin: `${cx}px ${cy}px`,
654
1723
  transformBox: 'view-box',
1724
+ pointerEvents: 'none',
655
1725
  }}
656
1726
  className="anet-topo-sweep"
657
1727
  opacity={isLight ? 0.7 : 1}
@@ -662,11 +1732,82 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
662
1732
  />
663
1733
  </g>
664
1734
 
665
- {/* radar rings */}
1735
+ {/* radar rings — pure decoration at fixed radii, independent of
1736
+ node positions so the radar aesthetic is preserved across tier
1737
+ changes. */}
666
1738
  {[90, 170, 250, 330].map(radius => (
667
1739
  <circle key={radius} cx={cx} cy={cy} r={radius} fill="none" stroke={pal.ringStroke} strokeWidth="1" opacity={isLight ? 0.6 : 0.35} />
668
1740
  ))}
669
1741
 
1742
+ {/* Round 54 / Loop: tier-radius guide rings. The radar rings above
1743
+ are decorative and don't match the actual tier radii nodes sit
1744
+ on (single 220 / dual 175,260 / triple 145,215,285). Drawing
1745
+ a faint dashed ring at each ACTIVE tier radius lets the eye
1746
+ anchor "this is the inner / outer ring" without inferring from
1747
+ node spacing. Picked based on online node count so only the
1748
+ tiers currently in use draw — empty tiers stay quiet. pointer-
1749
+ events:none so they never intercept hub or node clicks. The
1750
+ 0.7 stroke + dashed pattern reads as guide, not feature. */}
1751
+ {(() => {
1752
+ const tierRadii = onlineNodes.length > onlineTripleThreshold
1753
+ ? [onlineTripleInnerR, onlineTripleMidR, onlineTripleOuterR]
1754
+ : onlineNodes.length > onlineTierThreshold
1755
+ ? [onlineInnerRadius, onlineOuterRadius]
1756
+ : onlineNodes.length > 0
1757
+ ? [onlineRadius]
1758
+ : [];
1759
+ // Round 92 / Loop: tier-ring occupancy. R54 drew the guide
1760
+ // rings at fixed opacity regardless of how many nodes lived
1761
+ // on each tier. With pinned filters dimming most nodes to
1762
+ // 0.28, the ring at a deserted tier looked identical to a
1763
+ // crowded one — wasting a free piece of canvas. Count
1764
+ // online nodes whose hub-distance falls within ±15 px of
1765
+ // each ring (15 px = half the inter-tier gap, so each
1766
+ // node is assigned to exactly one ring). Empty tier → skip
1767
+ // entirely. Crowded tier → stronger opacity, says "look
1768
+ // here". Buckets so the ladder feels intentional, not
1769
+ // jittery as one node migrates between tiers.
1770
+ const occupancyOf = (r: number) => onlineNodes.reduce((acc, s) => {
1771
+ const p = nodePositions[s.alias];
1772
+ if (!p) return acc;
1773
+ const d = Math.hypot(p.x - cx, p.y - cy);
1774
+ return Math.abs(d - r) < 15 ? acc + 1 : acc;
1775
+ }, 0);
1776
+ // Round 93 / Loop: when any pin is active, tint the tier
1777
+ // rings to the legend accent so the spatial guide visually
1778
+ // shares the canvas's "filtered mode" colour. The chip
1779
+ // row already says WHICH filter is on (pills + letter
1780
+ // mirror); rings answering "we're filtered" reinforces
1781
+ // the state when the eye is on the canvas, not the chip
1782
+ // row. Composes cleanly with R92 occupancy — same opacity
1783
+ // bucket logic; just the stroke colour swaps.
1784
+ const anyPin = !!(pinnedStatus || pinnedGroup || pinnedVendor);
1785
+ const tierStroke = anyPin ? pal.legendAccent : pal.ringStroke;
1786
+ return tierRadii.map(r => {
1787
+ const n = occupancyOf(r);
1788
+ if (n === 0) return null;
1789
+ const bucket = n <= 2 ? 0 : n <= 6 ? 1 : 2;
1790
+ const opLight = [0.24, 0.36, 0.50][bucket];
1791
+ const opDark = [0.32, 0.46, 0.62][bucket];
1792
+ return (
1793
+ <circle
1794
+ key={`tier-${r}`}
1795
+ cx={cx} cy={cy} r={r}
1796
+ fill="none"
1797
+ stroke={tierStroke}
1798
+ strokeWidth="0.7"
1799
+ strokeDasharray="2 8"
1800
+ opacity={isLight ? opLight : opDark}
1801
+ style={{ pointerEvents: 'none', transition: 'stroke 200ms ease-out' }}
1802
+ data-tier-ring={r}
1803
+ data-tier-occupancy={n}
1804
+ data-tier-bucket={bucket}
1805
+ data-tier-tinted={anyPin ? 'true' : 'false'}
1806
+ />
1807
+ );
1808
+ });
1809
+ })()}
1810
+
670
1811
  {/* Round 50: 4 small particles slowly orbiting the outer ring
671
1812
  (r=330). Each starts at a different angle (offset 0/0.25/0.5/0.75
672
1813
  of the cycle) so they're evenly spaced. 16s per revolution is
@@ -705,12 +1846,13 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
705
1846
  opacity={isLight ? 0.35 : 0.18}
706
1847
  />
707
1848
  ))}
1849
+ </>)}
708
1850
 
709
1851
  {/* hub links — round 46: idle spokes now have animated
710
1852
  stroke-dashoffset so dashes flow outward from the hub
711
1853
  ("command relay" feel). Active spokes carrying live message
712
1854
  flow stay as solid bright strokes. */}
713
- {onlineNodes.map((session, idx) => {
1855
+ {layout === 'ring' && onlineNodes.map((session, idx) => {
714
1856
  const pos = nodePositions[session.alias];
715
1857
  if (!pos) return null;
716
1858
  const path = curvePath({ x: cx, y: cy }, pos, 0);
@@ -731,27 +1873,260 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
731
1873
  );
732
1874
  })}
733
1875
 
1876
+ {/* #111: prefix-group boundary boxes (Vincent 4722). Grid layout
1877
+ only — groupBoxes is empty in ring mode. Rendered behind the
1878
+ flow links + nodes; pointer-events off so they never intercept
1879
+ a node click. Restrained dashed container + group-name label. */}
1880
+ {groupBoxes.map(box => {
1881
+ const isHovered = activeGroup === box.key;
1882
+ // R68: distinguish "locked by click" from "currently hovered".
1883
+ // R63 made pinned and hovered identical (both hit isHovered
1884
+ // via activeGroup). A user with one team pinned should see at
1885
+ // a glance which is the locked one even while their cursor
1886
+ // sweeps elsewhere. isPinned reads pinnedGroup directly
1887
+ // (NOT activeGroup) so the visual is specific to the sticky
1888
+ // state — transient hover keeps the R63 isHovered styling.
1889
+ const isPinned = pinnedGroup === box.key;
1890
+ // Round 18 / Loop: group-box hover linkage. The Round 8 fade
1891
+ // already dropped OUT-of-focus groups to 0.28, but the IN-focus
1892
+ // group sat at its baseline appearance — no positive emphasis.
1893
+ // Hovering now upgrades the box to an "accent" treatment:
1894
+ // solid stroke (not dashed), thicker, accent-coloured; brighter
1895
+ // text and slightly stronger fill. Label and box read as one
1896
+ // selected unit. Geometry unchanged → overlap test untouched.
1897
+ return (
1898
+ <g
1899
+ key={`grp-${box.key}`}
1900
+ data-group={box.key}
1901
+ className="transition-opacity"
1902
+ // R63: drop the blanket pointerEvents:'none' that
1903
+ // previously sat here. Chrome's SVG impl doesn't let a
1904
+ // child override a parent's `none` even though the spec
1905
+ // says it should — moving the property onto just the
1906
+ // rect (where it's needed so nodes underneath stay
1907
+ // clickable) lets the label text receive its own click.
1908
+ style={{ opacity: !activeGroup || isHovered ? 1 : 0.28 }}
1909
+ >
1910
+ <rect
1911
+ x={box.x}
1912
+ y={box.y}
1913
+ width={box.w}
1914
+ height={box.h}
1915
+ rx="14"
1916
+ fill={isLight ? '#0f172a' : '#a5b4fc'}
1917
+ // R68: 3-tier opacity + stroke ladder.
1918
+ // pinned → fill 0.08 / 0.13, stroke 3 px (locked)
1919
+ // hovered → fill 0.05 / 0.09, stroke 2 px (inspecting)
1920
+ // idle → fill 0.025 / 0.045, stroke 1.5 px dashed
1921
+ fillOpacity={isPinned ? (isLight ? 0.08 : 0.13)
1922
+ : isHovered ? (isLight ? 0.05 : 0.09)
1923
+ : (isLight ? 0.025 : 0.045)}
1924
+ stroke={(isPinned || isHovered) ? pal.legendAccent : pal.ringStroke}
1925
+ strokeWidth={isPinned ? 3 : isHovered ? 2 : 1.5}
1926
+ strokeDasharray={(isPinned || isHovered) ? 'none' : '6 6'}
1927
+ data-group-box-pinned={isPinned ? 'true' : 'false'}
1928
+ // R85: ambient "marching ants" drift on the perimeter
1929
+ // when this group has at least one working member, and
1930
+ // neither pin nor hover is active (those treatments
1931
+ // already shout for attention via solid stroke). 12s
1932
+ // cycle reads as ambient — the eye parses "live work
1933
+ // here" without registering the box as animating.
1934
+ data-group-box-live={!isPinned && !isHovered && box.statuses.working > 0 ? 'true' : 'false'}
1935
+ className={!isPinned && !isHovered && box.statuses.working > 0 ? 'anet-topo-groupbox-live' : undefined}
1936
+ style={{ transition: 'stroke 200ms ease-out, stroke-width 200ms ease-out, fill-opacity 200ms ease-out', pointerEvents: 'none' }}
1937
+ />
1938
+ {/* R63: wrap label in a clickable <g> with an invisible
1939
+ rect hitbox. The text alone wasn't getting hit-tested
1940
+ reliably — the SVG-wide topo-panel <rect> intercepts
1941
+ at the label's screen position when the label sits at
1942
+ a high viewBox-y (it lands below where the compositor
1943
+ expects the text to paint on top, same gotcha as the
1944
+ recent-signal panel rows in R56). Hitbox rect + the
1945
+ parent <g> taking the click + onPointerDown stop-
1946
+ propagation match the R55/R56/R61 pattern. */}
1947
+ <g
1948
+ role="button"
1949
+ tabIndex={0}
1950
+ aria-pressed={pinnedGroup === box.key}
1951
+ data-group-label-hit={box.key}
1952
+ style={{ pointerEvents: 'all', cursor: 'pointer' }}
1953
+ onPointerDown={(e) => e.stopPropagation()}
1954
+ onClick={() => setPinnedGroup(prev => prev === box.key ? null : box.key)}
1955
+ // R86: hover the label → transient group focus. Releases
1956
+ // on leave; activeGroup = hoveredGroup ?? pinnedGroup
1957
+ // formula already handles transient-over-pin so a
1958
+ // user can spot-compare teams without losing their
1959
+ // pinned one. Closes the same R83-style hover/click
1960
+ // gap (segments) — now group labels carry it too.
1961
+ onPointerEnter={() => setHoveredGroupLabel(box.key)}
1962
+ onPointerLeave={() => setHoveredGroupLabel(prev => prev === box.key ? null : prev)}
1963
+ >
1964
+ <rect
1965
+ x={box.x + 6}
1966
+ y={box.y + 2}
1967
+ width={Math.min(box.w - 12, 240)}
1968
+ height={20}
1969
+ fill="transparent"
1970
+ />
1971
+ <text
1972
+ x={box.x + 12}
1973
+ y={box.y + 14}
1974
+ fill={isHovered ? pal.legendHeadline : pal.legendText}
1975
+ fontSize="13"
1976
+ fontFamily="monospace"
1977
+ fontWeight="700"
1978
+ style={{ transition: 'fill 200ms ease-out' }}
1979
+ data-group-label={box.key}
1980
+ >
1981
+ {box.key}
1982
+ {/* Round 19 / Loop: member-count chip. Inline tspan stays
1983
+ in the single <text> bbox the overlap test reads, so
1984
+ the node↔label guard still catches if the chip ever
1985
+ pushes the label far enough right to clip a node.
1986
+ Smaller + lighter weight reads as metadata, not name. */}
1987
+ <tspan dx="6" fill={pal.legendText} fontSize="11" fontWeight="400">· {box.count}</tspan>
1988
+ {/* Round 58 / Loop: status mix pip strip. Compact text-
1989
+ based chips (e.g. "2w 1i") so the strip stays inside
1990
+ the same <text> bbox the overlap-test reads — keeps
1991
+ the R27 label↔label and R19 node↔label guards intact.
1992
+ Each tier is colour-coded against the legend swatches
1993
+ and only renders when count > 0, so a healthy all-
1994
+ working group reads simply " · 2w". */}
1995
+ {box.statuses.working > 0 && (
1996
+ <tspan dx="8" fill={isLight ? '#059669' : '#22c55e'} fontSize="11" fontWeight="600">{box.statuses.working}w</tspan>
1997
+ )}
1998
+ {box.statuses.idle > 0 && (
1999
+ <tspan dx="4" fill={isLight ? '#0d9488' : '#2dd4bf'} fontSize="11" fontWeight="600">{box.statuses.idle}i</tspan>
2000
+ )}
2001
+ {box.statuses.offline > 0 && (
2002
+ <tspan dx="4" fill={isLight ? '#94a3b8' : '#6b7280'} fontSize="11" fontWeight="600">{box.statuses.offline}o</tspan>
2003
+ )}
2004
+ </text>
2005
+ </g>
2006
+ </g>
2007
+ );
2008
+ })}
2009
+
734
2010
  {/* directed message flows */}
735
2011
  {flowLinks.map((link, index) => {
736
2012
  const from = nodePositions[link.from];
737
2013
  const to = nodePositions[link.to];
738
2014
  if (!from || !to) return null;
739
2015
 
740
- const lift = index % 2 === 0 ? 36 : -36;
2016
+ // Round 7 / Loop: lift now scales with distance so short links
2017
+ // aren't over-bent (long links keep the ~36px hump), and the
2018
+ // particle period shortens with link.count so busier edges
2019
+ // visibly pulse faster — instant info density on top of the
2020
+ // existing stroke-width-by-count chip.
2021
+ const dist = Math.hypot(to.x - from.x, to.y - from.y);
2022
+ const lift = (index % 2 === 0 ? 1 : -1) * Math.min(36, dist * 0.18);
741
2023
  const path = curvePath(from, to, lift);
742
2024
  const width = Math.min(2 + link.count, 7);
743
- const duration = 2.2 + (index % 5) * 0.32;
744
-
2025
+ const duration = Math.max(0.9, 2.6 / Math.sqrt(link.count));
2026
+ // Round 10 / Loop: freshness fade. An edge that fired ≤30s ago
2027
+ // stays at full intensity; over 5 minutes it decays to ~35%.
2028
+ // Surfaces "what's happening now" vs background chatter without
2029
+ // hiding old flow entirely (some context still useful). `now`
2030
+ // captured at useMemo-recompute time (every 5s message refresh)
2031
+ // — accuracy is within the poll interval, plenty.
2032
+ const ageMs = link.last_at ? Math.max(0, Date.now() - Date.parse(link.last_at)) : 0;
2033
+ const fresh = Math.max(0.35, 1 - ageMs / (5 * 60 * 1000));
2034
+ // Round 16 arrow-tier binning — keep `topo-arrow` as the
2035
+ // medium tier id so the legend swatch picks it up unchanged.
2036
+ const arrowId = link.count <= 2 ? 'topo-arrow-s'
2037
+ : link.count <= 4 ? 'topo-arrow'
2038
+ : 'topo-arrow-l';
2039
+
2040
+ // Round 39 / Loop: edge hover tooltip — surface the same
2041
+ // last_at + count info the freshness fade and arrow tier
2042
+ // already encode visually, in plain text. The stroke is the
2043
+ // hover target; SVG `<title>` honours newlines on every
2044
+ // browser the dashboard targets.
2045
+ const lastAt = relativeAgo(link.last_at);
2046
+ const tooltip = `${link.from} → ${link.to}\n${link.count} message${link.count === 1 ? '' : 's'}${lastAt ? ` · last ${lastAt}` : ''}`;
2047
+ // Round 40 / Loop: edges follow node hover — when an alias is
2048
+ // hovered, every edge touching it brightens, the rest fade.
2049
+ // Pairs with the Round 8 group-focus fade on nodes: hover a
2050
+ // node to find "who is this agent talking to" at a glance.
2051
+ // No hover → multiplier is 1.0 (current behaviour preserved).
2052
+ // Round 50 / Loop: edge-on-self priority. When the user hovers
2053
+ // a flow edge directly (R48 widened the hitbox so this is now
2054
+ // a precise gesture), THAT edge gets the strongest boost (2.0)
2055
+ // and every other edge dims to 0.35. Node-hover (R40) keeps
2056
+ // its own ladder. Edge-hover and node-hover are mutually
2057
+ // exclusive in practice — the cursor is over one or the
2058
+ // other — but the order below makes edge-hover win if both
2059
+ // ever read truthy at the same React tick.
2060
+ // Round 53 / Loop: in-group edges follow team focus. R40
2061
+ // brightened only edges touching the exact hovered alias —
2062
+ // but with R8/R18 prefix-clustering, a user hovering one
2063
+ // member is asking about the team. So when BOTH endpoints of
2064
+ // an edge share the hoveredGroup (and neither is the exact
2065
+ // hovered alias — that gets the stronger 1.7×), the edge
2066
+ // boosts to 1.3×. Edges leaving the team (one endpoint in,
2067
+ // one out) still dim to 0.35× per R40 since they read as
2068
+ // background to the focus. Singletons fall through unchanged
2069
+ // (their group key is the alias itself, so bothInHoveredGroup
2070
+ // is impossible for a non-self-edge).
2071
+ const isHoveredEdge = hoveredEdgeKey === link.key;
2072
+ const fromGroup = groupKeys[link.from] ?? link.from;
2073
+ const toGroup = groupKeys[link.to] ?? link.to;
2074
+ const bothInHoveredGroup = !!activeGroup && fromGroup === activeGroup && toGroup === activeGroup;
2075
+ // R63: also gate "no filter" on activeGroup so pinnedGroup
2076
+ // alone activates the in-group 1.3× boost + non-group dim.
2077
+ // When neither a node nor a group is in focus, mul is 1.
2078
+ // R77: when the user hovers the "N active links" chip, the
2079
+ // baseline 1× becomes 1.5× — every flow brightens at once.
2080
+ // Sits inside the no-other-hover branch so it doesn't fight
2081
+ // the edge-hover (2.0×) or node-hover (1.7×) priorities.
2082
+ const edgeOpacityMul = isHoveredEdge
2083
+ ? 2.0
2084
+ : hoveredEdgeKey
2085
+ ? 0.35
2086
+ : !hoveredAlias && !activeGroup
2087
+ ? (hoveredActiveLinks ? 1.5 : 1)
2088
+ : (link.from === hoveredAlias || link.to === hoveredAlias)
2089
+ ? 1.7
2090
+ : bothInHoveredGroup
2091
+ ? 1.3
2092
+ : 0.35;
2093
+ // Round 50: the hovered edge also visibly thickens so the eye
2094
+ // tracks it even at low message counts (width was 3 → 4.5 on
2095
+ // hover). 1.4× is enough to read as "lifted" without
2096
+ // breaching the 16-px hitbox bound.
2097
+ const renderWidth = isHoveredEdge ? Math.min(width * 1.4, 10) : width;
745
2098
  return (
746
2099
  <g key={link.key}>
2100
+ {/* Round 48 / Loop: invisible hover hitbox — visible flow
2101
+ path is 3-7 px wide and damn hard to hover precisely.
2102
+ Stack a transparent 16-px-wide stroke behind it so the
2103
+ cursor only needs to be ~8 px from the line for the
2104
+ tooltip to fire. Native <title> moves here; the
2105
+ visible path no longer needs it. pointer-events on
2106
+ the visible path drop to "none" since the hitbox
2107
+ owns the hover surface. */}
2108
+ <path
2109
+ d={path}
2110
+ fill="none"
2111
+ stroke="transparent"
2112
+ strokeWidth={Math.max(width + 10, 16)}
2113
+ style={{ pointerEvents: 'stroke' }}
2114
+ data-edge-hitbox
2115
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
2116
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
2117
+ >
2118
+ <title>{tooltip}</title>
2119
+ </path>
747
2120
  <path
748
2121
  d={path}
749
2122
  fill="none"
750
2123
  stroke={pal.flowEdge}
751
- strokeWidth={width}
752
- opacity={isLight ? 0.22 : 0.28}
2124
+ strokeWidth={renderWidth}
2125
+ opacity={Math.min(1, (isLight ? 0.22 : 0.28) * fresh * edgeOpacityMul)}
753
2126
  filter={isLight ? undefined : 'url(#topo-glow)'}
754
- markerEnd="url(#topo-arrow)"
2127
+ markerEnd={`url(#${arrowId})`}
2128
+ className="transition-opacity duration-300"
2129
+ style={{ pointerEvents: 'none' }}
755
2130
  />
756
2131
  <path
757
2132
  id={`flow-path-${index}`}
@@ -760,45 +2135,162 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
760
2135
  stroke={pal.flowPath}
761
2136
  strokeWidth="1"
762
2137
  strokeDasharray="2 12"
763
- opacity={isLight ? 0.4 : 0.75}
2138
+ opacity={Math.min(1, (isLight ? 0.4 : 0.75) * fresh * edgeOpacityMul)}
2139
+ className="transition-opacity duration-300"
764
2140
  />
765
- <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'}>
766
- <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
767
- </circle>
2141
+ {!reducedMotion && (
2142
+ <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'} opacity={Math.min(1, fresh * edgeOpacityMul)}>
2143
+ <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
2144
+ </circle>
2145
+ )}
2146
+ {/* Round 75 / Loop: arrival ping at the destination. The
2147
+ particle currently fades into the arrow marker
2148
+ silently — adding a small radiating ring synchronised
2149
+ to the particle's period turns message delivery into
2150
+ a visible event. begin = -dur*0.92 offsets the
2151
+ animation so the ring expands NEAR the end of each
2152
+ cycle (≈when the particle arrives). Gated by
2153
+ reducedMotion and on fresh > 0.5 — stale edges that
2154
+ haven't fired in minutes don't need the eye-grab.
2155
+ data-arrival-ping for testability. */}
2156
+ {!reducedMotion && fresh > 0.5 && (
2157
+ <circle
2158
+ cx={to.x}
2159
+ cy={to.y}
2160
+ r="0"
2161
+ fill="none"
2162
+ stroke={pal.flowEdge}
2163
+ strokeWidth="1.5"
2164
+ opacity="0"
2165
+ style={{ pointerEvents: 'none' }}
2166
+ data-arrival-ping={link.key}
2167
+ >
2168
+ <animate attributeName="r" values="0;14;22" dur={`${duration}s`} begin={`-${(duration * 0.92).toFixed(2)}s`} repeatCount="indefinite" />
2169
+ <animate attributeName="opacity" values="0;0.55;0" dur={`${duration}s`} begin={`-${(duration * 0.92).toFixed(2)}s`} repeatCount="indefinite" />
2170
+ </circle>
2171
+ )}
2172
+ {/* Round 76 / Loop: source dispatch pulse — mirror to the
2173
+ R75 arrival ping. begin = 0s (start of cycle) so the
2174
+ ring expands as the particle LEAVES the source. Only
2175
+ fires for high-traffic edges (link.count >= 3) — on
2176
+ quiet conversations the canvas should stay calm; on
2177
+ busy senders the pulse plus arrival ping bookend
2178
+ every message in flight, making the topology feel
2179
+ alive. Same fresh/reducedMotion gates as R75. Slightly
2180
+ smaller radius (0→12→18 vs R75's 0→14→22) so the
2181
+ source reads as "smaller event than arrival" — the
2182
+ destination is the meaningful endpoint. */}
2183
+ {!reducedMotion && fresh > 0.5 && link.count >= 3 && (
2184
+ <circle
2185
+ cx={from.x}
2186
+ cy={from.y}
2187
+ r="0"
2188
+ fill="none"
2189
+ stroke={pal.flowEdge}
2190
+ strokeWidth="1.5"
2191
+ opacity="0"
2192
+ style={{ pointerEvents: 'none' }}
2193
+ data-dispatch-pulse={link.key}
2194
+ >
2195
+ <animate attributeName="r" values="0;12;18" dur={`${duration}s`} begin="0s" repeatCount="indefinite" />
2196
+ <animate attributeName="opacity" values="0;0.45;0" dur={`${duration}s`} begin="0s" repeatCount="indefinite" />
2197
+ </circle>
2198
+ )}
768
2199
  </g>
769
2200
  );
770
2201
  })}
771
2202
 
772
- {/* center hub — round 39: enlarged from r=6 (12px) to r=10 (20px)
773
- core so the "control plane" reads as the network's anchor,
774
- not a stray particle. Static halo ring at r=18 grounds it
775
- visually; two outward pulses keep the "signal source" idea
776
- from r17. Light theme gets a paler core to avoid hot-spotting
777
- the bg. */}
778
- <g>
779
- {/* static grounding halo — sits underneath, low opacity */}
780
- <circle
781
- cx={cx} cy={cy} r="18"
782
- fill={isLight ? '#d1fae5' : '#10b981'}
783
- opacity={isLight ? 0.35 : 0.10}
784
- />
785
- {/* outer pulse 1 */}
786
- <circle cx={cx} cy={cy} r="10" fill="none" stroke="#10b981" strokeWidth="1.5">
787
- <animate attributeName="r" values="10;38;10" dur="2.4s" repeatCount="indefinite" />
788
- <animate attributeName="opacity" values="0.55;0;0.55" dur="2.4s" repeatCount="indefinite" />
789
- </circle>
790
- {/* outer pulse 2 (delayed 1.2s) */}
791
- <circle cx={cx} cy={cy} r="10" fill="none" stroke="#10b981" strokeWidth="1.5" opacity="0">
792
- <animate attributeName="r" values="10;38;10" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
793
- <animate attributeName="opacity" values="0.45;0;0.45" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
794
- </circle>
2203
+ {/* center hub — round 39 sized + round 13 restraint.
2204
+ The hub is the control-plane anchor. r39 gave it two outward
2205
+ pulses (r 10→38, 2.4s, double-phase) which read as "loudest
2206
+ node in the room" — wrong semantics: anchors don't emit,
2207
+ they hold. r13 swaps the kinetic pulse for a slow opacity
2208
+ breath on the static halo (4s, ±15% from base), so the hub
2209
+ stays alive without throwing kinetic energy outward. The
2210
+ dual-circle "lit lamp" core is unchanged. */}
2211
+ {layout === 'ring' && (<g
2212
+ data-topo-hub
2213
+ style={{ cursor: 'pointer' }}
2214
+ // Stop pointerdown from reaching the SVG pan handler — same
2215
+ // reason as the node <g>: a captured pointer makes Chromium
2216
+ // fire the follow-up click on the SVG, not this group.
2217
+ onPointerDown={(e) => e.stopPropagation()}
2218
+ // Round 52 / Loop: hub is the visual anchor but had no click
2219
+ // action — users discovered fit-to-content only via the `f`
2220
+ // key or the chrome button. Wire the hub to fitView() so the
2221
+ // most prominent element in the canvas is also the "re-
2222
+ // center" affordance. A click-ripple keyed by ts confirms
2223
+ // the gesture; the hub <title> now ends with a hint.
2224
+ onClick={() => {
2225
+ fitView();
2226
+ setClickRipple({ ts: Date.now(), x: cx, y: cy, r0: 18, color: isLight ? '#059669' : '#10b981' });
2227
+ setTimeout(() => setClickRipple(prev => prev && prev.x === cx && prev.y === cy ? null : prev), 600);
2228
+ }}
2229
+ >
2230
+ {/* Round 43 / Loop: hub `<title>` summary — hovering the
2231
+ central glow now answers "what is this?" with a one-line
2232
+ fleet snapshot. Duplicates the header chips by design;
2233
+ hovering the most visually prominent element is the
2234
+ most natural impulse, so satisfy it where the cursor
2235
+ already is. Falls clean: omits sub-clauses when their
2236
+ counts are zero. R52 appends a "click to fit view" hint.
2237
+ */}
2238
+ <title>{(() => {
2239
+ const total = sessions.length;
2240
+ const parts = [`Network hub`, `${total} session${total === 1 ? '' : 's'}`];
2241
+ if (onlineNodes.length > 0) parts.push(`${onlineNodes.length} online`);
2242
+ if (workingCount > 0) parts.push(`${workingCount} working`);
2243
+ if (flowLinks.length > 0) parts.push(`${flowLinks.length} active link${flowLinks.length === 1 ? '' : 's'}`);
2244
+ return parts.join(' · ') + '\nclick to fit view';
2245
+ })()}</title>
2246
+ {/* grounding halo — breathes in opacity, no expansion.
2247
+ R84: breath amplitude + tempo reflect workingCount. An
2248
+ idle fleet keeps the original gentle 0.32→0.52 (light) /
2249
+ 0.08→0.16 (dark) cycle over 4s — "heart at rest". As
2250
+ working sessions accumulate, the peak climbs and the
2251
+ period shortens, capped at 2.4s so it never feels
2252
+ manic. Quiet fleets see zero change; busy fleets feel
2253
+ the canvas working. */}
2254
+ {(() => {
2255
+ // Bucket workingCount to keep the visual feedback discrete
2256
+ // rather than continuous: 0 / 1-2 / 3-5 / 6+. Three buckets
2257
+ // are enough — finer gradations are imperceptible.
2258
+ const busy = workingCount === 0 ? 0
2259
+ : workingCount <= 2 ? 1
2260
+ : workingCount <= 5 ? 2
2261
+ : 3;
2262
+ const peakLight = [0.52, 0.58, 0.65, 0.72][busy];
2263
+ const peakDark = [0.16, 0.20, 0.26, 0.32][busy];
2264
+ const troughLight = 0.32;
2265
+ const troughDark = 0.08;
2266
+ const dur = [4.0, 3.2, 2.7, 2.4][busy];
2267
+ const valuesLight = `${troughLight};${peakLight};${troughLight}`;
2268
+ const valuesDark = `${troughDark};${peakDark};${troughDark}`;
2269
+ return (
2270
+ <circle
2271
+ cx={cx} cy={cy} r="18"
2272
+ fill={isLight ? '#d1fae5' : '#10b981'}
2273
+ opacity={isLight ? 0.42 : 0.12}
2274
+ data-hub-busyness={busy}
2275
+ >
2276
+ {!reducedMotion && (
2277
+ <animate
2278
+ attributeName="opacity"
2279
+ values={isLight ? valuesLight : valuesDark}
2280
+ dur={`${dur}s`}
2281
+ repeatCount="indefinite"
2282
+ />
2283
+ )}
2284
+ </circle>
2285
+ );
2286
+ })()}
795
2287
  {/* core — 20px diameter, larger inner highlight reads as a "lit lamp" */}
796
2288
  <circle cx={cx} cy={cy} r="10" fill={isLight ? '#059669' : '#10b981'} />
797
2289
  <circle cx={cx} cy={cy} r="5" fill="#d1fae5" opacity="0.9" />
798
- </g>
2290
+ </g>)}
799
2291
 
800
2292
  {/* agent nodes */}
801
- {[...onlineNodes, ...offlineNodes].map(session => {
2293
+ {[...onlineNodes, ...offlineNodes].map((session, nodeIdx) => {
802
2294
  const pos = nodePositions[session.alias];
803
2295
  if (!pos) return null;
804
2296
 
@@ -806,7 +2298,9 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
806
2298
  const isOnline = session.status !== 'offline' || !!sseCountFor;
807
2299
  const status = nodeStatus(session, isOnline, isLight);
808
2300
  const isActive = activeAliases.has(session.alias);
809
- const radius = isOnline ? 26 : 18;
2301
+ // #113: node size scales with the S/M/L control; halos, labels,
2302
+ // badge and avatar all derive from `radius` so they follow.
2303
+ const radius = Math.round((isOnline ? 26 : 18) * nodeScale);
810
2304
  // Round 109/110 (Vincent 4582 + 4583 P0): at high node counts
811
2305
  // the 100px opaque label cards overlapped each other and
812
2306
  // covered neighbouring avatars. But hiding text entirely went
@@ -816,12 +2310,118 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
816
2310
  // occlude an avatar), and the full card appears only for the
817
2311
  // hovered node or once zoomed in past 1.4×.
818
2312
  const showFullLabel = !denseLayout || hoveredAlias === session.alias || view.zoom >= 1.4;
2313
+ // Round 8: when a node in another group is hovered, fade this
2314
+ // one. Same-group nodes (incl. singletons matching the hovered
2315
+ // alias) stay full. Pure visual focus, geometry unchanged.
2316
+ const inFocus = !activeGroup || (groupKeys[session.alias] ?? session.alias) === activeGroup;
2317
+ // R72: in ring layout, classify the node into a tier by its
2318
+ // distance from hub centre so the first-paint stagger can
2319
+ // emanate inner → outer instead of running clockwise. Grid
2320
+ // layout doesn't have a radial structure; it keeps R9's pure
2321
+ // nodeIdx stagger. Thresholds bracket the actual tier radii
2322
+ // (single 220, dual 175/260, triple 145/215/285, offline 325+).
2323
+ let tierIdx = 0;
2324
+ if (layout === 'ring') {
2325
+ const p = nodePositions[session.alias];
2326
+ if (p) {
2327
+ const d = Math.hypot(p.x - cx, p.y - cy);
2328
+ tierIdx = d < 195 ? 0 : d < 270 ? 1 : d < 310 ? 2 : 3;
2329
+ }
2330
+ }
819
2331
 
820
2332
  return (
821
2333
  <g
822
2334
  key={session.alias}
823
- className="transition-opacity"
824
- style={{ cursor: 'pointer' }}
2335
+ data-node={session.alias}
2336
+ data-tier-idx={layout === 'ring' ? tierIdx : -1}
2337
+ // Round 3 / Loop: `anet-fade-in` runs once when the <g>
2338
+ // mounts — a new session entering the fleet (or the topology
2339
+ // first rendering) eases in instead of popping. Re-renders of
2340
+ // an existing node don't re-trigger (React preserves the <g>
2341
+ // via the alias key), so status changes don't flicker. The
2342
+ // global prefers-reduced-motion sweep already neutralises it.
2343
+ className="group transition-opacity anet-fade-in"
2344
+ style={{
2345
+ cursor: 'pointer',
2346
+ // Round 17 / Loop: offline nodes drop to 0.6 at rest so
2347
+ // online nodes pop without losing the offline-as-ghost
2348
+ // information. The dashed stroke + smaller radius already
2349
+ // say "offline"; dimming the whole group strengthens the
2350
+ // online-vs-offline hierarchy at first glance. Exempt the
2351
+ // chat-focused node — if the user explicitly opened a
2352
+ // popover targeting an offline alias, that node stays
2353
+ // full-brightness so the focus ring + popover read as one
2354
+ // selected thing rather than a dimmed selection. Group-
2355
+ // hover fade (Round 8) still wins when inFocus is false.
2356
+ // Round 49 / Loop: edge-hover endpoint highlight composes
2357
+ // OVER the inFocus/online formula — a non-endpoint node
2358
+ // dims to 0.28 (just below the inFocus 0.32 to read as a
2359
+ // stronger "not relevant" signal), endpoints keep their
2360
+ // base opacity. chatAlias still wins to keep the focus
2361
+ // ring legible if the user clicked through.
2362
+ // Round 55 / Loop: legend-status hover composes on the
2363
+ // SAME level as edge-hover-endpoint. Non-matching nodes
2364
+ // dim to 0.28; matching nodes stay at their base.
2365
+ // activeStatus matches: working = status==='working',
2366
+ // idle = online but not working, offline = !isOnline.
2367
+ // Round 60 / Loop: activeStatus = hoveredStatus ?? pinnedStatus
2368
+ // so the pressure-bar segment pins (R60) and the legend
2369
+ // row hover (R55) feed the same branch.
2370
+ // Round 80 / Loop: vendor-letter hover composes ABOVE the
2371
+ // activeStatus branch so hovering the vendor chip's `C`
2372
+ // dims everything except C-vendor nodes. Same dim value
2373
+ // (0.28) as edge-endpoint and status filters — visually
2374
+ // consistent. Vendor lookup uses the same vendorForModel
2375
+ // helper the avatar render uses, keyed by initial so the
2376
+ // chip and the avatar always agree on grouping.
2377
+ opacity: hoveredEdgeEndpoints && !hoveredEdgeEndpoints.has(session.alias) && chatAlias !== session.alias
2378
+ ? 0.28
2379
+ : activeVendor && chatAlias !== session.alias && (() => {
2380
+ const v = vendorForModel(session.model);
2381
+ const initial = v.id === 'unknown' ? '?' : v.initial;
2382
+ return initial !== activeVendor;
2383
+ })()
2384
+ ? 0.28
2385
+ : activeStatus && chatAlias !== session.alias && !(
2386
+ activeStatus === 'working' ? session.status === 'working'
2387
+ : activeStatus === 'idle' ? (isOnline && session.status !== 'working')
2388
+ : /* offline */ !isOnline
2389
+ )
2390
+ ? 0.28
2391
+ : !inFocus
2392
+ ? 0.32
2393
+ : chatAlias === session.alias
2394
+ ? 1
2395
+ : isOnline ? 1 : 0.6,
2396
+ // Round 9 / Loop: stagger the anet-fade-in so the topology
2397
+ // reveals as a wave on first paint instead of one big pop.
2398
+ // Cap at 24 indices (≈600ms tail) so 50-node fleets still
2399
+ // finish revealing within a beat. CSS animation-delay only
2400
+ // applies during the keyframe — re-renders without a new
2401
+ // mount (same alias key) don't replay, so status changes
2402
+ // never trigger the stagger again.
2403
+ // Round 72 / Loop: in ring layout, stagger by tier radius
2404
+ // so the topology emanates from the hub outward — inner
2405
+ // tier at 0ms, middle at ~180ms, outer at ~360ms, offline
2406
+ // at ~540ms. A small within-tier offset (nodeIdx % 6 * 25)
2407
+ // adds variety so each ring rotates in instead of all-at-
2408
+ // once popping. Grid keeps R9's pure nodeIdx stagger.
2409
+ animationDelay: layout === 'ring'
2410
+ ? `${tierIdx * 180 + (nodeIdx % 6) * 25}ms`
2411
+ : `${Math.min(nodeIdx, 24) * 25}ms`,
2412
+ // Round 51 / Loop: hover micro-lift. The label already
2413
+ // lifts on group-hover (R26). The node body — circle,
2414
+ // avatar, status ring — stays planted, so the gesture
2415
+ // reads "label moves, body doesn't". Now the whole <g>
2416
+ // translates -2px when hovered: avatar + ring + label
2417
+ // move as one unit. 2 px is well inside the inter-row
2418
+ // gap (cellH headroom ≥22), and CSS transform on SVG
2419
+ // <g> never affects the overlap-test geometry (the
2420
+ // test never simulates hover). useReducedMotion drops
2421
+ // the lift to 0 for prefers-reduced-motion users.
2422
+ transform: !reducedMotion && hoveredAlias === session.alias ? 'translateY(-2px)' : undefined,
2423
+ transition: 'transform 180ms cubic-bezier(0.4,0,0.2,1)',
2424
+ }}
825
2425
  // Stop the pointerdown from reaching the SVG pan handler: the
826
2426
  // SVG calls setPointerCapture, and a captured pointer makes
827
2427
  // Chromium fire the follow-up `click` on the SVG instead of
@@ -829,17 +2429,106 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
829
2429
  // Side effect (intended): you pan from empty canvas, not by
830
2430
  // grabbing a node.
831
2431
  onPointerDown={(e) => e.stopPropagation()}
832
- onPointerEnter={() => denseLayout && setHoveredAlias(session.alias)}
833
- onPointerLeave={() => denseLayout && setHoveredAlias(prev => (prev === session.alias ? null : prev))}
834
- onClick={() => setChatAlias(session.alias)}
2432
+ // Track hover globally (not just under denseLayout) so the
2433
+ // Round 8 group-focus fade works at any fleet size.
2434
+ onPointerEnter={() => setHoveredAlias(session.alias)}
2435
+ onPointerLeave={() => setHoveredAlias(prev => (prev === session.alias ? null : prev))}
2436
+ onClick={() => {
2437
+ setChatAlias(session.alias);
2438
+ // Round 14 ripple — capture position, radius and status
2439
+ // colour at click time so the one-shot circle is self-
2440
+ // contained (no re-render-time recomputation needed).
2441
+ setClickRipple({
2442
+ ts: Date.now(),
2443
+ x: pos.x, y: pos.y, r0: radius,
2444
+ color: status.primary,
2445
+ });
2446
+ setTimeout(() => setClickRipple(prev =>
2447
+ prev && Date.now() - prev.ts >= 590 ? null : prev), 600);
2448
+ }}
835
2449
  >
836
- {isActive && (
2450
+ {/* Issue #96: native hover tooltip — "Vendor · model · Runtime".
2451
+ Falls back to just the alias when the node reports no
2452
+ model/runtime.
2453
+ Round 33 / Loop: cwd line answers "what is this agent on?".
2454
+ Round 34 / Loop: for offline nodes, append "last seen: 6m ago"
2455
+ so the operator knows whether to wait or chase. Online nodes
2456
+ skip the line (Round 27's 1 h ghost age-out means anything
2457
+ still online has heartbeated recently — the line would just
2458
+ be noise). Accepts both ISO ("…T06:00:28Z") and SQL-style
2459
+ ("… 06:00:28" assumed UTC) formats. */}
2460
+ {(() => {
2461
+ // Round 35 / Loop: TZ-safe parsing via the shared lib helper
2462
+ // — same parseHubTime is used by isGhost above (Round 38
2463
+ // factored it to app/lib/time.ts so both paths interpret SQL
2464
+ // bare timestamps as UTC on every browser).
2465
+ const lastSeen = !isOnline && session.last_seen_at ? relativeAgo(session.last_seen_at) : null;
2466
+ return (
2467
+ <title>{[
2468
+ identityLine(session.model, session.runtime) || session.alias,
2469
+ session.project_dir ? `cwd: ${session.project_dir}` : null,
2470
+ lastSeen ? `last seen: ${lastSeen}` : null,
2471
+ ].filter(Boolean).join('\n')}</title>
2472
+ );
2473
+ })()}
2474
+ {/* Round 2 / Loop: hover ring — a thin outer stroke that fades
2475
+ in when the cursor enters the node, signalling clickability
2476
+ (real-user feedback for the chat-popover open). Pure CSS via
2477
+ Tailwind group-hover, so it costs nothing per frame and
2478
+ respects prefers-reduced-motion via the global media query. */}
2479
+ <circle
2480
+ cx={pos.x}
2481
+ cy={pos.y}
2482
+ r={radius + 12}
2483
+ fill="none"
2484
+ stroke={status.primary}
2485
+ // strokeWidth must NOT be 1.5 (offline status ring) or 3
2486
+ // (online status ring) — the overlap test selects by those
2487
+ // exact widths and would mis-count this invisible hover
2488
+ // ring as a node footprint.
2489
+ strokeWidth="2"
2490
+ className="opacity-0 group-hover:opacity-70 transition-opacity duration-150"
2491
+ style={{ pointerEvents: 'none' }}
2492
+ />
2493
+ {/* Round 11 / Loop: chat-focus ring — when the ChatPopover is
2494
+ open targeting this node, anchor a persistent ring around
2495
+ it so the floating popover visibly links back to its source
2496
+ node. Static (not pulsing) so it reads as "selected state"
2497
+ rather than "this node is active". strokeWidth=2.5 stays
2498
+ clear of the overlap-test selectors (1.5 / 3). Sits just
2499
+ outside the halo radius+8 so it never overlaps a neighbour
2500
+ (halos already pack flush in dense grids). */}
2501
+ {chatAlias === session.alias && (
2502
+ <circle
2503
+ cx={pos.x}
2504
+ cy={pos.y}
2505
+ r={radius + 14}
2506
+ fill="none"
2507
+ stroke={status.primary}
2508
+ strokeWidth="2.5"
2509
+ opacity={isLight ? 0.85 : 0.95}
2510
+ filter={!isLight ? 'url(#topo-glow)' : undefined}
2511
+ className="transition-opacity duration-200"
2512
+ style={{ pointerEvents: 'none' }}
2513
+ />
2514
+ )}
2515
+ {isActive && !reducedMotion && (
837
2516
  <circle cx={pos.x} cy={pos.y} r={radius + 14} fill={status.primary} opacity={isLight ? 0.08 : 0.12}>
838
2517
  <animate attributeName="r" values={`${radius + 8};${radius + 22};${radius + 8}`} dur="2.4s" repeatCount="indefinite" />
839
2518
  <animate attributeName="opacity" values={isLight ? '0.12;0.02;0.12' : '0.18;0.04;0.18'} dur="2.4s" repeatCount="indefinite" />
840
2519
  </circle>
841
2520
  )}
842
- <circle cx={pos.x} cy={pos.y} r={radius + 8} fill={status.halo} opacity={isOnline ? (isLight ? 0.85 : 0.65) : (isLight ? 0.4 : 0.25)} />
2521
+ {/* Round 4 / Loop: transition-[fill,stroke,opacity] smooths
2522
+ status colour changes so idle↔working↔offline doesn't snap
2523
+ — task replies / node-rename / SSE updates ease in. */}
2524
+ <circle
2525
+ cx={pos.x}
2526
+ cy={pos.y}
2527
+ r={radius + 8}
2528
+ fill={status.halo}
2529
+ opacity={isOnline ? (isLight ? 0.85 : 0.65) : (isLight ? 0.4 : 0.25)}
2530
+ className="transition-[fill,opacity] duration-300 ease-out"
2531
+ />
843
2532
  <circle
844
2533
  cx={pos.x}
845
2534
  cy={pos.y}
@@ -849,34 +2538,28 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
849
2538
  strokeWidth={isOnline ? 3 : 1.5}
850
2539
  strokeDasharray={isOnline ? 'none' : '5 5'}
851
2540
  filter={isOnline && !isLight ? 'url(#topo-glow)' : undefined}
2541
+ className="transition-[fill,stroke] duration-300 ease-out"
852
2542
  />
853
- {/* Round 99 (issue #79): node "avatar" — hue-hashed
854
- initials circle inside the status ring. Round 100:
855
- when brand=intern, swap the initials for the 书小生
856
- mascot image (asset from 群星马 task 51dd0d1d). The
857
- image already has a transparent bg so it sits cleanly
858
- on the node's dark/light fill; the outer status ring
859
- still carries working/idle/etc. */}
2543
+ {/* Issue #96: node "avatar" is now driven by the model
2544
+ vendor. Decision order:
2545
+ 1. ?brand=intern flag, or an intern-aliased node with
2546
+ no model field → 书生 coin (preserves #79).
2547
+ 2. vendor has a packaged logo asset → that logo image
2548
+ (intern-s1-* models land here via vendorForModel).
2549
+ 3. known vendor, logo asset not shipped yet → a
2550
+ vendor-tinted monogram (spec-mandated fallback).
2551
+ 4. unknown vendor / null model → the prefix-group
2552
+ hue-hashed initial (#83/#99 behaviour, unchanged). */}
860
2553
  {(() => {
861
- const ar = isOnline ? 14 : 10;
862
- // Round 108 (issue #79 reopened): show the 书小生 avatar
863
- // coin for any agent whose alias marks it as an Intern /
864
- // 书生 runtime — not only under the global ?brand=intern
865
- // flag. Vincent 4565: a fleet of 书生N号 nodes should SHOW
866
- // 书生, not a generic "书" initial. ?brand=intern still
867
- // forces the coin on every node (full brand showcase).
868
- const isInternNode = isIntern || /书生|书小生|intern/i.test(session.alias);
869
- if (isInternNode) {
870
- // Round 102: self-contained "avatar coin" — 书小生 figure
871
- // keyed off its white background and composited onto a
872
- // cream circular backplate. The cream coin gives the
873
- // dark-haired figure consistent contrast on BOTH the
874
- // dark cyber theme and light theme. Render at the node
875
- // diameter so the coin fills inside the status ring.
876
- const size = radius * 2;
2554
+ const ar = Math.round((isOnline ? 14 : 10) * nodeScale);
2555
+ const size = radius * 2;
2556
+ const vendor = vendorForModel(session.model);
2557
+ const internByAlias = /书生|书小生|intern/i.test(session.alias);
2558
+
2559
+ if (isIntern || internByAlias || vendor.logo) {
877
2560
  return (
878
2561
  <image
879
- href="/intern_avatar.png"
2562
+ href={vendor.logo ?? '/intern_avatar.png'}
880
2563
  x={pos.x - size / 2}
881
2564
  y={pos.y - size / 2}
882
2565
  width={size}
@@ -885,12 +2568,25 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
885
2568
  />
886
2569
  );
887
2570
  }
2571
+ if (vendor.id !== 'unknown') {
2572
+ // Known model house, logo asset not in public/vendors/
2573
+ // yet — vendor-tinted monogram stands in.
2574
+ return (
2575
+ <>
2576
+ <circle cx={pos.x} cy={pos.y} r={ar} fill={vendor.mono.bg} stroke={vendor.mono.ring} strokeWidth="1" />
2577
+ <text
2578
+ x={pos.x} y={pos.y} dy="0.34em" textAnchor="middle"
2579
+ fill={vendor.mono.text} fontSize={ar}
2580
+ fontFamily="monospace" fontWeight="700"
2581
+ >
2582
+ {vendor.initial}
2583
+ </text>
2584
+ </>
2585
+ );
2586
+ }
888
2587
  // Round 106 (issue #83): hue keyed to the prefix group,
889
- // not the full alias — every 通信* node shares one color,
890
- // every 研究员* another, so teams cluster visually even
891
- // when the tier layout spreads them across rings.
2588
+ // not the full alias — every 通信* node shares one color.
892
2589
  const c = aliasAvatarColors(groupKeys[session.alias] || session.alias);
893
- const fs = isOnline ? 14 : 10;
894
2590
  return (
895
2591
  <>
896
2592
  <circle cx={pos.x} cy={pos.y} r={ar} fill={c.bg} stroke={c.ring} strokeWidth="1" />
@@ -900,7 +2596,7 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
900
2596
  dy="0.34em"
901
2597
  textAnchor="middle"
902
2598
  fill={c.text}
903
- fontSize={fs}
2599
+ fontSize={ar}
904
2600
  fontFamily="monospace"
905
2601
  fontWeight="700"
906
2602
  >
@@ -909,78 +2605,334 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
909
2605
  </>
910
2606
  );
911
2607
  })()}
912
- {session.status === 'working' && (
913
- // working pulse — small dot just inside the status ring,
914
- // above the avatar text. Was a 18px horizontal bar at
915
- // y+11 which now collides with the avatar; the dot reads
916
- // as the same "active" cue without occluding the initial.
917
- <circle cx={pos.x} cy={pos.y - (isOnline ? 18 : 13)} r="2.5" fill={pal.flowParticle}>
918
- <animate attributeName="opacity" values="1;0.25;1" dur="1.1s" repeatCount="indefinite" />
919
- </circle>
920
- )}
2608
+ {/* Issue #96: runtime badge — small corner glyph marking the
2609
+ execution shell (CLI / SDK / HTTP API). Sits bottom-right
2610
+ of the avatar; colours kept off the working/idle/offline
2611
+ status hues. Absent when the node reports no runtime. */}
2612
+ {(() => {
2613
+ const rt = runtimeIdentity(session.runtime);
2614
+ if (!rt) return null;
2615
+ const br = isOnline ? 7 : 5.5;
2616
+ const bx = pos.x + radius * 0.72;
2617
+ const by = pos.y + radius * 0.72;
2618
+ const icon = br * 2 * 0.62;
2619
+ return (
2620
+ <g style={{ pointerEvents: 'none' }}>
2621
+ <circle cx={bx} cy={by} r={br} fill={pal.containerBg} stroke={rt.color} strokeWidth="1.5" />
2622
+ <g transform={`translate(${bx - icon / 2} ${by - icon / 2}) scale(${icon / 24})`}>
2623
+ <path d={rt.iconPath} fill="none" stroke={rt.color} strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
2624
+ </g>
2625
+ </g>
2626
+ );
2627
+ })()}
2628
+ {session.status === 'working' && (() => {
2629
+ // Round 24 / Loop: pulse rate ↔ traffic. The dot's dur
2630
+ // was a flat 1.1s — every working node looked equally
2631
+ // busy. Mapping it to sse:N lets the eye land on what's
2632
+ // actually hot. 3 discrete tiers read cleaner than
2633
+ // continuous easing at a glance; thresholds picked so
2634
+ // typical 1-2-SSE Claude/Codex sessions stay in the
2635
+ // "active" tier and only multi-pane orchestrators pop
2636
+ // into the "busy" tier. Geometry unchanged (r=2.5).
2637
+ const sse = sseCountFor ?? 0;
2638
+ const dur = sse >= 4 ? '0.7s' : sse >= 2 ? '0.9s' : '1.2s';
2639
+ return (
2640
+ <circle cx={pos.x} cy={pos.y - (radius - 6)} r="2.5" fill={pal.flowParticle} data-pulse-dur={dur} opacity={reducedMotion ? 0.6 : undefined}>
2641
+ {!reducedMotion && (
2642
+ <animate attributeName="opacity" values="1;0.25;1" dur={dur} repeatCount="indefinite" />
2643
+ )}
2644
+ </circle>
2645
+ );
2646
+ })()}
921
2647
 
922
2648
  {/* Round 98 (issue #61): label rect 124px → 100px.
923
2649
  Round 109/110 (Vincent P0): full opaque card below the
924
2650
  density threshold / on hover / when zoomed; otherwise a
925
2651
  lightweight plain-text alias that keeps every node
926
- labelled without an opaque box covering its neighbours. */}
927
- {showFullLabel ? (
928
- <g transform={`translate(${pos.x}, ${pos.y + radius + 22})`} style={{ pointerEvents: 'none' }}>
929
- <rect x="-50" y="-14" width="100" height="42" rx="6" fill={pal.labelBox.fill} stroke={pal.labelBox.stroke} opacity={isLight ? 1 : 0.94} />
930
- <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize="12" fontFamily="monospace" fontWeight="700">
931
- {truncate(session.alias, 12)}
932
- </text>
933
- <text x="0" y="17" textAnchor="middle" fill={status.primary} fontSize="9" fontFamily="monospace">
934
- {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
2652
+ labelled without an opaque box covering its neighbours.
2653
+ Round 15 / Loop: when nodeScale=S the node shrinks 30%
2654
+ but the label card was staying full-size, so the label
2655
+ visually outweighed the small node. Tighten the card
2656
+ frame, alias / sub fontSize, drop-offset and truncate
2657
+ length specifically for S; M and L keep their existing
2658
+ sizes (M ≈ L for labels by design — the S user is the
2659
+ one who explicitly asked for a denser view). */}
2660
+ {(() => {
2661
+ const isSmall = nodeScale < 0.8;
2662
+ const cardW = isSmall ? 88 : 100;
2663
+ const cardH = isSmall ? 36 : 42;
2664
+ const cardTopY = isSmall ? -12 : -14;
2665
+ const aliasFs = isSmall ? 11 : 12;
2666
+ const subFs = isSmall ? 8 : 9;
2667
+ const subY = isSmall ? 15 : 17;
2668
+ const dropY = isSmall ? 18 : 22;
2669
+ const fullMax = isSmall ? 11 : 12;
2670
+ const denseFs = isSmall ? 9 : 10;
2671
+ const denseDrop = isSmall ? 12 : 14;
2672
+ // Round 26 / Loop: micro-lift the label group on hover —
2673
+ // 1.5 px upward, 200 ms ease. Pairs with the Round 18
2674
+ // group-box hover-accent treatment to give the same
2675
+ // "this is the focused element" feedback at the per-
2676
+ // node level. CSS transform stacks onto the SVG
2677
+ // positioning transform attribute (SVG 2 cascade);
2678
+ // bbox is unchanged at rest, so the overlap-test gate
2679
+ // continues to see the geometric layout it expects.
2680
+ return showFullLabel ? (
2681
+ <g transform={`translate(${pos.x}, ${pos.y + radius + dropY})`} style={{ pointerEvents: 'none' }}
2682
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]">
2683
+ <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} />
2684
+ <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize={aliasFs} fontFamily="monospace" fontWeight="700">
2685
+ {truncate(session.alias, fullMax)}
2686
+ </text>
2687
+ <text x="0" y={subY} textAnchor="middle" fill={status.primary} fontSize={subFs} fontFamily="monospace">
2688
+ {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
2689
+ </text>
2690
+ </g>
2691
+ ) : (
2692
+ <text
2693
+ x={pos.x}
2694
+ y={pos.y + radius + denseDrop}
2695
+ textAnchor="middle"
2696
+ fill={status.text}
2697
+ fontSize={denseFs}
2698
+ fontFamily="monospace"
2699
+ fontWeight="700"
2700
+ opacity={0.9}
2701
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]"
2702
+ style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
2703
+ stroke={pal.containerBg}
2704
+ strokeWidth="3"
2705
+ >
2706
+ {truncate(session.alias, isSmall ? 9 : 10)}
935
2707
  </text>
936
- </g>
937
- ) : (
938
- <text
939
- x={pos.x}
940
- y={pos.y + radius + 14}
941
- textAnchor="middle"
942
- fill={status.text}
943
- fontSize="10"
944
- fontFamily="monospace"
945
- fontWeight="700"
946
- opacity={0.9}
947
- style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
948
- stroke={pal.containerBg}
949
- strokeWidth="3"
950
- >
951
- {truncate(session.alias, 10)}
952
- </text>
953
- )}
2708
+ );
2709
+ })()}
954
2710
  </g>
955
2711
  );
956
2712
  })}
957
2713
 
2714
+ {/* Round 14 / Loop: click ripple — one-shot expanding ring from
2715
+ the clicked node, ~500ms, status-coloured. Sits inside the
2716
+ zoom/pan <g> so it scales / pans with the topology. Keyed by
2717
+ ts so a re-click on any node (same or different) remounts the
2718
+ <circle> and the SMIL <animate> elements replay from t=0.
2719
+ strokeWidth=2 doesn't match the overlap-test selectors. */}
2720
+ {clickRipple && !reducedMotion && (
2721
+ <circle
2722
+ key={clickRipple.ts}
2723
+ cx={clickRipple.x}
2724
+ cy={clickRipple.y}
2725
+ r={clickRipple.r0 + 4}
2726
+ fill="none"
2727
+ stroke={clickRipple.color}
2728
+ strokeWidth="2"
2729
+ opacity="0"
2730
+ style={{ pointerEvents: 'none' }}
2731
+ >
2732
+ <animate attributeName="r" values={`${clickRipple.r0 + 4};${clickRipple.r0 + 30}`} dur="0.5s" fill="freeze" />
2733
+ <animate attributeName="opacity" values="0.7;0" dur="0.5s" fill="freeze" />
2734
+ </circle>
2735
+ )}
2736
+
2737
+ </g>
2738
+
2739
+ {/* #112: overlay panels (recent-signal + legend) render OUTSIDE the
2740
+ zoom/pan <g> so they stay fixed while the topology pans/zooms.
2741
+ They're sized + tucked into the top corners so every corner of
2742
+ each panel is >325px from the canvas centre — i.e. fully outside
2743
+ the outermost (offline) ring. No node on any ring can reach the
2744
+ corner triangles, so the panels never overlap a node, in ring
2745
+ OR grid layout (Vincent 4727 zero-overlap criterion). */}
958
2746
  {/* latest flow labels */}
959
- <g transform="translate(28, 34)">
960
- <rect x="0" y="0" width="285" height={60 + Math.min(flowLinks.length, 4) * 20} rx="8" fill={pal.legendBox.fill} stroke={pal.legendBox.stroke} opacity={isLight ? 1 : 0.94} />
961
- <text x="16" y="26" fill={pal.legendHeadline} fontSize="13" fontFamily="monospace" fontWeight="700">recent signal</text>
962
- <text x="178" y="26" fill={pal.legendAccent} fontSize="11" fontFamily="monospace">{messages.length} messages</text>
963
- {flowLinks.slice(0, 4).map((link, index) => (
964
- <text key={link.key} x="16" y={54 + index * 20} fill={pal.legendText} fontSize="10" fontFamily="monospace">
965
- {truncate(link.from, 8)} {'->'} {truncate(link.to, 8)} / {link.count} / {truncate(link.content, 18)}
2747
+ {/* Round 57 / Loop: drop-shadow on the panel rects gives them
2748
+ card-like elevation, especially on light theme where the
2749
+ near-white fill on a near-white canvas read as pasted-on.
2750
+ data-topo-panel-elevation tag so the test can verify both
2751
+ panels carry the filter. The filter is on the rect, not
2752
+ the parent <g>, so it doesn't shadow the rows + text inside
2753
+ — only the panel chrome lifts. */}
2754
+ <g transform="translate(16, 16)">
2755
+ <rect
2756
+ x="0" y="0" width="230" height="84" rx="10"
2757
+ fill={pal.legendBox.fill}
2758
+ stroke={pal.legendBox.stroke}
2759
+ opacity={isLight ? 0.97 : 0.92}
2760
+ style={{ filter: isLight ? 'drop-shadow(0 2px 6px rgba(15,23,42,0.08))' : 'drop-shadow(0 2px 6px rgba(0,0,0,0.45))' }}
2761
+ data-topo-panel-elevation="recent"
2762
+ />
2763
+ <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700">recent signal</text>
2764
+ <text x="150" y="21" fill={pal.legendAccent} fontSize="10" fontFamily="monospace">{messages.length} msgs</text>
2765
+ {/* Round 45 / Loop: empty state. The panel used to render
2766
+ "recent signal" + "0 msgs" with three blank slots below
2767
+ when no flow yet — read as "broken" rather than "quiet".
2768
+ A muted centred placeholder makes the empty state
2769
+ deliberate. Messages count CAN diverge from flowLinks
2770
+ count (raw count vs. deduped pairs), so the placeholder
2771
+ fires on flowLinks.length=0 specifically. */}
2772
+ {flowLinks.length === 0 ? (
2773
+ <text x="115" y="60" textAnchor="middle" fill={pal.legendText} fontSize="10" fontFamily="monospace" fontStyle="italic" opacity={0.65} data-recent-signal-empty>
2774
+ no flow yet
966
2775
  </text>
967
- ))}
2776
+ ) : (
2777
+ // Round 56 / Loop: each row is a navigator into the canvas.
2778
+ // Hover a row → set hoveredEdgeKey, which the existing R50
2779
+ // edge-focus + R49 endpoint-highlight ladders consume. The
2780
+ // matching flow edge brightens to 2× + thickens, its two
2781
+ // endpoint nodes stay full opacity, and every other edge +
2782
+ // non-endpoint node dims. Released → all restore. Wrapping
2783
+ // <g> + a transparent 218×14 hitbox so the cursor doesn't
2784
+ // have to land precisely on the truncated text.
2785
+ flowLinks.slice(0, 3).map((link, index) => (
2786
+ <g
2787
+ key={link.key}
2788
+ data-recent-row={link.key}
2789
+ style={{ cursor: 'pointer' }}
2790
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
2791
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
2792
+ >
2793
+ <rect x="6" y={38 + index * 16 - 10} width="218" height="14" fill="transparent" />
2794
+ <text
2795
+ x="13" y={38 + index * 16}
2796
+ fill={hoveredEdgeKey === link.key ? pal.legendHeadline : pal.legendText}
2797
+ fontSize="9"
2798
+ fontFamily="monospace"
2799
+ style={{ transition: 'fill 150ms ease-out' }}
2800
+ >
2801
+ {truncate(link.from, 6)} {'->'} {truncate(link.to, 6)} / {link.count} / {truncate(link.content, 12)}
2802
+ </text>
2803
+ </g>
2804
+ ))
2805
+ )}
968
2806
  </g>
969
2807
 
970
- {/* legend */}
971
- <g transform="translate(720, 34)">
972
- <rect x="0" y="0" width="250" height="112" rx="8" fill={pal.legendBox.fill} stroke={pal.legendBox.stroke} opacity={isLight ? 1 : 0.94} />
973
- <circle cx="18" cy="26" r="6" fill={isLight ? '#059669' : '#22c55e'} />
974
- <text x="34" y="30" fill={pal.legendText} fontSize="11" fontFamily="monospace">working node</text>
975
- <circle cx="18" cy="52" r="6" fill={isLight ? '#0d9488' : '#2dd4bf'} />
976
- <text x="34" y="56" fill={pal.legendText} fontSize="11" fontFamily="monospace">online idle</text>
977
- <circle cx="18" cy="78" r="6" fill={isLight ? '#94a3b8' : '#6b7280'} />
978
- <text x="34" y="82" fill={pal.legendText} fontSize="11" fontFamily="monospace">offline / no SSE</text>
979
- <path d="M150,78 Q176,52 210,78" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" />
980
- </g>
2808
+ {/* legend — Round 55 / Loop: each status row is now a hover
2809
+ target. Pointer enter sets `hoveredStatus`; pointer leave
2810
+ clears it. Node opacity formula composes the match below.
2811
+ The row text brightens to legendHeadline while hovered as
2812
+ a small affordance hint. Geometry unchanged — the new
2813
+ <g> wrappers only carry pointer handlers. */}
2814
+ <g transform="translate(760, 16)">
2815
+ {/* R57: matching drop-shadow elevation to the legend panel. */}
2816
+ <rect
2817
+ x="0" y="0" width="224" height="96" rx="10"
2818
+ fill={pal.legendBox.fill}
2819
+ stroke={pal.legendBox.stroke}
2820
+ opacity={isLight ? 0.97 : 0.92}
2821
+ style={{ filter: isLight ? 'drop-shadow(0 2px 6px rgba(15,23,42,0.08))' : 'drop-shadow(0 2px 6px rgba(0,0,0,0.45))' }}
2822
+ data-topo-panel-elevation="legend"
2823
+ />
2824
+ {([
2825
+ { key: 'working' as const, y0: 24, y1: 28, fill: isLight ? '#059669' : '#22c55e', label: 'working node' },
2826
+ { key: 'idle' as const, y0: 48, y1: 52, fill: isLight ? '#0d9488' : '#2dd4bf', label: 'online idle' },
2827
+ { key: 'offline' as const, y0: 72, y1: 76, fill: isLight ? '#94a3b8' : '#6b7280', label: 'offline / no SSE' },
2828
+ ]).map(row => {
2829
+ // Round 61 / Loop: legend rows pin too — symmetric with the
2830
+ // R60 pressure-bar segments. R55 hover stays transient; the
2831
+ // new onClick toggles pinnedStatus so users can lock a
2832
+ // filter without holding the cursor still. Pinned row gets
2833
+ // an inset ring on the swatch (same vocab as R60).
2834
+ const isPinned = pinnedStatus === row.key;
2835
+ return (
2836
+ <g
2837
+ key={row.key}
2838
+ data-legend-status={row.key}
2839
+ role="button"
2840
+ tabIndex={0}
2841
+ aria-pressed={isPinned}
2842
+ style={{ cursor: 'pointer' }}
2843
+ // R61: stopPropagation on pointerdown so the SVG-level
2844
+ // pan handler (R103) doesn't setPointerCapture and
2845
+ // redirect the follow-up click away from this <g>.
2846
+ // Same trick the node <g> uses (and R52 hub).
2847
+ onPointerDown={(e) => e.stopPropagation()}
2848
+ onMouseEnter={() => setHoveredStatus(row.key)}
2849
+ onMouseLeave={() => setHoveredStatus(prev => prev === row.key ? null : prev)}
2850
+ onClick={() => setPinnedStatus(prev => prev === row.key ? null : row.key)}
2851
+ >
2852
+ {/* invisible hitbox covers the row so cursor doesn't need
2853
+ to be exactly on the 5-px swatch */}
2854
+ <rect x="6" y={row.y0 - 12} width="170" height="22" fill="transparent" />
2855
+ <circle cx="16" cy={row.y0} r="5.5" fill={row.fill} />
2856
+ {/* R61 pinned-state ring — concentric stroke at r=8 in
2857
+ the row colour, draws OUTSIDE the swatch so it
2858
+ doesn't fight the fill colour the user is matching. */}
2859
+ {isPinned && (
2860
+ <circle cx="16" cy={row.y0} r="8" fill="none" stroke={row.fill} strokeWidth="1.5" />
2861
+ )}
2862
+ <text
2863
+ x="30" y={row.y1}
2864
+ fill={hoveredStatus === row.key || isPinned ? pal.legendHeadline : pal.legendText}
2865
+ fontSize="11"
2866
+ fontFamily="monospace"
2867
+ style={{ transition: 'fill 150ms ease-out' }}
2868
+ >{row.label}</text>
2869
+ </g>
2870
+ );
2871
+ })}
2872
+ {/* Flow-arrow swatch sits at y=72 — same row as the offline
2873
+ hitbox. Drop its pointerEvents so the offline legend row
2874
+ stays hoverable (R55). It's decoration, no need to
2875
+ receive events. */}
2876
+ <path d="M140,72 Q164,48 196,72" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" style={{ pointerEvents: 'none' }} />
981
2877
  </g>
982
2878
  </svg>
983
2879
 
2880
+ {/* Round 30 / Loop: minimap. Big fleets in fullscreen mode at high
2881
+ zoom let users lose their position — the minimap shows the
2882
+ whole topology miniaturised plus a viewport rectangle so the
2883
+ user always knows where they are. Click anywhere to recenter
2884
+ the canvas there. Only mounted when the view is non-default
2885
+ (zoomed or panned) since at 1× centered the minimap and the
2886
+ canvas show the same thing. HTML overlay so it stays fixed
2887
+ while the SVG transforms. */}
2888
+ {(() => {
2889
+ const isDefaultView = Math.abs(view.zoom - 1) < 0.01 && Math.abs(view.x) < 1 && Math.abs(view.y) < 1;
2890
+ if (isDefaultView || (onlineNodes.length + offlineNodes.length) === 0) return null;
2891
+ const MW = 120, MH = 82;
2892
+ const sx = MW / VIEWBOX_W, sy = MH / VIEWBOX_H;
2893
+ const rectX = (-view.x / view.zoom) * sx;
2894
+ const rectY = (-view.y / view.zoom) * sy;
2895
+ const rectW = (VIEWBOX_W / view.zoom) * sx;
2896
+ const rectH = (VIEWBOX_H / view.zoom) * sy;
2897
+ return (
2898
+ <div
2899
+ className="absolute right-3 rounded-md border shadow-lg shadow-black/30 overflow-hidden anet-fade-in"
2900
+ style={{ bottom: 56, background: pal.legendBox.fill, borderColor: pal.containerBorder, cursor: 'crosshair' }}
2901
+ role="img"
2902
+ aria-label="Topology minimap — click to recenter"
2903
+ title="Minimap · click to recenter"
2904
+ onClick={(e) => {
2905
+ const r = e.currentTarget.getBoundingClientRect();
2906
+ const fx = (e.clientX - r.left) / r.width;
2907
+ const fy = (e.clientY - r.top) / r.height;
2908
+ setView(prev => ({
2909
+ ...prev,
2910
+ x: VIEWBOX_W / 2 - fx * VIEWBOX_W * prev.zoom,
2911
+ y: VIEWBOX_H / 2 - fy * VIEWBOX_H * prev.zoom,
2912
+ }));
2913
+ }}
2914
+ data-topo-minimap
2915
+ >
2916
+ <svg width={MW} height={MH} viewBox={`0 0 ${MW} ${MH}`} style={{ display: 'block' }}>
2917
+ {[...onlineNodes, ...offlineNodes].map(s => {
2918
+ const p = nodePositions[s.alias];
2919
+ if (!p) return null;
2920
+ const sseN = (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
2921
+ const isOn = s.status !== 'offline' || !!sseN;
2922
+ const st = nodeStatus(s, isOn, isLight);
2923
+ 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} />;
2924
+ })}
2925
+ {/* viewport rectangle */}
2926
+ <rect
2927
+ x={Math.max(0, rectX)} y={Math.max(0, rectY)}
2928
+ width={Math.max(0, Math.min(MW - Math.max(0, rectX), rectW))}
2929
+ height={Math.max(0, Math.min(MH - Math.max(0, rectY), rectH))}
2930
+ fill="none" stroke={pal.legendAccent} strokeWidth="1" opacity="0.9"
2931
+ />
2932
+ </svg>
2933
+ </div>
2934
+ );
2935
+ })()}
984
2936
  {/* Round 103 (issue #81): zoom / pan / fullscreen controls — HTML
985
2937
  overlay so they stay fixed while the SVG content transforms.
986
2938
  Round 104: Vincent 实测 — the reset action used to be hidden
@@ -988,6 +2940,26 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
988
2940
  Split into a plain % readout + an explicit reset button with
989
2941
  its own icon + tooltip. */}
990
2942
  <div className="absolute bottom-3 right-3 flex items-center gap-1.5 text-xs select-none">
2943
+ {/* #113: node size — S / M / L segmented control (Vincent 4727). */}
2944
+ <div
2945
+ className="flex items-center rounded-md border overflow-hidden"
2946
+ style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder }}
2947
+ role="group"
2948
+ aria-label="Node size"
2949
+ >
2950
+ {([['S', 0.7], ['M', 0.84], ['L', 1]] as const).map(([lbl, v], idx) => (
2951
+ <button
2952
+ key={lbl}
2953
+ onClick={() => pickNodeScale(v)}
2954
+ aria-pressed={nodeScale === v}
2955
+ title={`Node size: ${lbl === 'S' ? 'small' : lbl === 'M' ? 'medium' : 'large'}`}
2956
+ className={`px-2 py-1 transition-colors ${idx > 0 ? 'border-l' : ''} ${nodeScale === v ? 'bg-cyan-500/15 text-cyan-300' : 'hover:bg-white/5'}`}
2957
+ style={{ color: nodeScale === v ? undefined : pal.legendText, borderColor: pal.containerBorder }}
2958
+ >
2959
+ {lbl}
2960
+ </button>
2961
+ ))}
2962
+ </div>
991
2963
  <div
992
2964
  className="flex items-center rounded-md border overflow-hidden"
993
2965
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder }}
@@ -997,6 +2969,7 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
997
2969
  className="px-2 py-1 hover:bg-white/5 transition-colors"
998
2970
  style={{ color: pal.legendText }}
999
2971
  aria-label="Zoom out"
2972
+ title="Zoom out (−)"
1000
2973
  >
1001
2974
  <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>
1002
2975
  </button>
@@ -1012,6 +2985,7 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
1012
2985
  className="px-2 py-1 hover:bg-white/5 transition-colors"
1013
2986
  style={{ color: pal.legendText }}
1014
2987
  aria-label="Zoom in"
2988
+ title="Zoom in (+)"
1015
2989
  >
1016
2990
  <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>
1017
2991
  </button>
@@ -1021,7 +2995,7 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
1021
2995
  className="p-1.5 rounded-md border hover:bg-white/5 transition-colors"
1022
2996
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
1023
2997
  aria-label="Reset view"
1024
- title="Reset zoom + pan (or double-click the canvas)"
2998
+ title="Reset zoom + pan (0, or double-click the canvas)"
1025
2999
  >
1026
3000
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1027
3001
  <path d="M3 12a9 9 0 1 0 9-9 9 9 0 0 0-6.4 2.6L3 8" />
@@ -1046,14 +3020,17 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
1046
3020
  )}
1047
3021
  </button>
1048
3022
  </div>
1049
- </div>
1050
3023
 
1051
- {/* Issue #100: draggable singleton chat popover. position:fixed, so it
1052
- floats above the page and isn't clipped by the graph container's
1053
- overflow-hidden. */}
1054
- {chatAlias && (
1055
- <ChatPopover alias={chatAlias} onClose={() => setChatAlias(null)} />
1056
- )}
3024
+ {/* Issue #100/#106: draggable, resizable singleton chat popover.
3025
+ position:fixed so it floats above the page (overflow-hidden here
3026
+ doesn't clip fixed children). Rendered *inside* the container so
3027
+ that when the graph goes fullscreen (#81) the popover joins the
3028
+ fullscreen subtree and stays visible — a sibling render would be
3029
+ outside the fullscreened element and disappear. */}
3030
+ {chatAlias && (
3031
+ <ChatPopover alias={chatAlias} onClose={() => setChatAlias(null)} />
3032
+ )}
3033
+ </div>
1057
3034
  </section>
1058
3035
  );
1059
3036
  }