@sleep2agi/agent-network-dashboard 0.4.6-preview.7 → 0.4.6-preview.70

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 (863) 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/01lmbsd37fybu.js +1 -0
  506. package/.next/static/chunks/039-b.v_9-_rm.js +1 -0
  507. package/.next/static/chunks/03o.h6kvmw4l_.js +1 -0
  508. package/.next/static/chunks/03~~oirxz7~vc.js +1 -0
  509. package/.next/static/chunks/04d_4ubjp6cbf.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/07~8sx3_5uiox.js +1 -0
  513. package/.next/static/chunks/08kf-.bgs10ze.js +1 -0
  514. package/.next/static/chunks/{0pqt~8bl3ukh4.js → 0_pxg.4pebw__.js} +2 -2
  515. package/.next/static/chunks/0b8wnt4put-5n.js +2 -0
  516. package/.next/static/chunks/{01xlw8hd842-c.js → 0eov~nkwaf-qa.js} +1 -1
  517. package/.next/static/chunks/0glmrdj9112tc.js +7 -0
  518. package/.next/static/chunks/0iu1-_964ar6r.js +1 -0
  519. package/.next/static/chunks/{0w1zwt8kq_7nb.js → 0ku0fjqlm9mca.js} +1 -1
  520. package/.next/static/chunks/{0-hlcuoemhe7w.js → 0pu117ktqke6..js} +2 -2
  521. package/.next/static/chunks/0qf3nx.q6qxxd.css +2 -0
  522. package/.next/static/chunks/0r4b.7-_s50t7.js +2 -0
  523. package/.next/static/chunks/0rxrez_cjn.eh.js +1 -0
  524. package/.next/static/chunks/0tvn2l1pc.h65.js +1 -0
  525. package/.next/static/chunks/0u3b93reo9xci.js +1 -0
  526. package/.next/static/chunks/0x8x.29hkju5a.js +1 -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 +38 -2
  536. package/app/components/HelpOverlay.tsx +30 -0
  537. package/app/components/ServersDrawer.tsx +206 -0
  538. package/app/components/TopoGraph.tsx +1372 -177
  539. package/app/components/types.ts +3 -0
  540. package/app/components/utils.ts +8 -7
  541. package/app/lib/time.ts +36 -0
  542. package/package.json +1 -1
  543. package/scripts/chat-popover-test.mjs +2 -0
  544. package/scripts/chat-theme-test.mjs +1 -0
  545. package/scripts/help-overlay-topo-test.mjs +44 -0
  546. package/scripts/servers-drawer-live-test.mjs +96 -0
  547. package/scripts/topo-active-links-chip-test.mjs +73 -0
  548. package/scripts/topo-arrow-tier-test.mjs +61 -0
  549. package/scripts/topo-auto-fit-test.mjs +73 -0
  550. package/scripts/topo-chat-focus-ring-test.mjs +77 -0
  551. package/scripts/topo-click-ripple-test.mjs +66 -0
  552. package/scripts/topo-cwd-tooltip-test.mjs +64 -0
  553. package/scripts/topo-dblclick-reset-test.mjs +74 -0
  554. package/scripts/topo-edge-endpoint-highlight-test.mjs +111 -0
  555. package/scripts/topo-edge-focus-test.mjs +113 -0
  556. package/scripts/topo-edge-hover-test.mjs +93 -0
  557. package/scripts/topo-edge-tooltip-test.mjs +70 -0
  558. package/scripts/topo-esc-clears-pin-test.mjs +98 -0
  559. package/scripts/topo-fit-key-test.mjs +106 -0
  560. package/scripts/topo-freshness-chip-test.mjs +74 -0
  561. package/scripts/topo-group-count-test.mjs +68 -0
  562. package/scripts/topo-group-hover-test.mjs +82 -0
  563. package/scripts/topo-group-pin-test.mjs +119 -0
  564. package/scripts/topo-group-status-pips-test.mjs +103 -0
  565. package/scripts/topo-groupbox-test.mjs +5 -2
  566. package/scripts/topo-help-overlay-r59-test.mjs +67 -0
  567. package/scripts/topo-hub-click-fit-test.mjs +137 -0
  568. package/scripts/topo-hub-title-test.mjs +93 -0
  569. package/scripts/topo-ingroup-edge-test.mjs +103 -0
  570. package/scripts/topo-label-lift-test.mjs +119 -0
  571. package/scripts/topo-label-scale-test.mjs +78 -0
  572. package/scripts/topo-lastseen-tooltip-test.mjs +59 -0
  573. package/scripts/topo-layout-key-test.mjs +91 -0
  574. package/scripts/topo-layout-test.mjs +1 -0
  575. package/scripts/topo-legend-hover-test.mjs +113 -0
  576. package/scripts/topo-legend-pin-test.mjs +128 -0
  577. package/scripts/topo-minimap-test.mjs +68 -0
  578. package/scripts/topo-mobile-chips-test.mjs +103 -0
  579. package/scripts/topo-node-hover-lift-test.mjs +85 -0
  580. package/scripts/topo-offline-dim-test.mjs +70 -0
  581. package/scripts/topo-overlap-test.mjs +164 -0
  582. package/scripts/topo-p0round-test.mjs +166 -0
  583. package/scripts/topo-pan-cursor-test.mjs +62 -0
  584. package/scripts/topo-panel-elevation-test.mjs +76 -0
  585. package/scripts/topo-popover-meta-test.mjs +78 -0
  586. package/scripts/topo-pressure-bar-test.mjs +69 -0
  587. package/scripts/topo-pressure-pin-test.mjs +112 -0
  588. package/scripts/topo-pulse-rate-test.mjs +71 -0
  589. package/scripts/topo-recent-row-hover-test.mjs +122 -0
  590. package/scripts/topo-recent-signal-empty-test.mjs +63 -0
  591. package/scripts/topo-reduced-motion-test.mjs +77 -0
  592. package/scripts/topo-rename-test.mjs +1 -0
  593. package/scripts/topo-tier-ring-test.mjs +69 -0
  594. package/scripts/topo-tz-ghost-test.mjs +69 -0
  595. package/scripts/topo-vendor-test.mjs +1 -0
  596. package/scripts/topo-wheel-zoom-test.mjs +79 -0
  597. package/scripts/topo-zoom-keys-test.mjs +89 -0
  598. package/.next/build/chunks/[turbopack-node]_transforms_postcss_ts_06e.r3r._.js +0 -13
  599. package/.next/build/chunks/node_modules_13sb.px._.js.map +0 -47
  600. package/.next/server/chunks/[root-of-the-server]__0-mykaf._.js +0 -3
  601. package/.next/server/chunks/[root-of-the-server]__0-mykaf._.js.map +0 -1
  602. package/.next/server/chunks/[root-of-the-server]__0.d_kqy._.js +0 -3
  603. package/.next/server/chunks/[root-of-the-server]__0.d_kqy._.js.map +0 -1
  604. package/.next/server/chunks/[root-of-the-server]__02pocyg._.js +0 -3
  605. package/.next/server/chunks/[root-of-the-server]__02pocyg._.js.map +0 -1
  606. package/.next/server/chunks/[root-of-the-server]__03ueci3._.js +0 -3
  607. package/.next/server/chunks/[root-of-the-server]__04aud7x._.js +0 -3
  608. package/.next/server/chunks/[root-of-the-server]__04aud7x._.js.map +0 -1
  609. package/.next/server/chunks/[root-of-the-server]__05fnt3n._.js +0 -3
  610. package/.next/server/chunks/[root-of-the-server]__05fnt3n._.js.map +0 -1
  611. package/.next/server/chunks/[root-of-the-server]__077tsd.._.js +0 -20
  612. package/.next/server/chunks/[root-of-the-server]__077tsd.._.js.map +0 -1
  613. package/.next/server/chunks/[root-of-the-server]__08wy1jr._.js +0 -3
  614. package/.next/server/chunks/[root-of-the-server]__08wy1jr._.js.map +0 -1
  615. package/.next/server/chunks/[root-of-the-server]__0adhu1m._.js +0 -3
  616. package/.next/server/chunks/[root-of-the-server]__0adhu1m._.js.map +0 -1
  617. package/.next/server/chunks/[root-of-the-server]__0aqv02v._.js +0 -3
  618. package/.next/server/chunks/[root-of-the-server]__0aqv02v._.js.map +0 -1
  619. package/.next/server/chunks/[root-of-the-server]__0cawhfq._.js +0 -3
  620. package/.next/server/chunks/[root-of-the-server]__0cawhfq._.js.map +0 -1
  621. package/.next/server/chunks/[root-of-the-server]__0d_-_k.._.js +0 -3
  622. package/.next/server/chunks/[root-of-the-server]__0d_-_k.._.js.map +0 -1
  623. package/.next/server/chunks/[root-of-the-server]__0f6bxfl._.js +0 -3
  624. package/.next/server/chunks/[root-of-the-server]__0f6bxfl._.js.map +0 -1
  625. package/.next/server/chunks/[root-of-the-server]__0h15edq._.js +0 -3
  626. package/.next/server/chunks/[root-of-the-server]__0h15edq._.js.map +0 -1
  627. package/.next/server/chunks/[root-of-the-server]__0k.vegl._.js +0 -3
  628. package/.next/server/chunks/[root-of-the-server]__0k.vegl._.js.map +0 -1
  629. package/.next/server/chunks/[root-of-the-server]__0kltbce._.js +0 -3
  630. package/.next/server/chunks/[root-of-the-server]__0kltbce._.js.map +0 -1
  631. package/.next/server/chunks/[root-of-the-server]__0mn04fw._.js +0 -3
  632. package/.next/server/chunks/[root-of-the-server]__0mn04fw._.js.map +0 -1
  633. package/.next/server/chunks/[root-of-the-server]__0o29v8c._.js +0 -3
  634. package/.next/server/chunks/[root-of-the-server]__0o4qfn9._.js +0 -3
  635. package/.next/server/chunks/[root-of-the-server]__0o7ya7r._.js +0 -3
  636. package/.next/server/chunks/[root-of-the-server]__0o7ya7r._.js.map +0 -1
  637. package/.next/server/chunks/[root-of-the-server]__0p3fo6l._.js +0 -6
  638. package/.next/server/chunks/[root-of-the-server]__0p3fo6l._.js.map +0 -1
  639. package/.next/server/chunks/[root-of-the-server]__0p6tutt._.js +0 -3
  640. package/.next/server/chunks/[root-of-the-server]__0pn3wsx._.js +0 -3
  641. package/.next/server/chunks/[root-of-the-server]__0pn3wsx._.js.map +0 -1
  642. package/.next/server/chunks/[root-of-the-server]__0rg9ifx._.js +0 -3
  643. package/.next/server/chunks/[root-of-the-server]__0x.-194._.js +0 -3
  644. package/.next/server/chunks/[root-of-the-server]__0x.-194._.js.map +0 -1
  645. package/.next/server/chunks/[root-of-the-server]__0x.a_yh._.js +0 -3
  646. package/.next/server/chunks/[root-of-the-server]__0x.a_yh._.js.map +0 -1
  647. package/.next/server/chunks/[root-of-the-server]__0~h2uqi._.js +0 -3
  648. package/.next/server/chunks/[root-of-the-server]__139~d6a._.js +0 -3
  649. package/.next/server/chunks/[root-of-the-server]__139~d6a._.js.map +0 -1
  650. package/.next/server/chunks/_next-internal_server_app_api_anet_config_route_actions_0xzy73j.js +0 -3
  651. package/.next/server/chunks/_next-internal_server_app_api_auth_login_route_actions_0zukc38.js +0 -3
  652. package/.next/server/chunks/_next-internal_server_app_api_auth_logout_route_actions_0fm~3ij.js +0 -3
  653. package/.next/server/chunks/_next-internal_server_app_api_auth_v3_route_actions_0ndzs5l.js +0 -3
  654. package/.next/server/chunks/_next-internal_server_app_api_hub_audit-log_route_actions_11k8fqt.js +0 -3
  655. package/.next/server/chunks/_next-internal_server_app_api_hub_auth_route_actions_0gs7ttk.js +0 -3
  656. package/.next/server/chunks/_next-internal_server_app_api_hub_broadcast_route_actions_0ads-kd.js +0 -3
  657. package/.next/server/chunks/_next-internal_server_app_api_hub_events_route_actions_0opktby.js +0 -3
  658. package/.next/server/chunks/_next-internal_server_app_api_hub_health_route_actions_0tzzt~x.js +0 -3
  659. package/.next/server/chunks/_next-internal_server_app_api_hub_inbox_route_actions_0xvslrg.js +0 -3
  660. package/.next/server/chunks/_next-internal_server_app_api_hub_license_route_actions_0i3tlr3.js +0 -3
  661. package/.next/server/chunks/_next-internal_server_app_api_hub_messages_route_actions_08-gi-s.js +0 -3
  662. package/.next/server/chunks/_next-internal_server_app_api_hub_networks_route_actions_02vejq2.js +0 -3
  663. package/.next/server/chunks/_next-internal_server_app_api_hub_nodes_route_actions_0nx8ryn.js +0 -3
  664. package/.next/server/chunks/_next-internal_server_app_api_hub_register_route_actions_086wi_3.js +0 -3
  665. package/.next/server/chunks/_next-internal_server_app_api_hub_send_route_actions_0-ga_ug.js +0 -3
  666. package/.next/server/chunks/_next-internal_server_app_api_hub_server-logs_route_actions_00euexv.js +0 -3
  667. package/.next/server/chunks/_next-internal_server_app_api_hub_session_route_actions_041wa62.js +0 -3
  668. package/.next/server/chunks/_next-internal_server_app_api_hub_stats_route_actions_1242~od.js +0 -3
  669. package/.next/server/chunks/_next-internal_server_app_api_hub_status_route_actions_0aml09a.js +0 -3
  670. package/.next/server/chunks/_next-internal_server_app_api_hub_task-events_route_actions_02v1tmm.js +0 -3
  671. package/.next/server/chunks/_next-internal_server_app_api_hub_tasks_route_actions_0nl77v4.js +0 -3
  672. package/.next/server/chunks/_next-internal_server_app_api_hub_tmux_route_actions_0u~aaza.js +0 -3
  673. package/.next/server/chunks/_next-internal_server_app_api_hub_tokens_route_actions_09hzf_1.js +0 -3
  674. package/.next/server/chunks/_next-internal_server_app_favicon_ico_route_actions_095lj93.js +0 -3
  675. package/.next/server/chunks/node_modules_next_11synfn._.js +0 -10
  676. package/.next/server/chunks/node_modules_next_11synfn._.js.map +0 -1
  677. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_0yubv2d.js +0 -3
  678. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_0yubv2d.js.map +0 -1
  679. package/.next/server/chunks/ssr/[root-of-the-server]__014uja_._.js +0 -3
  680. package/.next/server/chunks/ssr/[root-of-the-server]__014uja_._.js.map +0 -1
  681. package/.next/server/chunks/ssr/[root-of-the-server]__02hi65f._.js +0 -3
  682. package/.next/server/chunks/ssr/[root-of-the-server]__02hi65f._.js.map +0 -1
  683. package/.next/server/chunks/ssr/[root-of-the-server]__02xqa5z._.js +0 -3
  684. package/.next/server/chunks/ssr/[root-of-the-server]__02xqa5z._.js.map +0 -1
  685. package/.next/server/chunks/ssr/[root-of-the-server]__04ph1de._.js +0 -3
  686. package/.next/server/chunks/ssr/[root-of-the-server]__04ph1de._.js.map +0 -1
  687. package/.next/server/chunks/ssr/[root-of-the-server]__05wc5-z._.js +0 -3
  688. package/.next/server/chunks/ssr/[root-of-the-server]__05wc5-z._.js.map +0 -1
  689. package/.next/server/chunks/ssr/[root-of-the-server]__06-2p1a._.js +0 -33
  690. package/.next/server/chunks/ssr/[root-of-the-server]__06-2p1a._.js.map +0 -1
  691. package/.next/server/chunks/ssr/[root-of-the-server]__08qr2ji._.js.map +0 -1
  692. package/.next/server/chunks/ssr/[root-of-the-server]__09l8xaa._.js +0 -3
  693. package/.next/server/chunks/ssr/[root-of-the-server]__09l8xaa._.js.map +0 -1
  694. package/.next/server/chunks/ssr/[root-of-the-server]__09z7o2x._.js +0 -19
  695. package/.next/server/chunks/ssr/[root-of-the-server]__09z7o2x._.js.map +0 -1
  696. package/.next/server/chunks/ssr/[root-of-the-server]__0a0ffmv._.js +0 -3
  697. package/.next/server/chunks/ssr/[root-of-the-server]__0a0ffmv._.js.map +0 -1
  698. package/.next/server/chunks/ssr/[root-of-the-server]__0b7uu-_._.js +0 -3
  699. package/.next/server/chunks/ssr/[root-of-the-server]__0b7uu-_._.js.map +0 -1
  700. package/.next/server/chunks/ssr/[root-of-the-server]__0bl_wqk._.js +0 -3
  701. package/.next/server/chunks/ssr/[root-of-the-server]__0bl_wqk._.js.map +0 -1
  702. package/.next/server/chunks/ssr/[root-of-the-server]__0ft0rpu._.js +0 -3
  703. package/.next/server/chunks/ssr/[root-of-the-server]__0ft0rpu._.js.map +0 -1
  704. package/.next/server/chunks/ssr/[root-of-the-server]__0i~g-2t._.js +0 -3
  705. package/.next/server/chunks/ssr/[root-of-the-server]__0i~g-2t._.js.map +0 -1
  706. package/.next/server/chunks/ssr/[root-of-the-server]__0jl4ukn._.js +0 -3
  707. package/.next/server/chunks/ssr/[root-of-the-server]__0jl4ukn._.js.map +0 -1
  708. package/.next/server/chunks/ssr/[root-of-the-server]__0mook08._.js +0 -3
  709. package/.next/server/chunks/ssr/[root-of-the-server]__0mook08._.js.map +0 -1
  710. package/.next/server/chunks/ssr/[root-of-the-server]__0n46s~k._.js +0 -3
  711. package/.next/server/chunks/ssr/[root-of-the-server]__0n46s~k._.js.map +0 -1
  712. package/.next/server/chunks/ssr/[root-of-the-server]__0qchqb~._.js +0 -3
  713. package/.next/server/chunks/ssr/[root-of-the-server]__0qchqb~._.js.map +0 -1
  714. package/.next/server/chunks/ssr/[root-of-the-server]__0rrm~-q._.js +0 -3
  715. package/.next/server/chunks/ssr/[root-of-the-server]__0rrm~-q._.js.map +0 -1
  716. package/.next/server/chunks/ssr/[root-of-the-server]__0w2ay2h._.js +0 -3
  717. package/.next/server/chunks/ssr/[root-of-the-server]__0w2ay2h._.js.map +0 -1
  718. package/.next/server/chunks/ssr/[root-of-the-server]__0xjish~._.js +0 -3
  719. package/.next/server/chunks/ssr/[root-of-the-server]__0xjish~._.js.map +0 -1
  720. package/.next/server/chunks/ssr/[root-of-the-server]__0xm3h9x._.js +0 -3
  721. package/.next/server/chunks/ssr/[root-of-the-server]__0xm3h9x._.js.map +0 -1
  722. package/.next/server/chunks/ssr/[root-of-the-server]__0ywihy_._.js +0 -8
  723. package/.next/server/chunks/ssr/[root-of-the-server]__0ywihy_._.js.map +0 -1
  724. package/.next/server/chunks/ssr/[root-of-the-server]__12ad1n.._.js +0 -3
  725. package/.next/server/chunks/ssr/[root-of-the-server]__12ad1n.._.js.map +0 -1
  726. package/.next/server/chunks/ssr/_0nne7mz._.js +0 -3
  727. package/.next/server/chunks/ssr/_0nne7mz._.js.map +0 -1
  728. package/.next/server/chunks/ssr/_next-internal_server_app__global-error_page_actions_0k77kol.js +0 -3
  729. package/.next/server/chunks/ssr/_next-internal_server_app__not-found_page_actions_0eq97pa.js +0 -3
  730. package/.next/server/chunks/ssr/_next-internal_server_app_admin_page_actions_0ycnu8f.js +0 -3
  731. package/.next/server/chunks/ssr/_next-internal_server_app_login_page_actions_02kefem.js +0 -3
  732. package/.next/server/chunks/ssr/_next-internal_server_app_logs_page_actions_0rvsl~d.js +0 -3
  733. package/.next/server/chunks/ssr/_next-internal_server_app_messages_page_actions_0gsqep_.js +0 -3
  734. package/.next/server/chunks/ssr/_next-internal_server_app_node_page_actions_0wd8cgl.js +0 -3
  735. package/.next/server/chunks/ssr/_next-internal_server_app_nodes_page_actions_0-.rv67.js +0 -3
  736. package/.next/server/chunks/ssr/_next-internal_server_app_page_actions_09-gtaw.js +0 -3
  737. package/.next/server/chunks/ssr/_next-internal_server_app_server-logs_page_actions_0g1gb5c.js +0 -3
  738. package/.next/server/chunks/ssr/_next-internal_server_app_settings_networks_page_actions_09uw.sw.js +0 -3
  739. package/.next/server/chunks/ssr/_next-internal_server_app_settings_page_actions_0mr68ai.js +0 -3
  740. package/.next/server/chunks/ssr/_next-internal_server_app_settings_tokens_page_actions_0yi54x8.js +0 -3
  741. package/.next/server/chunks/ssr/_next-internal_server_app_tasks_[id]_page_actions_0k_hm9g.js +0 -3
  742. package/.next/server/chunks/ssr/_next-internal_server_app_tasks_page_actions_0_2o_cr.js +0 -3
  743. package/.next/server/chunks/ssr/app_0.zbtn5._.js +0 -9
  744. package/.next/server/chunks/ssr/app_0.zbtn5._.js.map +0 -1
  745. package/.next/server/chunks/ssr/app_01y-~mp._.js +0 -3
  746. package/.next/server/chunks/ssr/app_01y-~mp._.js.map +0 -1
  747. package/.next/server/chunks/ssr/app_04n78pr._.js +0 -3
  748. package/.next/server/chunks/ssr/app_04n78pr._.js.map +0 -1
  749. package/.next/server/chunks/ssr/app_0_agk0r._.js +0 -3
  750. package/.next/server/chunks/ssr/app_0_agk0r._.js.map +0 -1
  751. package/.next/server/chunks/ssr/app_0aenxef._.js +0 -3
  752. package/.next/server/chunks/ssr/app_0aenxef._.js.map +0 -1
  753. package/.next/server/chunks/ssr/app_0aum_g5._.js +0 -3
  754. package/.next/server/chunks/ssr/app_0aum_g5._.js.map +0 -1
  755. package/.next/server/chunks/ssr/app_0emvkn2._.js +0 -3
  756. package/.next/server/chunks/ssr/app_0emvkn2._.js.map +0 -1
  757. package/.next/server/chunks/ssr/app_0esuljp._.js +0 -3
  758. package/.next/server/chunks/ssr/app_0esuljp._.js.map +0 -1
  759. package/.next/server/chunks/ssr/app_0izi28j._.js +0 -3
  760. package/.next/server/chunks/ssr/app_0izi28j._.js.map +0 -1
  761. package/.next/server/chunks/ssr/app_components_08ys5y3._.js.map +0 -1
  762. package/.next/server/chunks/ssr/app_server-logs_page_tsx_0_kpp1p._.js.map +0 -1
  763. package/.next/server/chunks/ssr/app_tasks_page_tsx_0z-d6mp._.js +0 -3
  764. package/.next/server/chunks/ssr/app_tasks_page_tsx_0z-d6mp._.js.map +0 -1
  765. package/.next/server/chunks/ssr/node_modules_09w7yel._.js.map +0 -1
  766. package/.next/server/chunks/ssr/node_modules_0ck2~9g._.js +0 -3
  767. package/.next/server/chunks/ssr/node_modules_0ck2~9g._.js.map +0 -1
  768. package/.next/server/chunks/ssr/node_modules_0r_.s40._.js.map +0 -1
  769. package/.next/server/chunks/ssr/node_modules_0vtdjw7._.js +0 -3
  770. package/.next/server/chunks/ssr/node_modules_0vtdjw7._.js.map +0 -1
  771. package/.next/server/chunks/ssr/node_modules_next_0w2y7fz._.js +0 -6
  772. package/.next/server/chunks/ssr/node_modules_next_0w2y7fz._.js.map +0 -1
  773. package/.next/server/chunks/ssr/node_modules_next_dist_0e1izl_._.js +0 -6
  774. package/.next/server/chunks/ssr/node_modules_next_dist_0e1izl_._.js.map +0 -1
  775. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_0inhx6q._.js +0 -3
  776. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_0inhx6q._.js.map +0 -1
  777. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_forbidden_0ghu-f7.js +0 -3
  778. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_forbidden_0ghu-f7.js.map +0 -1
  779. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_global-error_0lgvd_..js +0 -3
  780. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_global-error_0lgvd_..js.map +0 -1
  781. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_unauthorized_0cjv-23.js +0 -3
  782. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_unauthorized_0cjv-23.js.map +0 -1
  783. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_002l7yi.js.map +0 -1
  784. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_02suzhc.js.map +0 -1
  785. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_07uu-xq.js.map +0 -1
  786. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0d2yq5t.js.map +0 -1
  787. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0dm~kj8.js.map +0 -1
  788. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0foejvu.js.map +0 -1
  789. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0l-fh0i.js.map +0 -1
  790. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0nsg22r.js.map +0 -1
  791. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0o9vt1n.js.map +0 -1
  792. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0qm1-a1.js.map +0 -1
  793. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0uccf9z.js.map +0 -1
  794. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0uv6jyc.js.map +0 -1
  795. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_10~~o-h.js +0 -4
  796. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_10~~o-h.js.map +0 -1
  797. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_13leyhk.js +0 -4
  798. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_13leyhk.js.map +0 -1
  799. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_13s07li.js.map +0 -1
  800. package/.next/static/chunks/01fk_e9tpe4cr.js +0 -1
  801. package/.next/static/chunks/0257pdz1-imal.js +0 -2
  802. package/.next/static/chunks/02_zpo7taex9o.js +0 -7
  803. package/.next/static/chunks/06n3d~fbbtagk.js +0 -1
  804. package/.next/static/chunks/07eba1lz2u3p4.js +0 -1
  805. package/.next/static/chunks/0_rmxom9~1lmk.css +0 -2
  806. package/.next/static/chunks/0av2~0.96~d~g.js +0 -1
  807. package/.next/static/chunks/0e-.zgw81gela.js +0 -31
  808. package/.next/static/chunks/0e0c5hzwhvzje.js +0 -1
  809. package/.next/static/chunks/0g9aof0ob_fcj.js +0 -1
  810. package/.next/static/chunks/0j6t.m1a6._pk.js +0 -1
  811. package/.next/static/chunks/0s8jo6jypzkx5.js +0 -1
  812. package/.next/static/chunks/0u359llqm1_01.js +0 -1
  813. package/.next/static/chunks/0ubqqhhpsaez4.js +0 -1
  814. package/.next/static/chunks/0yz2othu-3th..js +0 -1
  815. package/.next/static/chunks/0~qw9kxksr1v2.js +0 -1
  816. package/.next/static/chunks/12gprw3l0kvvf.js +0 -1
  817. package/.next/static/chunks/12yuo353giomx.js +0 -1
  818. package/.next/static/chunks/14bsg3_-taiw4.js +0 -1
  819. /package/.next/build/chunks/{[root-of-the-server]__0ubbtyl._.js.map → [root-of-the-server]__0tgc-8p._.js.map} +0 -0
  820. /package/.next/build/chunks/{[turbopack-node]_transforms_postcss_ts_06e.r3r._.js.map → [turbopack-node]_transforms_postcss_ts_0r4x5ys._.js.map} +0 -0
  821. /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
  822. /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
  823. /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
  824. /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
  825. /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
  826. /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
  827. /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
  828. /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
  829. /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
  830. /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
  831. /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
  832. /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
  833. /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
  834. /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
  835. /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
  836. /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
  837. /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
  838. /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
  839. /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
  840. /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
  841. /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
  842. /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
  843. /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
  844. /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
  845. /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
  846. /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
  847. /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
  848. /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
  849. /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
  850. /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
  851. /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
  852. /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
  853. /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
  854. /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
  855. /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
  856. /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
  857. /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
  858. /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
  859. /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
  860. /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
  861. /package/.next/static/{wi8v9sMITbvGOeqsOVwUq → L2qvYYjQpVLHRMImytrI-}/_buildManifest.js +0 -0
  862. /package/.next/static/{wi8v9sMITbvGOeqsOVwUq → L2qvYYjQpVLHRMImytrI-}/_clientMiddlewareManifest.js +0 -0
  863. /package/.next/static/{wi8v9sMITbvGOeqsOVwUq → L2qvYYjQpVLHRMImytrI-}/_ssgManifest.js +0 -0
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { Session } from './types';
5
5
  import { aliasAvatarColors, aliasInitial } from './AliasAvatar';
6
6
  import { ChatPopover } from './ChatPopover';
7
7
  import { vendorForModel, runtimeIdentity, identityLine } from '../lib/vendorIdentity';
8
+ import { parseHubTime, relativeAgo } from '../lib/time';
8
9
 
9
10
  interface MessageFlow {
10
11
  from_alias: string;
@@ -32,6 +33,9 @@ interface FlowLink {
32
33
  to: string;
33
34
  count: number;
34
35
  content: string;
36
+ /** ISO timestamp of the most recent message on this edge — drives
37
+ * the Round 10 freshness fade so dormant links recede visually. */
38
+ last_at: string;
35
39
  }
36
40
 
37
41
  const cx = 500;
@@ -85,36 +89,118 @@ function truncate(value: string, max: number) {
85
89
  return value.length > max ? `${value.slice(0, max - 1)}...` : value;
86
90
  }
87
91
 
92
+ /** Round 46 / Loop: SWR-freshness chip.
93
+ *
94
+ * TopoGraph's data refreshes every 5 s via SWR but the user has no
95
+ * visible signal that the canvas they're looking at is current. The
96
+ * flow particles and chips (R42 active-links, R43 hub) tell you when
97
+ * the FLEET last did something, not when the DATA last syncing. This
98
+ * chip ticks `live · Ns` against a 1-second interval so the operator
99
+ * can trust freshness without doing internal math.
100
+ *
101
+ * Owns its own setInterval so the parent topology doesn't re-render
102
+ * every second (only this small chip does). lastSyncRef is updated
103
+ * whenever the `sessions` prop reference changes — that's the SWR
104
+ * refresh signal. */
105
+ function FreshnessChip({ sessions }: { sessions: unknown }) {
106
+ const lastSyncRef = useRef<number>(Date.now());
107
+ const [now, setNow] = useState(() => Date.now());
108
+ useEffect(() => {
109
+ // sessions reference changed → SWR just delivered a new payload.
110
+ lastSyncRef.current = Date.now();
111
+ }, [sessions]);
112
+ useEffect(() => {
113
+ const id = setInterval(() => setNow(Date.now()), 1000);
114
+ return () => clearInterval(id);
115
+ }, []);
116
+ const sec = Math.max(0, Math.floor((now - lastSyncRef.current) / 1000));
117
+ // Tint the chip warmer when the data goes stale (>10s since last sync).
118
+ // SWR's default refreshInterval here is 5s, so anything past ~10s is
119
+ // either a poll miss or a network hiccup.
120
+ const stale = sec > 10;
121
+ return (
122
+ <span
123
+ className={stale
124
+ ? "hidden sm:inline px-2.5 py-1 rounded-md bg-amber-500/10 text-amber-300 border border-amber-500/20 font-mono"
125
+ : "hidden sm:inline px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20 font-mono"}
126
+ title={stale ? `Last sync ${sec}s ago — SWR refresh may be lagging` : `Live data · refreshes every 5s · last sync ${sec}s ago`}
127
+ data-freshness-chip
128
+ >
129
+ live · {sec}s
130
+ </span>
131
+ );
132
+ }
133
+
134
+ /** Round 36 / Loop: prefers-reduced-motion hook.
135
+ *
136
+ * Round 29's a11y sweep zeroed CSS animations via media query, but SVG
137
+ * SMIL `<animate>` / `<animateMotion>` elements aren't reachable from CSS
138
+ * — they need JS to opt out. This hook reads the media query and listens
139
+ * for changes so the topology's flow particles, pulses, click ripple
140
+ * and hub breath honour the OS-level preference. */
141
+ function useReducedMotion(): boolean {
142
+ const [reduced, setReduced] = useState(false);
143
+ useEffect(() => {
144
+ if (typeof window === 'undefined' || !window.matchMedia) return;
145
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
146
+ setReduced(mq.matches);
147
+ const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
148
+ mq.addEventListener('change', onChange);
149
+ return () => mq.removeEventListener('change', onChange);
150
+ }, []);
151
+ return reduced;
152
+ }
153
+
154
+ // Round 38 / Loop: parseHubTime + relativeAgo factored to app/lib/time.ts
155
+ // — the Round 35 fix lives there now alongside the Round 37 mirror so
156
+ // any future TZ-safe parse update happens in one place.
157
+
158
+ /** Round 12 / Loop: status trio audit.
159
+ *
160
+ * Each (status × theme) cell returns a {primary, halo, text} trio. The trio
161
+ * invariant — keep it when adding states or tweaking shades:
162
+ *
163
+ * light: primary = <hue>-600 halo = <hue>-100 * text = <hue>-800/900
164
+ * dark: primary = <hue>-400/500 halo = <hue>-900 text = <hue>-100
165
+ *
166
+ * * offline.halo light deviates intentionally to slate-200 (#e2e8f0):
167
+ * slate-100 (#f1f5f9) is too close to the panel bg (#f8fafc) and the
168
+ * halo would vanish. The other three rows keep the 100-shade.
169
+ *
170
+ * Audit caught one cross-family drift before this round: online-other halo
171
+ * light was #dbeafe (blue-100) while its primary (#0284c7 sky-600) and text
172
+ * (#0c4a6e sky-900) were sky-family — now sky-100 (#e0f2fe). Tiny visual
173
+ * difference; large hygiene win — every trio is now mono-hue. */
88
174
  function nodeStatus(session: Session, isOnline: boolean, isLight: boolean) {
89
175
  if (!isOnline) {
90
176
  return {
91
177
  label: 'offline',
92
- primary: isLight ? '#94a3b8' : '#6b7280',
93
- halo: isLight ? '#e2e8f0' : '#111827',
94
- text: isLight ? '#475569' : '#9ca3af',
178
+ primary: isLight ? '#94a3b8' : '#6b7280', // slate-400 / gray-500
179
+ halo: isLight ? '#e2e8f0' : '#111827', // slate-200* / gray-900
180
+ text: isLight ? '#475569' : '#9ca3af', // slate-600 / gray-400
95
181
  };
96
182
  }
97
183
  if (session.status === 'working') {
98
184
  return {
99
185
  label: 'working',
100
- primary: isLight ? '#059669' : '#22c55e',
101
- halo: isLight ? '#d1fae5' : '#14532d',
102
- text: isLight ? '#065f46' : '#dcfce7',
186
+ primary: isLight ? '#059669' : '#22c55e', // emerald-600 / green-500
187
+ halo: isLight ? '#d1fae5' : '#14532d', // emerald-100 / green-900
188
+ text: isLight ? '#065f46' : '#dcfce7', // emerald-800 / green-100
103
189
  };
104
190
  }
105
191
  if (session.status === 'idle') {
106
192
  return {
107
193
  label: 'idle',
108
- primary: isLight ? '#0d9488' : '#2dd4bf',
109
- halo: isLight ? '#ccfbf1' : '#134e4a',
110
- text: isLight ? '#115e59' : '#ccfbf1',
194
+ primary: isLight ? '#0d9488' : '#2dd4bf', // teal-600 / teal-400
195
+ halo: isLight ? '#ccfbf1' : '#134e4a', // teal-100 / teal-900
196
+ text: isLight ? '#115e59' : '#ccfbf1', // teal-800 / teal-100
111
197
  };
112
198
  }
113
199
  return {
114
200
  label: session.status || 'online',
115
- primary: isLight ? '#0284c7' : '#38bdf8',
116
- halo: isLight ? '#dbeafe' : '#0c4a6e',
117
- text: isLight ? '#0c4a6e' : '#e0f2fe',
201
+ primary: isLight ? '#0284c7' : '#38bdf8', // sky-600 / sky-400
202
+ halo: isLight ? '#e0f2fe' : '#0c4a6e', // sky-100 / sky-900 (was blue-100 — drift fixed Round 12)
203
+ text: isLight ? '#0c4a6e' : '#e0f2fe', // sky-900 / sky-100
118
204
  };
119
205
  }
120
206
 
@@ -240,22 +326,59 @@ function commonPrefix(a: string, b: string): string {
240
326
  return a.slice(0, i);
241
327
  }
242
328
 
243
- function computeGroupKeys(aliases: string[]): Record<string, string> {
244
- 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);
245
366
  const keys: Record<string, string> = {};
246
- let i = 0;
247
- while (i < sorted.length) {
248
- let j = i;
249
- let prefix = sorted[i];
250
- while (j + 1 < sorted.length) {
251
- const lcp = commonPrefix(prefix, sorted[j + 1]);
252
- if (lcp.length < 2) break;
253
- prefix = lcp;
254
- 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
+ }
255
380
  }
256
- const key = j > i ? prefix : sorted[i];
257
- for (let k = i; k <= j; k++) keys[sorted[k]] = key;
258
- i = j + 1;
381
+ for (const i of members) keys[sessions[i].alias] = label;
259
382
  }
260
383
  return keys;
261
384
  }
@@ -275,12 +398,20 @@ function buildFlowLinks(messages: MessageFlow[], positions: Record<string, Point
275
398
  const key = `${message.from_alias}->${message.to_alias}`;
276
399
  const current = links.get(key);
277
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
+
278
408
  links.set(key, {
279
409
  key,
280
410
  from: message.from_alias,
281
411
  to: message.to_alias,
282
412
  count: (current?.count || 0) + 1,
283
413
  content: current?.content || message.content,
414
+ last_at,
284
415
  });
285
416
  });
286
417
 
@@ -290,6 +421,7 @@ function buildFlowLinks(messages: MessageFlow[], positions: Record<string, Point
290
421
  export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProps) {
291
422
  const theme = useTheme();
292
423
  const isLight = theme === 'light';
424
+ const reducedMotion = useReducedMotion();
293
425
  const pal = isLight ? LIGHT_PALETTE : DARK_PALETTE;
294
426
  const brand = useBrand();
295
427
  const isIntern = brand === 'intern';
@@ -310,6 +442,20 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
310
442
  try { localStorage.setItem('anet-topo-layout', next); } catch {}
311
443
  return next;
312
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
+ };
313
459
 
314
460
  useEffect(() => {
315
461
  const fetchMessages = async () => {
@@ -337,6 +483,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
337
483
  activeAliases,
338
484
  groupKeys,
339
485
  groupBoxes,
486
+ gridContentBottom,
340
487
  } = useMemo(() => {
341
488
  const sseCount = (s: { alias: string; network_id?: string }) =>
342
489
  (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
@@ -346,8 +493,28 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
346
493
  // index, so contiguous-in-array becomes contiguous-in-ring, i.e.
347
494
  // each team visually clusters. localeCompare keeps CJK ordering sane.
348
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
+ };
349
516
  const online = sessions.filter(s => s.status !== 'offline' || sseCount(s)).sort(byAlias);
350
- 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);
351
518
  const positions: Record<string, Point> = {};
352
519
 
353
520
  if (layout === 'grid') {
@@ -357,10 +524,16 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
357
524
  // shared rows. Group-banded placement keeps every group's bounding box
358
525
  // (#111) a clean rectangle — no box ever overlaps another group's.
359
526
  const all = [...online, ...offline];
360
- const groupKeys = computeGroupKeys(all.map(s => s.alias));
527
+ const groupKeys = computeGroups(all);
361
528
  const cols = Math.max(1, Math.ceil(Math.sqrt(all.length)));
362
- const gx0 = 150, gx1 = 850, gy0 = 110, gy1 = 565;
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;
363
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);
364
537
 
365
538
  // ordered runs of consecutive same-group-key nodes (≥2 = real group)
366
539
  const runs: { key: string; members: Session[] }[] = [];
@@ -394,9 +567,32 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
394
567
  }
395
568
  }
396
569
  const totalRows = Math.max(1, row);
397
- // Adaptive row height so every band fits the canvas many small groups
398
- // would overflow a fixed height; zoom/pan still works past the floor.
399
- const cellH = Math.min(100, (gy1 - gy0) / totalRows);
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
+ );
400
596
 
401
597
  // Pass 2 — place each band's members.
402
598
  for (const band of bands) {
@@ -416,11 +612,10 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
416
612
  const active = new Set<string>();
417
613
  links.forEach(link => { active.add(link.from); active.add(link.to); });
418
614
  // #111: one bounding box per multi-member group (Vincent 4722). Each
419
- // group owns its rows, so the box is a clean rect with no overlap.
420
- // Padding scales with the (adaptive) row height so stacked group boxes
421
- // stay clear of each other even when many bands compress the grid.
422
- const GROUP_TOP = Math.max(22, Math.min(44, cellH * 0.4)); // label band
423
- const GROUP_PAD = Math.max(16, Math.min(32, cellH * 0.3)); // side/bottom
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
424
619
  const groupBoxes = bands
425
620
  .filter(b => b.isGroup)
426
621
  .map(band => {
@@ -428,14 +623,36 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
428
623
  const xs = pts.map(p => p.x);
429
624
  const ys = pts.map(p => p.y);
430
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
+ }
431
640
  return {
432
641
  key: band.members.length ? groupKeys[band.members[0].alias] : '',
642
+ count: band.members.length,
643
+ statuses: { working: w, idle: i, offline: o },
433
644
  x: minX - GROUP_PAD,
434
645
  y: minY - GROUP_TOP,
435
646
  w: Math.max(...xs) - minX + GROUP_PAD * 2,
436
647
  h: Math.max(...ys) - minY + GROUP_TOP + GROUP_PAD,
437
648
  };
438
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;
439
656
  return {
440
657
  onlineNodes: online,
441
658
  offlineNodes: offline,
@@ -444,6 +661,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
444
661
  activeAliases: active,
445
662
  groupKeys,
446
663
  groupBoxes,
664
+ gridContentBottom,
447
665
  };
448
666
  }
449
667
 
@@ -514,7 +732,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
514
732
  });
515
733
 
516
734
  // Round 106 (issue #83): group key per alias → shared hue per team.
517
- const groupKeys = computeGroupKeys([...online, ...offline].map(s => s.alias));
735
+ const groupKeys = computeGroups([...online, ...offline]);
518
736
 
519
737
  return {
520
738
  onlineNodes: online,
@@ -525,16 +743,70 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
525
743
  groupKeys,
526
744
  // #111: group boxes are a grid-layout feature only — radially scattered
527
745
  // ring nodes can't be cleanly boxed. Ring keeps the #83 prefix hue.
528
- groupBoxes: [] as { key: string; x: number; y: number; w: number; h: number }[],
746
+ groupBoxes: [] as { key: string; count: number; statuses: { working: number; idle: number; offline: number }; x: number; y: number; w: number; h: number }[],
747
+ // ring fits within VIEWBOX_H by construction (offlineRadius=325 + centre at y=330)
748
+ gridContentBottom: 0,
529
749
  };
530
- }, [messages, sessions, sseSessions, layout]);
750
+ }, [messages, sessions, sseSessions, layout, nodeScale]);
531
751
 
532
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]);
533
767
  // Round 109 (Vincent 4582 P0): hover-gated labels above this node count
534
768
  // so dense fleets show clean avatars instead of a wall of overlapping
535
769
  // label cards. 16 ≈ where the triple-tier rings start to crowd.
536
770
  const denseLayout = onlineNodes.length + offlineNodes.length > 16;
537
771
  const [hoveredAlias, setHoveredAlias] = useState<string | null>(null);
772
+ // Round 8 / Loop: which group is currently focused. Nodes outside this
773
+ // group + other group boxes fade so the eye locks onto the team you're
774
+ // pointing at. Singletons use their own alias as the group key.
775
+ const hoveredGroup = hoveredAlias ? (groupKeys[hoveredAlias] ?? hoveredAlias) : null;
776
+ // Round 63 / Loop: sticky group focus. R8 dims non-group nodes while
777
+ // a member is hovered, but releasing the hover lets the focus fade.
778
+ // Clicking the group label pins the group key here; activeGroup =
779
+ // hoveredGroup ?? pinnedGroup so hover transiently overrides the
780
+ // pin (handy for spot-comparing teams). Same compose pattern as
781
+ // R60/R61 pinnedStatus.
782
+ const [pinnedGroup, setPinnedGroup] = useState<string | null>(null);
783
+ const activeGroup = hoveredGroup ?? pinnedGroup;
784
+ // Round 49 / Loop: reverse-direction of R40's edge-on-node-hover linkage.
785
+ // R48 widened the flow hitbox to 16 px, so edges are precise enough to
786
+ // serve as a state trigger. When the user hovers a flow edge, light up its
787
+ // two endpoint nodes and dim the rest — "who is this edge between" becomes
788
+ // visible without reading the tooltip. The set is the link's two aliases
789
+ // (null when no edge hovered); node opacity composes this after inFocus.
790
+ const [hoveredEdgeKey, setHoveredEdgeKey] = useState<string | null>(null);
791
+ // Round 55 / Loop: hovering a legend status row dims nodes whose status
792
+ // doesn't match. The legend was passive — "what does this colour mean".
793
+ // Now it answers "show me all of these" the same way R8 group-focus
794
+ // answers "show me this team". Three values match the legend rows.
795
+ const [hoveredStatus, setHoveredStatus] = useState<'working' | 'idle' | 'offline' | null>(null);
796
+ // Round 60 / Loop: sticky variant of `hoveredStatus`. R55 only filters
797
+ // while the user is actively hovering the legend; for sweeping a fleet
798
+ // you want to LOCK the filter. Each segment of the R31 pressure bar
799
+ // toggles a pin — click again on the same segment to release. The node
800
+ // opacity formula reads `activeStatus = hoveredStatus ?? pinnedStatus`
801
+ // so hover transiently overrides a pin (handy for spot-comparison)
802
+ // without nuking it.
803
+ const [pinnedStatus, setPinnedStatus] = useState<'working' | 'idle' | 'offline' | null>(null);
804
+ const activeStatus = hoveredStatus ?? pinnedStatus;
805
+ const hoveredEdgeEndpoints = useMemo<Set<string> | null>(() => {
806
+ if (!hoveredEdgeKey) return null;
807
+ const link = flowLinks.find(l => l.key === hoveredEdgeKey);
808
+ return link ? new Set([link.from, link.to]) : null;
809
+ }, [hoveredEdgeKey, flowLinks]);
538
810
 
539
811
  // --- Round 103 (issue #81): fullscreen + zoom + pan interaction layer ---
540
812
  // DIY native (no d3 / svg-pan-zoom): wrap the topology content in a single
@@ -554,6 +826,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
554
826
  // Issue #100: singleton chat popover. One alias at a time — clicking
555
827
  // another node swaps the target and the conversation switches in place.
556
828
  const [chatAlias, setChatAlias] = useState<string | null>(null);
829
+ // Round 14 / Loop: one-shot click ripple — fades outward from the clicked
830
+ // node so the click registers physically before the popover snaps in.
831
+ // Pairs with the Round 11 chat-focus ring: ripple expands, ring locks on.
832
+ // Keyed by ts so re-clicking the same node remounts the <circle> and the
833
+ // SMIL <animate> replays. Cleared 600ms after click (longer than the
834
+ // 500ms animation so a final frame at opacity 0 is still in the tree).
835
+ const [clickRipple, setClickRipple] = useState<{
836
+ ts: number; x: number; y: number; r0: number; color: string;
837
+ } | null>(null);
557
838
  // #84: when a node is renamed while its chat popover is open, follow the
558
839
  // rename so the conversation keeps targeting a live alias.
559
840
  useEffect(() => {
@@ -584,6 +865,42 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
584
865
  } catch {}
585
866
  }, []);
586
867
 
868
+ // Round 28 / Loop: first-paint auto-fit. Round 27's cellH bump means
869
+ // dense grids (≥6-7 rows) overflow the 680-px viewBox at the natural
870
+ // 100% zoom — a new user sees the topology with its bottom rows
871
+ // clipped and no hint that there's more below. Auto-fit on first
872
+ // paint (and only if the user has no persisted view) sets the
873
+ // initial zoom so all content fits. Subsequent zoom changes persist
874
+ // and override the auto-fit on reload; explicit reset (0 key) still
875
+ // goes back to 100% so the gesture's "reset to natural size" semantic
876
+ // is preserved.
877
+ //
878
+ // Capture pre-mount persistence in a useState initializer — the
879
+ // existing `persist` effect (declared below) runs on first render
880
+ // with the default {1,0,0} view and writes it to localStorage before
881
+ // the auto-fit effect (deps on async-arriving sessions) gets a turn.
882
+ // Reading localStorage from the effect would see that write and
883
+ // skip the fit. The useState snapshot fires once, before any effects.
884
+ const [hadPersistedViewOnMount] = useState<boolean>(
885
+ () => typeof window !== 'undefined' && !!localStorage.getItem('anet-topo-view'),
886
+ );
887
+ const autoFitDoneRef = useRef(false);
888
+ useEffect(() => {
889
+ if (autoFitDoneRef.current) return;
890
+ if (hadPersistedViewOnMount) {
891
+ autoFitDoneRef.current = true;
892
+ return;
893
+ }
894
+ if (layout !== 'grid' || sessions.length === 0 || !gridContentBottom) return;
895
+ if (gridContentBottom <= VIEWBOX_H) {
896
+ autoFitDoneRef.current = true; // no overflow → no fit needed
897
+ return;
898
+ }
899
+ const fitZoom = Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
900
+ setView({ zoom: fitZoom, x: 0, y: 0 });
901
+ autoFitDoneRef.current = true;
902
+ }, [layout, sessions.length, gridContentBottom, hadPersistedViewOnMount]);
903
+
587
904
  // persist view
588
905
  useEffect(() => {
589
906
  try { localStorage.setItem('anet-topo-view', JSON.stringify(view)); } catch {}
@@ -603,6 +920,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
603
920
  const svg = svgRef.current;
604
921
  if (!svg) return;
605
922
  const onWheel = (e: WheelEvent) => {
923
+ // Round 23 / Loop: only zoom on Ctrl/Meta+wheel when inline; plain
924
+ // wheel over the canvas keeps scrolling the page (the topo sits at
925
+ // the top of /; trapping page scroll mid-page is the classic
926
+ // "scroll-jail" anti-pattern). In fullscreen the canvas owns the
927
+ // viewport so any wheel zooms — no page scroll to preserve. Pinch-
928
+ // zoom on a trackpad surfaces as ctrlKey=true natively, which is
929
+ // also what we want (zoom the canvas, not the browser).
930
+ if (!isFullscreen && !e.ctrlKey && !e.metaKey) return;
606
931
  e.preventDefault();
607
932
  const rect = svg.getBoundingClientRect();
608
933
  const mx = ((e.clientX - rect.left) / rect.width) * VIEWBOX_W;
@@ -623,8 +948,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
623
948
  };
624
949
  svg.addEventListener('wheel', onWheel, { passive: false });
625
950
  return () => svg.removeEventListener('wheel', onWheel);
626
- }, []);
627
-
951
+ // Re-attach when the fullscreen flag toggles so the handler's
952
+ // closure picks up the new value (capture-by-closure means we'd
953
+ // otherwise read the stale boolean forever).
954
+ }, [isFullscreen]);
955
+
956
+ // Round 21 / Loop: pan-aware cursor. Static `grab` gave no tactile cue
957
+ // that the canvas was actually being dragged — "grabbing" while
958
+ // dragRef.current.active = true tells the hand it's moving the
959
+ // viewport. Mirroring dragRef.active into state is the only way to
960
+ // re-render the SVG style; the ref itself doesn't trigger renders.
961
+ const [isPanning, setIsPanning] = useState(false);
628
962
  const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
629
963
  if (e.button !== 0) return;
630
964
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
@@ -635,6 +969,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
635
969
  baseX: viewRef.current.x,
636
970
  baseY: viewRef.current.y,
637
971
  };
972
+ setIsPanning(true);
638
973
  };
639
974
  const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
640
975
  const d = dragRef.current;
@@ -649,6 +984,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
649
984
  const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
650
985
  if (!dragRef.current.active) return;
651
986
  dragRef.current.active = false;
987
+ setIsPanning(false);
652
988
  try {
653
989
  (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
654
990
  } catch {}
@@ -666,6 +1002,64 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
666
1002
  };
667
1003
  const resetView = () => setView({ zoom: 1, x: 0, y: 0 });
668
1004
 
1005
+ // Round 29 / Loop: `f` = fit-to-content. Shared by the Round 28
1006
+ // first-paint auto-fit effect and the keyboard handler so the math is
1007
+ // in one place. When content already fits at natural zoom, this is
1008
+ // effectively a "recenter" — `f` always lands on a known good view.
1009
+ const fitView = useCallback(() => {
1010
+ const zoom = !gridContentBottom || gridContentBottom <= VIEWBOX_H
1011
+ ? 1
1012
+ : Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
1013
+ setView({ zoom, x: 0, y: 0 });
1014
+ }, [gridContentBottom]);
1015
+
1016
+ // Round 22 / Loop: keyboard zoom — +/= zoom in, - zoom out, 0 reset.
1017
+ // Round 29 / Loop: +f to fit content.
1018
+ // Listen on window so the user doesn't need to focus the SVG first,
1019
+ // but only act when no text input has focus (otherwise typing "-" in
1020
+ // a chat would zoom the topology — surprising and bad). Modifier keys
1021
+ // pass through so Cmd/Ctrl combos still hit their owners. The button
1022
+ // titles below document these shortcuts so users discover them.
1023
+ useEffect(() => {
1024
+ const onKey = (e: KeyboardEvent) => {
1025
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
1026
+ const ae = document.activeElement as HTMLElement | null;
1027
+ if (ae) {
1028
+ const tag = ae.tagName;
1029
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || ae.isContentEditable) return;
1030
+ }
1031
+ if (e.key === '+' || e.key === '=') { zoomBy(1.2); e.preventDefault(); }
1032
+ else if (e.key === '-' || e.key === '_') { zoomBy(1 / 1.2); e.preventDefault(); }
1033
+ else if (e.key === '0') { resetView(); e.preventDefault(); }
1034
+ else if (e.key === 'f' || e.key === 'F') { fitView(); e.preventDefault(); }
1035
+ // Round 32 / Loop: `l` toggles ring|grid. The vim-style `g l` route
1036
+ // (Audit Log) requires a preceding `g` within 1500ms; a bare `l`
1037
+ // outside that window is free for topology use.
1038
+ else if (e.key === 'l' || e.key === 'L') { toggleLayout(); e.preventDefault(); }
1039
+ // Round 62 / Loop: Esc clears the R60/R61 pinned status filter so
1040
+ // users have a universal-cancel keyboard out. Esc on an open chat
1041
+ // is owned by ChatPopover (which only mounts when chatAlias is
1042
+ // set), so this handler is effectively scoped to "no chat open".
1043
+ // We additionally guard on chatAlias to be explicit — if the chat
1044
+ // closed mid-cycle, the pin can still be cleared on the next Esc.
1045
+ // R63: extends to pinnedGroup too. Clears WHATEVER pin is active
1046
+ // (one Esc collapses all topology pins) so the keyboard escape
1047
+ // route stays a single key, not "Esc maybe Esc again".
1048
+ else if (e.key === 'Escape' && !chatAlias && (pinnedStatus || pinnedGroup)) {
1049
+ if (pinnedStatus) setPinnedStatus(null);
1050
+ if (pinnedGroup) setPinnedGroup(null);
1051
+ e.preventDefault();
1052
+ }
1053
+ };
1054
+ window.addEventListener('keydown', onKey);
1055
+ return () => window.removeEventListener('keydown', onKey);
1056
+ // zoomBy / resetView are stable wrt setView callback; fitView changes
1057
+ // with gridContentBottom so deps list catches it. R62 adds chatAlias
1058
+ // and pinnedStatus so the Escape branch reads fresh state (re-binding
1059
+ // the listener on these state changes is sub-ms — cheaper than refs).
1060
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1061
+ }, [fitView, chatAlias, pinnedStatus, pinnedGroup]);
1062
+
669
1063
  const toggleFullscreen = () => {
670
1064
  const el = containerRef.current;
671
1065
  if (!el) return;
@@ -693,6 +1087,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
693
1087
  <button
694
1088
  onClick={() => { if (layout !== 'ring') toggleLayout(); }}
695
1089
  aria-pressed={layout === 'ring'}
1090
+ title="Ring layout (l to toggle)"
696
1091
  className={`px-2.5 py-1 transition-colors ${layout === 'ring' ? 'bg-cyan-500/15 text-cyan-300' : 'text-gray-500 hover:text-gray-400'}`}
697
1092
  >
698
1093
  Ring
@@ -700,6 +1095,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
700
1095
  <button
701
1096
  onClick={() => { if (layout !== 'grid') toggleLayout(); }}
702
1097
  aria-pressed={layout === 'grid'}
1098
+ title="Grid layout (l to toggle)"
703
1099
  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'}`}
704
1100
  >
705
1101
  Grid
@@ -711,9 +1107,110 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
711
1107
  <span className="px-2.5 py-1 rounded-md bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
712
1108
  {onlineNodes.length} online
713
1109
  </span>
714
- <span className="px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20">
715
- {flowLinks.length} active links
716
- </span>
1110
+ {/* Round 31 / Loop: fleet-health pressure bar. The "X working /
1111
+ Y online" chips already carry the raw counts; the bar lets
1112
+ the eye get the working/idle/offline RATIO in one glance
1113
+ without mental math. Stacked 3-segment chip, ~64 px wide. */}
1114
+ {(() => {
1115
+ const w = workingCount;
1116
+ const i = onlineNodes.length - workingCount; // idle = online - working
1117
+ const o = offlineNodes.length;
1118
+ const total = w + i + o;
1119
+ if (total === 0) return null;
1120
+ // Round 60 / Loop: each segment toggles a sticky filter via
1121
+ // `pinnedStatus`. Click the working segment → all non-working
1122
+ // nodes dim; click again → release. Segments share width with
1123
+ // their proportion of `total`, so a 1-node working share is
1124
+ // ~3 px wide on a 64-px bar. We pad the click target with a
1125
+ // negative-margin overlay wrapper to give thin slices a
1126
+ // 14-px minimum hit area without disturbing the rendered
1127
+ // chip width. cursor:pointer + a one-line title hint at the
1128
+ // affordance.
1129
+ const seg = (n: number, color: string, key: 'working' | 'idle' | 'offline', label: string) => {
1130
+ if (n === 0) return null;
1131
+ const isPinned = pinnedStatus === key;
1132
+ return (
1133
+ <span
1134
+ key={key}
1135
+ data-pressure-seg={key}
1136
+ role="button"
1137
+ tabIndex={0}
1138
+ aria-pressed={isPinned}
1139
+ title={`${n} ${label} — click to ${isPinned ? 'release filter' : 'highlight'}`}
1140
+ style={{
1141
+ width: `${(n / total) * 100}%`,
1142
+ background: color,
1143
+ height: '100%',
1144
+ cursor: 'pointer',
1145
+ boxShadow: isPinned ? `inset 0 0 0 1px ${color}, inset 0 0 0 2px rgba(255,255,255,0.6)` : undefined,
1146
+ }}
1147
+ onClick={(e) => {
1148
+ e.stopPropagation();
1149
+ setPinnedStatus(prev => prev === key ? null : key);
1150
+ }}
1151
+ onKeyDown={(e) => {
1152
+ if (e.key === 'Enter' || e.key === ' ') {
1153
+ e.preventDefault();
1154
+ setPinnedStatus(prev => prev === key ? null : key);
1155
+ }
1156
+ }}
1157
+ />
1158
+ );
1159
+ };
1160
+ // Round 47 / Loop: hidden on mobile — at <640px the chip row
1161
+ // wraps to multiple lines and crowds the topology header;
1162
+ // pressure ratio is best read with the working+online raw
1163
+ // counts (kept visible) anyway.
1164
+ return (
1165
+ <span
1166
+ 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"
1167
+ title={`${w} working · ${i} idle · ${o} offline`}
1168
+ data-fleet-pressure
1169
+ >
1170
+ <span className="text-[10px] tracking-wide">pressure</span>
1171
+ <span className="inline-flex h-1.5 w-16 rounded-full overflow-hidden" style={{ background: 'rgb(75 85 99 / 0.25)' }}>
1172
+ {seg(w, isLight ? '#059669' : '#22c55e', 'working', 'working')}
1173
+ {seg(i, isLight ? '#0d9488' : '#2dd4bf', 'idle', 'idle')}
1174
+ {seg(o, isLight ? '#94a3b8' : '#6b7280', 'offline', 'offline')}
1175
+ </span>
1176
+ </span>
1177
+ );
1178
+ })()}
1179
+ {vendorDist.length > 1 && (
1180
+ <span
1181
+ 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"
1182
+ title="Fleet by model vendor"
1183
+ >
1184
+ {vendorDist.map(v => (
1185
+ <span key={v.initial} className="inline-flex items-baseline gap-0.5">
1186
+ <span style={{ color: v.color }}>{v.initial}</span>
1187
+ <span className="text-gray-500">:{v.count}</span>
1188
+ </span>
1189
+ ))}
1190
+ </span>
1191
+ )}
1192
+ {/* Round 42 / Loop: extend active-links chip with the timestamp
1193
+ of the most-recent flow event. Tells the operator at a glance
1194
+ whether the topology is currently humming (last 30s) or has
1195
+ been quiet for a while — the visual flow particles and
1196
+ edge brightness only show that there IS traffic, not when
1197
+ the last one was. Reuses Round 38's relativeAgo. */}
1198
+ {(() => {
1199
+ const recent = flowLinks.reduce<number | null>((acc, l) => {
1200
+ if (!l.last_at) return acc;
1201
+ const t = parseHubTime(l.last_at);
1202
+ if (t === null) return acc;
1203
+ return acc === null || t > acc ? t : acc;
1204
+ }, null);
1205
+ const rel = recent !== null ? relativeAgo(new Date(recent).toISOString()) : null;
1206
+ return (
1207
+ <span className="hidden sm:inline px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20">
1208
+ {flowLinks.length} active link{flowLinks.length === 1 ? '' : 's'}
1209
+ {rel ? <span className="text-gray-500"> · last {rel}</span> : null}
1210
+ </span>
1211
+ );
1212
+ })()}
1213
+ <FreshnessChip sessions={sessions} />
717
1214
  </div>
718
1215
  </div>
719
1216
 
@@ -733,7 +1230,18 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
733
1230
  onPointerMove={onPointerMove}
734
1231
  onPointerUp={onPointerUp}
735
1232
  onPointerLeave={onPointerUp}
736
- style={{ cursor: 'grab', touchAction: 'none' }}
1233
+ // Round 41 / Loop: the reset-button title (R22) and the Help
1234
+ // overlay (R25) both advertise "double-click the canvas to
1235
+ // reset", but the handler was never actually wired. The text
1236
+ // was lying. Wire it here, guarded so dbl-clicking a node
1237
+ // (which would also trigger the SVG-level handler via event
1238
+ // bubbling) doesn't unexpectedly reset the view on the user.
1239
+ onDoubleClick={(e) => {
1240
+ const t = e.target as Element | null;
1241
+ if (t?.closest('g[data-node]')) return;
1242
+ resetView();
1243
+ }}
1244
+ style={{ cursor: isPanning ? 'grabbing' : 'grab', touchAction: 'none' }}
737
1245
  >
738
1246
  <defs>
739
1247
  <linearGradient id="topo-panel" x1="0" x2="1" y1="0" y2="1">
@@ -755,9 +1263,37 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
755
1263
  </feMerge>
756
1264
  </filter>
757
1265
  )}
758
- <marker id="topo-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
759
- <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
760
- </marker>
1266
+ {/* Round 16 / Loop: 3-tier flow-link arrow markers.
1267
+ The single marker had `markerUnits` defaulting to
1268
+ `strokeWidth`, so heavy edges (stroke=7) rendered 35-user-
1269
+ unit arrowheads — visually dominant, the head outweighed
1270
+ the line. Switching to `userSpaceOnUse` decouples arrow
1271
+ size from stroke; binning by count gives a clearer
1272
+ hierarchy than continuous linear scaling:
1273
+ s (count 1-2) → 12 user units
1274
+ m (count 3-4) → 16 user units (alias for `topo-arrow`)
1275
+ l (count 5+) → 22 user units
1276
+ `topo-arrow` stays bound to the medium tier so the legend
1277
+ swatch (line ~1500) renders without change. */}
1278
+ {[
1279
+ { id: 'topo-arrow-s', size: 12 },
1280
+ { id: 'topo-arrow', size: 16 },
1281
+ { id: 'topo-arrow-l', size: 22 },
1282
+ ].map(m => (
1283
+ <marker
1284
+ key={m.id}
1285
+ id={m.id}
1286
+ viewBox="0 0 10 10"
1287
+ refX="8"
1288
+ refY="5"
1289
+ markerWidth={m.size}
1290
+ markerHeight={m.size}
1291
+ markerUnits="userSpaceOnUse"
1292
+ orient="auto-start-reverse"
1293
+ >
1294
+ <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
1295
+ </marker>
1296
+ ))}
761
1297
  {/* Round 45: radial radar sweep gradient — bright at center
762
1298
  (where it meets the hub) fading to leading edge. */}
763
1299
  <radialGradient id="topo-sweep" cx="0%" cy="50%" r="100%">
@@ -777,13 +1313,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
777
1313
  grid mode drops it so the concentric rings don't sit behind a
778
1314
  rectangular grid. */}
779
1315
  {layout === 'ring' && (<>
780
- <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" />
1316
+ {/* R52: radar bg is pure decoration — drop its pointer events so
1317
+ the hub <g> under it (and any future inner-disk affordances)
1318
+ can receive clicks. Previously the r=330 disc intercepted
1319
+ the hub click outright. */}
1320
+ <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" style={{ pointerEvents: 'none' }} />
781
1321
 
782
1322
  {/* Round 45: subtle star field — 24 deterministic dots scattered
783
1323
  across the canvas give the radar bg some depth. Skipped on
784
1324
  light theme so the white surface stays clean. */}
785
1325
  {!isLight && (
786
- <g opacity="0.5">
1326
+ <g opacity="0.5" style={{ pointerEvents: 'none' }}>
787
1327
  {Array.from({ length: 28 }).map((_, i) => {
788
1328
  // Deterministic pseudo-random scatter so positions are
789
1329
  // stable between renders (no JS hydration mismatch).
@@ -805,6 +1345,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
805
1345
  style={{
806
1346
  transformOrigin: `${cx}px ${cy}px`,
807
1347
  transformBox: 'view-box',
1348
+ pointerEvents: 'none',
808
1349
  }}
809
1350
  className="anet-topo-sweep"
810
1351
  opacity={isLight ? 0.7 : 1}
@@ -815,11 +1356,44 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
815
1356
  />
816
1357
  </g>
817
1358
 
818
- {/* radar rings */}
1359
+ {/* radar rings — pure decoration at fixed radii, independent of
1360
+ node positions so the radar aesthetic is preserved across tier
1361
+ changes. */}
819
1362
  {[90, 170, 250, 330].map(radius => (
820
1363
  <circle key={radius} cx={cx} cy={cy} r={radius} fill="none" stroke={pal.ringStroke} strokeWidth="1" opacity={isLight ? 0.6 : 0.35} />
821
1364
  ))}
822
1365
 
1366
+ {/* Round 54 / Loop: tier-radius guide rings. The radar rings above
1367
+ are decorative and don't match the actual tier radii nodes sit
1368
+ on (single 220 / dual 175,260 / triple 145,215,285). Drawing
1369
+ a faint dashed ring at each ACTIVE tier radius lets the eye
1370
+ anchor "this is the inner / outer ring" without inferring from
1371
+ node spacing. Picked based on online node count so only the
1372
+ tiers currently in use draw — empty tiers stay quiet. pointer-
1373
+ events:none so they never intercept hub or node clicks. The
1374
+ 0.7 stroke + dashed pattern reads as guide, not feature. */}
1375
+ {(
1376
+ onlineNodes.length > onlineTripleThreshold
1377
+ ? [onlineTripleInnerR, onlineTripleMidR, onlineTripleOuterR]
1378
+ : onlineNodes.length > onlineTierThreshold
1379
+ ? [onlineInnerRadius, onlineOuterRadius]
1380
+ : onlineNodes.length > 0
1381
+ ? [onlineRadius]
1382
+ : []
1383
+ ).map(r => (
1384
+ <circle
1385
+ key={`tier-${r}`}
1386
+ cx={cx} cy={cy} r={r}
1387
+ fill="none"
1388
+ stroke={pal.ringStroke}
1389
+ strokeWidth="0.7"
1390
+ strokeDasharray="2 8"
1391
+ opacity={isLight ? 0.32 : 0.42}
1392
+ style={{ pointerEvents: 'none' }}
1393
+ data-tier-ring={r}
1394
+ />
1395
+ ))}
1396
+
823
1397
  {/* Round 50: 4 small particles slowly orbiting the outer ring
824
1398
  (r=330). Each starts at a different angle (offset 0/0.25/0.5/0.75
825
1399
  of the cycle) so they're evenly spaced. 16s per revolution is
@@ -889,32 +1463,104 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
889
1463
  only — groupBoxes is empty in ring mode. Rendered behind the
890
1464
  flow links + nodes; pointer-events off so they never intercept
891
1465
  a node click. Restrained dashed container + group-name label. */}
892
- {groupBoxes.map(box => (
893
- <g key={`grp-${box.key}`} data-group={box.key} style={{ pointerEvents: 'none' }}>
894
- <rect
895
- x={box.x}
896
- y={box.y}
897
- width={box.w}
898
- height={box.h}
899
- rx="14"
900
- fill={isLight ? '#0f172a' : '#a5b4fc'}
901
- fillOpacity={isLight ? 0.025 : 0.045}
902
- stroke={pal.ringStroke}
903
- strokeWidth="1.5"
904
- strokeDasharray="6 6"
905
- />
906
- <text
907
- x={box.x + 12}
908
- y={box.y + 21}
909
- fill={pal.legendText}
910
- fontSize="13"
911
- fontFamily="monospace"
912
- fontWeight="700"
1466
+ {groupBoxes.map(box => {
1467
+ const isHovered = activeGroup === box.key;
1468
+ // Round 18 / Loop: group-box hover linkage. The Round 8 fade
1469
+ // already dropped OUT-of-focus groups to 0.28, but the IN-focus
1470
+ // group sat at its baseline appearance — no positive emphasis.
1471
+ // Hovering now upgrades the box to an "accent" treatment:
1472
+ // solid stroke (not dashed), thicker, accent-coloured; brighter
1473
+ // text and slightly stronger fill. Label and box read as one
1474
+ // selected unit. Geometry unchanged → overlap test untouched.
1475
+ return (
1476
+ <g
1477
+ key={`grp-${box.key}`}
1478
+ data-group={box.key}
1479
+ className="transition-opacity"
1480
+ // R63: drop the blanket pointerEvents:'none' that
1481
+ // previously sat here. Chrome's SVG impl doesn't let a
1482
+ // child override a parent's `none` even though the spec
1483
+ // says it should — moving the property onto just the
1484
+ // rect (where it's needed so nodes underneath stay
1485
+ // clickable) lets the label text receive its own click.
1486
+ style={{ opacity: !activeGroup || isHovered ? 1 : 0.28 }}
913
1487
  >
914
- {box.key}
915
- </text>
916
- </g>
917
- ))}
1488
+ <rect
1489
+ x={box.x}
1490
+ y={box.y}
1491
+ width={box.w}
1492
+ height={box.h}
1493
+ rx="14"
1494
+ fill={isLight ? '#0f172a' : '#a5b4fc'}
1495
+ fillOpacity={isHovered ? (isLight ? 0.05 : 0.09) : (isLight ? 0.025 : 0.045)}
1496
+ stroke={isHovered ? pal.legendAccent : pal.ringStroke}
1497
+ strokeWidth={isHovered ? 2 : 1.5}
1498
+ strokeDasharray={isHovered ? 'none' : '6 6'}
1499
+ style={{ transition: 'stroke 200ms ease-out, stroke-width 200ms ease-out, fill-opacity 200ms ease-out', pointerEvents: 'none' }}
1500
+ />
1501
+ {/* R63: wrap label in a clickable <g> with an invisible
1502
+ rect hitbox. The text alone wasn't getting hit-tested
1503
+ reliably — the SVG-wide topo-panel <rect> intercepts
1504
+ at the label's screen position when the label sits at
1505
+ a high viewBox-y (it lands below where the compositor
1506
+ expects the text to paint on top, same gotcha as the
1507
+ recent-signal panel rows in R56). Hitbox rect + the
1508
+ parent <g> taking the click + onPointerDown stop-
1509
+ propagation match the R55/R56/R61 pattern. */}
1510
+ <g
1511
+ role="button"
1512
+ tabIndex={0}
1513
+ aria-pressed={pinnedGroup === box.key}
1514
+ data-group-label-hit={box.key}
1515
+ style={{ pointerEvents: 'all', cursor: 'pointer' }}
1516
+ onPointerDown={(e) => e.stopPropagation()}
1517
+ onClick={() => setPinnedGroup(prev => prev === box.key ? null : box.key)}
1518
+ >
1519
+ <rect
1520
+ x={box.x + 6}
1521
+ y={box.y + 2}
1522
+ width={Math.min(box.w - 12, 240)}
1523
+ height={20}
1524
+ fill="transparent"
1525
+ />
1526
+ <text
1527
+ x={box.x + 12}
1528
+ y={box.y + 14}
1529
+ fill={isHovered ? pal.legendHeadline : pal.legendText}
1530
+ fontSize="13"
1531
+ fontFamily="monospace"
1532
+ fontWeight="700"
1533
+ style={{ transition: 'fill 200ms ease-out' }}
1534
+ data-group-label={box.key}
1535
+ >
1536
+ {box.key}
1537
+ {/* Round 19 / Loop: member-count chip. Inline tspan stays
1538
+ in the single <text> bbox the overlap test reads, so
1539
+ the node↔label guard still catches if the chip ever
1540
+ pushes the label far enough right to clip a node.
1541
+ Smaller + lighter weight reads as metadata, not name. */}
1542
+ <tspan dx="6" fill={pal.legendText} fontSize="11" fontWeight="400">· {box.count}</tspan>
1543
+ {/* Round 58 / Loop: status mix pip strip. Compact text-
1544
+ based chips (e.g. "2w 1i") so the strip stays inside
1545
+ the same <text> bbox the overlap-test reads — keeps
1546
+ the R27 label↔label and R19 node↔label guards intact.
1547
+ Each tier is colour-coded against the legend swatches
1548
+ and only renders when count > 0, so a healthy all-
1549
+ working group reads simply " · 2w". */}
1550
+ {box.statuses.working > 0 && (
1551
+ <tspan dx="8" fill={isLight ? '#059669' : '#22c55e'} fontSize="11" fontWeight="600">{box.statuses.working}w</tspan>
1552
+ )}
1553
+ {box.statuses.idle > 0 && (
1554
+ <tspan dx="4" fill={isLight ? '#0d9488' : '#2dd4bf'} fontSize="11" fontWeight="600">{box.statuses.idle}i</tspan>
1555
+ )}
1556
+ {box.statuses.offline > 0 && (
1557
+ <tspan dx="4" fill={isLight ? '#94a3b8' : '#6b7280'} fontSize="11" fontWeight="600">{box.statuses.offline}o</tspan>
1558
+ )}
1559
+ </text>
1560
+ </g>
1561
+ </g>
1562
+ );
1563
+ })}
918
1564
 
919
1565
  {/* directed message flows */}
920
1566
  {flowLinks.map((link, index) => {
@@ -922,21 +1568,116 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
922
1568
  const to = nodePositions[link.to];
923
1569
  if (!from || !to) return null;
924
1570
 
925
- const lift = index % 2 === 0 ? 36 : -36;
1571
+ // Round 7 / Loop: lift now scales with distance so short links
1572
+ // aren't over-bent (long links keep the ~36px hump), and the
1573
+ // particle period shortens with link.count so busier edges
1574
+ // visibly pulse faster — instant info density on top of the
1575
+ // existing stroke-width-by-count chip.
1576
+ const dist = Math.hypot(to.x - from.x, to.y - from.y);
1577
+ const lift = (index % 2 === 0 ? 1 : -1) * Math.min(36, dist * 0.18);
926
1578
  const path = curvePath(from, to, lift);
927
1579
  const width = Math.min(2 + link.count, 7);
928
- const duration = 2.2 + (index % 5) * 0.32;
929
-
1580
+ const duration = Math.max(0.9, 2.6 / Math.sqrt(link.count));
1581
+ // Round 10 / Loop: freshness fade. An edge that fired ≤30s ago
1582
+ // stays at full intensity; over 5 minutes it decays to ~35%.
1583
+ // Surfaces "what's happening now" vs background chatter without
1584
+ // hiding old flow entirely (some context still useful). `now`
1585
+ // captured at useMemo-recompute time (every 5s message refresh)
1586
+ // — accuracy is within the poll interval, plenty.
1587
+ const ageMs = link.last_at ? Math.max(0, Date.now() - Date.parse(link.last_at)) : 0;
1588
+ const fresh = Math.max(0.35, 1 - ageMs / (5 * 60 * 1000));
1589
+ // Round 16 arrow-tier binning — keep `topo-arrow` as the
1590
+ // medium tier id so the legend swatch picks it up unchanged.
1591
+ const arrowId = link.count <= 2 ? 'topo-arrow-s'
1592
+ : link.count <= 4 ? 'topo-arrow'
1593
+ : 'topo-arrow-l';
1594
+
1595
+ // Round 39 / Loop: edge hover tooltip — surface the same
1596
+ // last_at + count info the freshness fade and arrow tier
1597
+ // already encode visually, in plain text. The stroke is the
1598
+ // hover target; SVG `<title>` honours newlines on every
1599
+ // browser the dashboard targets.
1600
+ const lastAt = relativeAgo(link.last_at);
1601
+ const tooltip = `${link.from} → ${link.to}\n${link.count} message${link.count === 1 ? '' : 's'}${lastAt ? ` · last ${lastAt}` : ''}`;
1602
+ // Round 40 / Loop: edges follow node hover — when an alias is
1603
+ // hovered, every edge touching it brightens, the rest fade.
1604
+ // Pairs with the Round 8 group-focus fade on nodes: hover a
1605
+ // node to find "who is this agent talking to" at a glance.
1606
+ // No hover → multiplier is 1.0 (current behaviour preserved).
1607
+ // Round 50 / Loop: edge-on-self priority. When the user hovers
1608
+ // a flow edge directly (R48 widened the hitbox so this is now
1609
+ // a precise gesture), THAT edge gets the strongest boost (2.0)
1610
+ // and every other edge dims to 0.35. Node-hover (R40) keeps
1611
+ // its own ladder. Edge-hover and node-hover are mutually
1612
+ // exclusive in practice — the cursor is over one or the
1613
+ // other — but the order below makes edge-hover win if both
1614
+ // ever read truthy at the same React tick.
1615
+ // Round 53 / Loop: in-group edges follow team focus. R40
1616
+ // brightened only edges touching the exact hovered alias —
1617
+ // but with R8/R18 prefix-clustering, a user hovering one
1618
+ // member is asking about the team. So when BOTH endpoints of
1619
+ // an edge share the hoveredGroup (and neither is the exact
1620
+ // hovered alias — that gets the stronger 1.7×), the edge
1621
+ // boosts to 1.3×. Edges leaving the team (one endpoint in,
1622
+ // one out) still dim to 0.35× per R40 since they read as
1623
+ // background to the focus. Singletons fall through unchanged
1624
+ // (their group key is the alias itself, so bothInHoveredGroup
1625
+ // is impossible for a non-self-edge).
1626
+ const isHoveredEdge = hoveredEdgeKey === link.key;
1627
+ const fromGroup = groupKeys[link.from] ?? link.from;
1628
+ const toGroup = groupKeys[link.to] ?? link.to;
1629
+ const bothInHoveredGroup = !!activeGroup && fromGroup === activeGroup && toGroup === activeGroup;
1630
+ // R63: also gate "no filter" on activeGroup so pinnedGroup
1631
+ // alone activates the in-group 1.3× boost + non-group dim.
1632
+ // When neither a node nor a group is in focus, mul is 1.
1633
+ const edgeOpacityMul = isHoveredEdge
1634
+ ? 2.0
1635
+ : hoveredEdgeKey
1636
+ ? 0.35
1637
+ : !hoveredAlias && !activeGroup
1638
+ ? 1
1639
+ : (link.from === hoveredAlias || link.to === hoveredAlias)
1640
+ ? 1.7
1641
+ : bothInHoveredGroup
1642
+ ? 1.3
1643
+ : 0.35;
1644
+ // Round 50: the hovered edge also visibly thickens so the eye
1645
+ // tracks it even at low message counts (width was 3 → 4.5 on
1646
+ // hover). 1.4× is enough to read as "lifted" without
1647
+ // breaching the 16-px hitbox bound.
1648
+ const renderWidth = isHoveredEdge ? Math.min(width * 1.4, 10) : width;
930
1649
  return (
931
1650
  <g key={link.key}>
1651
+ {/* Round 48 / Loop: invisible hover hitbox — visible flow
1652
+ path is 3-7 px wide and damn hard to hover precisely.
1653
+ Stack a transparent 16-px-wide stroke behind it so the
1654
+ cursor only needs to be ~8 px from the line for the
1655
+ tooltip to fire. Native <title> moves here; the
1656
+ visible path no longer needs it. pointer-events on
1657
+ the visible path drop to "none" since the hitbox
1658
+ owns the hover surface. */}
1659
+ <path
1660
+ d={path}
1661
+ fill="none"
1662
+ stroke="transparent"
1663
+ strokeWidth={Math.max(width + 10, 16)}
1664
+ style={{ pointerEvents: 'stroke' }}
1665
+ data-edge-hitbox
1666
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
1667
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
1668
+ >
1669
+ <title>{tooltip}</title>
1670
+ </path>
932
1671
  <path
933
1672
  d={path}
934
1673
  fill="none"
935
1674
  stroke={pal.flowEdge}
936
- strokeWidth={width}
937
- opacity={isLight ? 0.22 : 0.28}
1675
+ strokeWidth={renderWidth}
1676
+ opacity={Math.min(1, (isLight ? 0.22 : 0.28) * fresh * edgeOpacityMul)}
938
1677
  filter={isLight ? undefined : 'url(#topo-glow)'}
939
- markerEnd="url(#topo-arrow)"
1678
+ markerEnd={`url(#${arrowId})`}
1679
+ className="transition-opacity duration-300"
1680
+ style={{ pointerEvents: 'none' }}
940
1681
  />
941
1682
  <path
942
1683
  id={`flow-path-${index}`}
@@ -945,37 +1686,75 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
945
1686
  stroke={pal.flowPath}
946
1687
  strokeWidth="1"
947
1688
  strokeDasharray="2 12"
948
- opacity={isLight ? 0.4 : 0.75}
1689
+ opacity={Math.min(1, (isLight ? 0.4 : 0.75) * fresh * edgeOpacityMul)}
1690
+ className="transition-opacity duration-300"
949
1691
  />
950
- <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'}>
951
- <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
952
- </circle>
1692
+ {!reducedMotion && (
1693
+ <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'} opacity={Math.min(1, fresh * edgeOpacityMul)}>
1694
+ <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
1695
+ </circle>
1696
+ )}
953
1697
  </g>
954
1698
  );
955
1699
  })}
956
1700
 
957
- {/* center hub — round 39: enlarged from r=6 (12px) to r=10 (20px)
958
- core so the "control plane" reads as the network's anchor,
959
- not a stray particle. Static halo ring at r=18 grounds it
960
- visually; two outward pulses keep the "signal source" idea
961
- from r17. Light theme gets a paler core to avoid hot-spotting
962
- the bg. */}
963
- {layout === 'ring' && (<g>
964
- {/* static grounding halo sits underneath, low opacity */}
1701
+ {/* center hub — round 39 sized + round 13 restraint.
1702
+ The hub is the control-plane anchor. r39 gave it two outward
1703
+ pulses (r 10→38, 2.4s, double-phase) which read as "loudest
1704
+ node in the room" wrong semantics: anchors don't emit,
1705
+ they hold. r13 swaps the kinetic pulse for a slow opacity
1706
+ breath on the static halo (4s, ±15% from base), so the hub
1707
+ stays alive without throwing kinetic energy outward. The
1708
+ dual-circle "lit lamp" core is unchanged. */}
1709
+ {layout === 'ring' && (<g
1710
+ data-topo-hub
1711
+ style={{ cursor: 'pointer' }}
1712
+ // Stop pointerdown from reaching the SVG pan handler — same
1713
+ // reason as the node <g>: a captured pointer makes Chromium
1714
+ // fire the follow-up click on the SVG, not this group.
1715
+ onPointerDown={(e) => e.stopPropagation()}
1716
+ // Round 52 / Loop: hub is the visual anchor but had no click
1717
+ // action — users discovered fit-to-content only via the `f`
1718
+ // key or the chrome button. Wire the hub to fitView() so the
1719
+ // most prominent element in the canvas is also the "re-
1720
+ // center" affordance. A click-ripple keyed by ts confirms
1721
+ // the gesture; the hub <title> now ends with a hint.
1722
+ onClick={() => {
1723
+ fitView();
1724
+ setClickRipple({ ts: Date.now(), x: cx, y: cy, r0: 18, color: isLight ? '#059669' : '#10b981' });
1725
+ setTimeout(() => setClickRipple(prev => prev && prev.x === cx && prev.y === cy ? null : prev), 600);
1726
+ }}
1727
+ >
1728
+ {/* Round 43 / Loop: hub `<title>` summary — hovering the
1729
+ central glow now answers "what is this?" with a one-line
1730
+ fleet snapshot. Duplicates the header chips by design;
1731
+ hovering the most visually prominent element is the
1732
+ most natural impulse, so satisfy it where the cursor
1733
+ already is. Falls clean: omits sub-clauses when their
1734
+ counts are zero. R52 appends a "click to fit view" hint.
1735
+ */}
1736
+ <title>{(() => {
1737
+ const total = sessions.length;
1738
+ const parts = [`Network hub`, `${total} session${total === 1 ? '' : 's'}`];
1739
+ if (onlineNodes.length > 0) parts.push(`${onlineNodes.length} online`);
1740
+ if (workingCount > 0) parts.push(`${workingCount} working`);
1741
+ if (flowLinks.length > 0) parts.push(`${flowLinks.length} active link${flowLinks.length === 1 ? '' : 's'}`);
1742
+ return parts.join(' · ') + '\nclick to fit view';
1743
+ })()}</title>
1744
+ {/* grounding halo — now breathes in opacity, no expansion */}
965
1745
  <circle
966
1746
  cx={cx} cy={cy} r="18"
967
1747
  fill={isLight ? '#d1fae5' : '#10b981'}
968
- opacity={isLight ? 0.35 : 0.10}
969
- />
970
- {/* outer pulse 1 */}
971
- <circle cx={cx} cy={cy} r="10" fill="none" stroke="#10b981" strokeWidth="1.5">
972
- <animate attributeName="r" values="10;38;10" dur="2.4s" repeatCount="indefinite" />
973
- <animate attributeName="opacity" values="0.55;0;0.55" dur="2.4s" repeatCount="indefinite" />
974
- </circle>
975
- {/* outer pulse 2 (delayed 1.2s) */}
976
- <circle cx={cx} cy={cy} r="10" fill="none" stroke="#10b981" strokeWidth="1.5" opacity="0">
977
- <animate attributeName="r" values="10;38;10" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
978
- <animate attributeName="opacity" values="0.45;0;0.45" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
1748
+ opacity={isLight ? 0.42 : 0.12}
1749
+ >
1750
+ {!reducedMotion && (
1751
+ <animate
1752
+ attributeName="opacity"
1753
+ values={isLight ? '0.32;0.52;0.32' : '0.08;0.16;0.08'}
1754
+ dur="4s"
1755
+ repeatCount="indefinite"
1756
+ />
1757
+ )}
979
1758
  </circle>
980
1759
  {/* core — 20px diameter, larger inner highlight reads as a "lit lamp" */}
981
1760
  <circle cx={cx} cy={cy} r="10" fill={isLight ? '#059669' : '#10b981'} />
@@ -983,7 +1762,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
983
1762
  </g>)}
984
1763
 
985
1764
  {/* agent nodes */}
986
- {[...onlineNodes, ...offlineNodes].map(session => {
1765
+ {[...onlineNodes, ...offlineNodes].map((session, nodeIdx) => {
987
1766
  const pos = nodePositions[session.alias];
988
1767
  if (!pos) return null;
989
1768
 
@@ -991,7 +1770,9 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
991
1770
  const isOnline = session.status !== 'offline' || !!sseCountFor;
992
1771
  const status = nodeStatus(session, isOnline, isLight);
993
1772
  const isActive = activeAliases.has(session.alias);
994
- const radius = isOnline ? 26 : 18;
1773
+ // #113: node size scales with the S/M/L control; halos, labels,
1774
+ // badge and avatar all derive from `radius` so they follow.
1775
+ const radius = Math.round((isOnline ? 26 : 18) * nodeScale);
995
1776
  // Round 109/110 (Vincent 4582 + 4583 P0): at high node counts
996
1777
  // the 100px opaque label cards overlapped each other and
997
1778
  // covered neighbouring avatars. But hiding text entirely went
@@ -1001,12 +1782,82 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1001
1782
  // occlude an avatar), and the full card appears only for the
1002
1783
  // hovered node or once zoomed in past 1.4×.
1003
1784
  const showFullLabel = !denseLayout || hoveredAlias === session.alias || view.zoom >= 1.4;
1785
+ // Round 8: when a node in another group is hovered, fade this
1786
+ // one. Same-group nodes (incl. singletons matching the hovered
1787
+ // alias) stay full. Pure visual focus, geometry unchanged.
1788
+ const inFocus = !activeGroup || (groupKeys[session.alias] ?? session.alias) === activeGroup;
1004
1789
 
1005
1790
  return (
1006
1791
  <g
1007
1792
  key={session.alias}
1008
- className="transition-opacity"
1009
- style={{ cursor: 'pointer' }}
1793
+ data-node={session.alias}
1794
+ // Round 3 / Loop: `anet-fade-in` runs once when the <g>
1795
+ // mounts — a new session entering the fleet (or the topology
1796
+ // first rendering) eases in instead of popping. Re-renders of
1797
+ // an existing node don't re-trigger (React preserves the <g>
1798
+ // via the alias key), so status changes don't flicker. The
1799
+ // global prefers-reduced-motion sweep already neutralises it.
1800
+ className="group transition-opacity anet-fade-in"
1801
+ style={{
1802
+ cursor: 'pointer',
1803
+ // Round 17 / Loop: offline nodes drop to 0.6 at rest so
1804
+ // online nodes pop without losing the offline-as-ghost
1805
+ // information. The dashed stroke + smaller radius already
1806
+ // say "offline"; dimming the whole group strengthens the
1807
+ // online-vs-offline hierarchy at first glance. Exempt the
1808
+ // chat-focused node — if the user explicitly opened a
1809
+ // popover targeting an offline alias, that node stays
1810
+ // full-brightness so the focus ring + popover read as one
1811
+ // selected thing rather than a dimmed selection. Group-
1812
+ // hover fade (Round 8) still wins when inFocus is false.
1813
+ // Round 49 / Loop: edge-hover endpoint highlight composes
1814
+ // OVER the inFocus/online formula — a non-endpoint node
1815
+ // dims to 0.28 (just below the inFocus 0.32 to read as a
1816
+ // stronger "not relevant" signal), endpoints keep their
1817
+ // base opacity. chatAlias still wins to keep the focus
1818
+ // ring legible if the user clicked through.
1819
+ // Round 55 / Loop: legend-status hover composes on the
1820
+ // SAME level as edge-hover-endpoint. Non-matching nodes
1821
+ // dim to 0.28; matching nodes stay at their base.
1822
+ // activeStatus matches: working = status==='working',
1823
+ // idle = online but not working, offline = !isOnline.
1824
+ // Round 60 / Loop: activeStatus = hoveredStatus ?? pinnedStatus
1825
+ // so the pressure-bar segment pins (R60) and the legend
1826
+ // row hover (R55) feed the same branch.
1827
+ opacity: hoveredEdgeEndpoints && !hoveredEdgeEndpoints.has(session.alias) && chatAlias !== session.alias
1828
+ ? 0.28
1829
+ : activeStatus && chatAlias !== session.alias && !(
1830
+ activeStatus === 'working' ? session.status === 'working'
1831
+ : activeStatus === 'idle' ? (isOnline && session.status !== 'working')
1832
+ : /* offline */ !isOnline
1833
+ )
1834
+ ? 0.28
1835
+ : !inFocus
1836
+ ? 0.32
1837
+ : chatAlias === session.alias
1838
+ ? 1
1839
+ : isOnline ? 1 : 0.6,
1840
+ // Round 9 / Loop: stagger the anet-fade-in so the topology
1841
+ // reveals as a wave on first paint instead of one big pop.
1842
+ // Cap at 24 indices (≈600ms tail) so 50-node fleets still
1843
+ // finish revealing within a beat. CSS animation-delay only
1844
+ // applies during the keyframe — re-renders without a new
1845
+ // mount (same alias key) don't replay, so status changes
1846
+ // never trigger the stagger again.
1847
+ animationDelay: `${Math.min(nodeIdx, 24) * 25}ms`,
1848
+ // Round 51 / Loop: hover micro-lift. The label already
1849
+ // lifts on group-hover (R26). The node body — circle,
1850
+ // avatar, status ring — stays planted, so the gesture
1851
+ // reads "label moves, body doesn't". Now the whole <g>
1852
+ // translates -2px when hovered: avatar + ring + label
1853
+ // move as one unit. 2 px is well inside the inter-row
1854
+ // gap (cellH headroom ≥22), and CSS transform on SVG
1855
+ // <g> never affects the overlap-test geometry (the
1856
+ // test never simulates hover). useReducedMotion drops
1857
+ // the lift to 0 for prefers-reduced-motion users.
1858
+ transform: !reducedMotion && hoveredAlias === session.alias ? 'translateY(-2px)' : undefined,
1859
+ transition: 'transform 180ms cubic-bezier(0.4,0,0.2,1)',
1860
+ }}
1010
1861
  // Stop the pointerdown from reaching the SVG pan handler: the
1011
1862
  // SVG calls setPointerCapture, and a captured pointer makes
1012
1863
  // Chromium fire the follow-up `click` on the SVG instead of
@@ -1014,21 +1865,106 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1014
1865
  // Side effect (intended): you pan from empty canvas, not by
1015
1866
  // grabbing a node.
1016
1867
  onPointerDown={(e) => e.stopPropagation()}
1017
- onPointerEnter={() => denseLayout && setHoveredAlias(session.alias)}
1018
- onPointerLeave={() => denseLayout && setHoveredAlias(prev => (prev === session.alias ? null : prev))}
1019
- onClick={() => setChatAlias(session.alias)}
1868
+ // Track hover globally (not just under denseLayout) so the
1869
+ // Round 8 group-focus fade works at any fleet size.
1870
+ onPointerEnter={() => setHoveredAlias(session.alias)}
1871
+ onPointerLeave={() => setHoveredAlias(prev => (prev === session.alias ? null : prev))}
1872
+ onClick={() => {
1873
+ setChatAlias(session.alias);
1874
+ // Round 14 ripple — capture position, radius and status
1875
+ // colour at click time so the one-shot circle is self-
1876
+ // contained (no re-render-time recomputation needed).
1877
+ setClickRipple({
1878
+ ts: Date.now(),
1879
+ x: pos.x, y: pos.y, r0: radius,
1880
+ color: status.primary,
1881
+ });
1882
+ setTimeout(() => setClickRipple(prev =>
1883
+ prev && Date.now() - prev.ts >= 590 ? null : prev), 600);
1884
+ }}
1020
1885
  >
1021
1886
  {/* Issue #96: native hover tooltip — "Vendor · model · Runtime".
1022
1887
  Falls back to just the alias when the node reports no
1023
- model/runtime. */}
1024
- <title>{identityLine(session.model, session.runtime) || session.alias}</title>
1025
- {isActive && (
1888
+ model/runtime.
1889
+ Round 33 / Loop: cwd line answers "what is this agent on?".
1890
+ Round 34 / Loop: for offline nodes, append "last seen: 6m ago"
1891
+ so the operator knows whether to wait or chase. Online nodes
1892
+ skip the line (Round 27's 1 h ghost age-out means anything
1893
+ still online has heartbeated recently — the line would just
1894
+ be noise). Accepts both ISO ("…T06:00:28Z") and SQL-style
1895
+ ("… 06:00:28" assumed UTC) formats. */}
1896
+ {(() => {
1897
+ // Round 35 / Loop: TZ-safe parsing via the shared lib helper
1898
+ // — same parseHubTime is used by isGhost above (Round 38
1899
+ // factored it to app/lib/time.ts so both paths interpret SQL
1900
+ // bare timestamps as UTC on every browser).
1901
+ const lastSeen = !isOnline && session.last_seen_at ? relativeAgo(session.last_seen_at) : null;
1902
+ return (
1903
+ <title>{[
1904
+ identityLine(session.model, session.runtime) || session.alias,
1905
+ session.project_dir ? `cwd: ${session.project_dir}` : null,
1906
+ lastSeen ? `last seen: ${lastSeen}` : null,
1907
+ ].filter(Boolean).join('\n')}</title>
1908
+ );
1909
+ })()}
1910
+ {/* Round 2 / Loop: hover ring — a thin outer stroke that fades
1911
+ in when the cursor enters the node, signalling clickability
1912
+ (real-user feedback for the chat-popover open). Pure CSS via
1913
+ Tailwind group-hover, so it costs nothing per frame and
1914
+ respects prefers-reduced-motion via the global media query. */}
1915
+ <circle
1916
+ cx={pos.x}
1917
+ cy={pos.y}
1918
+ r={radius + 12}
1919
+ fill="none"
1920
+ stroke={status.primary}
1921
+ // strokeWidth must NOT be 1.5 (offline status ring) or 3
1922
+ // (online status ring) — the overlap test selects by those
1923
+ // exact widths and would mis-count this invisible hover
1924
+ // ring as a node footprint.
1925
+ strokeWidth="2"
1926
+ className="opacity-0 group-hover:opacity-70 transition-opacity duration-150"
1927
+ style={{ pointerEvents: 'none' }}
1928
+ />
1929
+ {/* Round 11 / Loop: chat-focus ring — when the ChatPopover is
1930
+ open targeting this node, anchor a persistent ring around
1931
+ it so the floating popover visibly links back to its source
1932
+ node. Static (not pulsing) so it reads as "selected state"
1933
+ rather than "this node is active". strokeWidth=2.5 stays
1934
+ clear of the overlap-test selectors (1.5 / 3). Sits just
1935
+ outside the halo radius+8 so it never overlaps a neighbour
1936
+ (halos already pack flush in dense grids). */}
1937
+ {chatAlias === session.alias && (
1938
+ <circle
1939
+ cx={pos.x}
1940
+ cy={pos.y}
1941
+ r={radius + 14}
1942
+ fill="none"
1943
+ stroke={status.primary}
1944
+ strokeWidth="2.5"
1945
+ opacity={isLight ? 0.85 : 0.95}
1946
+ filter={!isLight ? 'url(#topo-glow)' : undefined}
1947
+ className="transition-opacity duration-200"
1948
+ style={{ pointerEvents: 'none' }}
1949
+ />
1950
+ )}
1951
+ {isActive && !reducedMotion && (
1026
1952
  <circle cx={pos.x} cy={pos.y} r={radius + 14} fill={status.primary} opacity={isLight ? 0.08 : 0.12}>
1027
1953
  <animate attributeName="r" values={`${radius + 8};${radius + 22};${radius + 8}`} dur="2.4s" repeatCount="indefinite" />
1028
1954
  <animate attributeName="opacity" values={isLight ? '0.12;0.02;0.12' : '0.18;0.04;0.18'} dur="2.4s" repeatCount="indefinite" />
1029
1955
  </circle>
1030
1956
  )}
1031
- <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)} />
1957
+ {/* Round 4 / Loop: transition-[fill,stroke,opacity] smooths
1958
+ status colour changes so idle↔working↔offline doesn't snap
1959
+ — task replies / node-rename / SSE updates ease in. */}
1960
+ <circle
1961
+ cx={pos.x}
1962
+ cy={pos.y}
1963
+ r={radius + 8}
1964
+ fill={status.halo}
1965
+ opacity={isOnline ? (isLight ? 0.85 : 0.65) : (isLight ? 0.4 : 0.25)}
1966
+ className="transition-[fill,opacity] duration-300 ease-out"
1967
+ />
1032
1968
  <circle
1033
1969
  cx={pos.x}
1034
1970
  cy={pos.y}
@@ -1038,6 +1974,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1038
1974
  strokeWidth={isOnline ? 3 : 1.5}
1039
1975
  strokeDasharray={isOnline ? 'none' : '5 5'}
1040
1976
  filter={isOnline && !isLight ? 'url(#topo-glow)' : undefined}
1977
+ className="transition-[fill,stroke] duration-300 ease-out"
1041
1978
  />
1042
1979
  {/* Issue #96: node "avatar" is now driven by the model
1043
1980
  vendor. Decision order:
@@ -1050,7 +1987,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1050
1987
  4. unknown vendor / null model → the prefix-group
1051
1988
  hue-hashed initial (#83/#99 behaviour, unchanged). */}
1052
1989
  {(() => {
1053
- const ar = isOnline ? 14 : 10;
1990
+ const ar = Math.round((isOnline ? 14 : 10) * nodeScale);
1054
1991
  const size = radius * 2;
1055
1992
  const vendor = vendorForModel(session.model);
1056
1993
  const internByAlias = /书生|书小生|intern/i.test(session.alias);
@@ -1075,7 +2012,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1075
2012
  <circle cx={pos.x} cy={pos.y} r={ar} fill={vendor.mono.bg} stroke={vendor.mono.ring} strokeWidth="1" />
1076
2013
  <text
1077
2014
  x={pos.x} y={pos.y} dy="0.34em" textAnchor="middle"
1078
- fill={vendor.mono.text} fontSize={isOnline ? 14 : 10}
2015
+ fill={vendor.mono.text} fontSize={ar}
1079
2016
  fontFamily="monospace" fontWeight="700"
1080
2017
  >
1081
2018
  {vendor.initial}
@@ -1095,7 +2032,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1095
2032
  dy="0.34em"
1096
2033
  textAnchor="middle"
1097
2034
  fill={c.text}
1098
- fontSize={isOnline ? 14 : 10}
2035
+ fontSize={ar}
1099
2036
  fontFamily="monospace"
1100
2037
  fontWeight="700"
1101
2038
  >
@@ -1124,78 +2061,314 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1124
2061
  </g>
1125
2062
  );
1126
2063
  })()}
1127
- {session.status === 'working' && (
1128
- // working pulse small dot just inside the status ring,
1129
- // above the avatar text. Was a 18px horizontal bar at
1130
- // y+11 which now collides with the avatar; the dot reads
1131
- // as the same "active" cue without occluding the initial.
1132
- <circle cx={pos.x} cy={pos.y - (isOnline ? 18 : 13)} r="2.5" fill={pal.flowParticle}>
1133
- <animate attributeName="opacity" values="1;0.25;1" dur="1.1s" repeatCount="indefinite" />
1134
- </circle>
1135
- )}
2064
+ {session.status === 'working' && (() => {
2065
+ // Round 24 / Loop: pulse rate traffic. The dot's dur
2066
+ // was a flat 1.1s every working node looked equally
2067
+ // busy. Mapping it to sse:N lets the eye land on what's
2068
+ // actually hot. 3 discrete tiers read cleaner than
2069
+ // continuous easing at a glance; thresholds picked so
2070
+ // typical 1-2-SSE Claude/Codex sessions stay in the
2071
+ // "active" tier and only multi-pane orchestrators pop
2072
+ // into the "busy" tier. Geometry unchanged (r=2.5).
2073
+ const sse = sseCountFor ?? 0;
2074
+ const dur = sse >= 4 ? '0.7s' : sse >= 2 ? '0.9s' : '1.2s';
2075
+ return (
2076
+ <circle cx={pos.x} cy={pos.y - (radius - 6)} r="2.5" fill={pal.flowParticle} data-pulse-dur={dur} opacity={reducedMotion ? 0.6 : undefined}>
2077
+ {!reducedMotion && (
2078
+ <animate attributeName="opacity" values="1;0.25;1" dur={dur} repeatCount="indefinite" />
2079
+ )}
2080
+ </circle>
2081
+ );
2082
+ })()}
1136
2083
 
1137
2084
  {/* Round 98 (issue #61): label rect 124px → 100px.
1138
2085
  Round 109/110 (Vincent P0): full opaque card below the
1139
2086
  density threshold / on hover / when zoomed; otherwise a
1140
2087
  lightweight plain-text alias that keeps every node
1141
- labelled without an opaque box covering its neighbours. */}
1142
- {showFullLabel ? (
1143
- <g transform={`translate(${pos.x}, ${pos.y + radius + 22})`} style={{ pointerEvents: 'none' }}>
1144
- <rect x="-50" y="-14" width="100" height="42" rx="6" fill={pal.labelBox.fill} stroke={pal.labelBox.stroke} opacity={isLight ? 1 : 0.94} />
1145
- <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize="12" fontFamily="monospace" fontWeight="700">
1146
- {truncate(session.alias, 12)}
1147
- </text>
1148
- <text x="0" y="17" textAnchor="middle" fill={status.primary} fontSize="9" fontFamily="monospace">
1149
- {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
2088
+ labelled without an opaque box covering its neighbours.
2089
+ Round 15 / Loop: when nodeScale=S the node shrinks 30%
2090
+ but the label card was staying full-size, so the label
2091
+ visually outweighed the small node. Tighten the card
2092
+ frame, alias / sub fontSize, drop-offset and truncate
2093
+ length specifically for S; M and L keep their existing
2094
+ sizes (M ≈ L for labels by design — the S user is the
2095
+ one who explicitly asked for a denser view). */}
2096
+ {(() => {
2097
+ const isSmall = nodeScale < 0.8;
2098
+ const cardW = isSmall ? 88 : 100;
2099
+ const cardH = isSmall ? 36 : 42;
2100
+ const cardTopY = isSmall ? -12 : -14;
2101
+ const aliasFs = isSmall ? 11 : 12;
2102
+ const subFs = isSmall ? 8 : 9;
2103
+ const subY = isSmall ? 15 : 17;
2104
+ const dropY = isSmall ? 18 : 22;
2105
+ const fullMax = isSmall ? 11 : 12;
2106
+ const denseFs = isSmall ? 9 : 10;
2107
+ const denseDrop = isSmall ? 12 : 14;
2108
+ // Round 26 / Loop: micro-lift the label group on hover —
2109
+ // 1.5 px upward, 200 ms ease. Pairs with the Round 18
2110
+ // group-box hover-accent treatment to give the same
2111
+ // "this is the focused element" feedback at the per-
2112
+ // node level. CSS transform stacks onto the SVG
2113
+ // positioning transform attribute (SVG 2 cascade);
2114
+ // bbox is unchanged at rest, so the overlap-test gate
2115
+ // continues to see the geometric layout it expects.
2116
+ return showFullLabel ? (
2117
+ <g transform={`translate(${pos.x}, ${pos.y + radius + dropY})`} style={{ pointerEvents: 'none' }}
2118
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]">
2119
+ <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} />
2120
+ <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize={aliasFs} fontFamily="monospace" fontWeight="700">
2121
+ {truncate(session.alias, fullMax)}
2122
+ </text>
2123
+ <text x="0" y={subY} textAnchor="middle" fill={status.primary} fontSize={subFs} fontFamily="monospace">
2124
+ {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
2125
+ </text>
2126
+ </g>
2127
+ ) : (
2128
+ <text
2129
+ x={pos.x}
2130
+ y={pos.y + radius + denseDrop}
2131
+ textAnchor="middle"
2132
+ fill={status.text}
2133
+ fontSize={denseFs}
2134
+ fontFamily="monospace"
2135
+ fontWeight="700"
2136
+ opacity={0.9}
2137
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]"
2138
+ style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
2139
+ stroke={pal.containerBg}
2140
+ strokeWidth="3"
2141
+ >
2142
+ {truncate(session.alias, isSmall ? 9 : 10)}
1150
2143
  </text>
1151
- </g>
1152
- ) : (
1153
- <text
1154
- x={pos.x}
1155
- y={pos.y + radius + 14}
1156
- textAnchor="middle"
1157
- fill={status.text}
1158
- fontSize="10"
1159
- fontFamily="monospace"
1160
- fontWeight="700"
1161
- opacity={0.9}
1162
- style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
1163
- stroke={pal.containerBg}
1164
- strokeWidth="3"
1165
- >
1166
- {truncate(session.alias, 10)}
1167
- </text>
1168
- )}
2144
+ );
2145
+ })()}
1169
2146
  </g>
1170
2147
  );
1171
2148
  })}
1172
2149
 
2150
+ {/* Round 14 / Loop: click ripple — one-shot expanding ring from
2151
+ the clicked node, ~500ms, status-coloured. Sits inside the
2152
+ zoom/pan <g> so it scales / pans with the topology. Keyed by
2153
+ ts so a re-click on any node (same or different) remounts the
2154
+ <circle> and the SMIL <animate> elements replay from t=0.
2155
+ strokeWidth=2 doesn't match the overlap-test selectors. */}
2156
+ {clickRipple && !reducedMotion && (
2157
+ <circle
2158
+ key={clickRipple.ts}
2159
+ cx={clickRipple.x}
2160
+ cy={clickRipple.y}
2161
+ r={clickRipple.r0 + 4}
2162
+ fill="none"
2163
+ stroke={clickRipple.color}
2164
+ strokeWidth="2"
2165
+ opacity="0"
2166
+ style={{ pointerEvents: 'none' }}
2167
+ >
2168
+ <animate attributeName="r" values={`${clickRipple.r0 + 4};${clickRipple.r0 + 30}`} dur="0.5s" fill="freeze" />
2169
+ <animate attributeName="opacity" values="0.7;0" dur="0.5s" fill="freeze" />
2170
+ </circle>
2171
+ )}
2172
+
2173
+ </g>
2174
+
2175
+ {/* #112: overlay panels (recent-signal + legend) render OUTSIDE the
2176
+ zoom/pan <g> so they stay fixed while the topology pans/zooms.
2177
+ They're sized + tucked into the top corners so every corner of
2178
+ each panel is >325px from the canvas centre — i.e. fully outside
2179
+ the outermost (offline) ring. No node on any ring can reach the
2180
+ corner triangles, so the panels never overlap a node, in ring
2181
+ OR grid layout (Vincent 4727 zero-overlap criterion). */}
1173
2182
  {/* latest flow labels */}
1174
- <g transform="translate(28, 34)">
1175
- <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} />
1176
- <text x="16" y="26" fill={pal.legendHeadline} fontSize="13" fontFamily="monospace" fontWeight="700">recent signal</text>
1177
- <text x="178" y="26" fill={pal.legendAccent} fontSize="11" fontFamily="monospace">{messages.length} messages</text>
1178
- {flowLinks.slice(0, 4).map((link, index) => (
1179
- <text key={link.key} x="16" y={54 + index * 20} fill={pal.legendText} fontSize="10" fontFamily="monospace">
1180
- {truncate(link.from, 8)} {'->'} {truncate(link.to, 8)} / {link.count} / {truncate(link.content, 18)}
2183
+ {/* Round 57 / Loop: drop-shadow on the panel rects gives them
2184
+ card-like elevation, especially on light theme where the
2185
+ near-white fill on a near-white canvas read as pasted-on.
2186
+ data-topo-panel-elevation tag so the test can verify both
2187
+ panels carry the filter. The filter is on the rect, not
2188
+ the parent <g>, so it doesn't shadow the rows + text inside
2189
+ only the panel chrome lifts. */}
2190
+ <g transform="translate(16, 16)">
2191
+ <rect
2192
+ x="0" y="0" width="230" height="84" rx="10"
2193
+ fill={pal.legendBox.fill}
2194
+ stroke={pal.legendBox.stroke}
2195
+ opacity={isLight ? 0.97 : 0.92}
2196
+ 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))' }}
2197
+ data-topo-panel-elevation="recent"
2198
+ />
2199
+ <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700">recent signal</text>
2200
+ <text x="150" y="21" fill={pal.legendAccent} fontSize="10" fontFamily="monospace">{messages.length} msgs</text>
2201
+ {/* Round 45 / Loop: empty state. The panel used to render
2202
+ "recent signal" + "0 msgs" with three blank slots below
2203
+ when no flow yet — read as "broken" rather than "quiet".
2204
+ A muted centred placeholder makes the empty state
2205
+ deliberate. Messages count CAN diverge from flowLinks
2206
+ count (raw count vs. deduped pairs), so the placeholder
2207
+ fires on flowLinks.length=0 specifically. */}
2208
+ {flowLinks.length === 0 ? (
2209
+ <text x="115" y="60" textAnchor="middle" fill={pal.legendText} fontSize="10" fontFamily="monospace" fontStyle="italic" opacity={0.65} data-recent-signal-empty>
2210
+ no flow yet
1181
2211
  </text>
1182
- ))}
2212
+ ) : (
2213
+ // Round 56 / Loop: each row is a navigator into the canvas.
2214
+ // Hover a row → set hoveredEdgeKey, which the existing R50
2215
+ // edge-focus + R49 endpoint-highlight ladders consume. The
2216
+ // matching flow edge brightens to 2× + thickens, its two
2217
+ // endpoint nodes stay full opacity, and every other edge +
2218
+ // non-endpoint node dims. Released → all restore. Wrapping
2219
+ // <g> + a transparent 218×14 hitbox so the cursor doesn't
2220
+ // have to land precisely on the truncated text.
2221
+ flowLinks.slice(0, 3).map((link, index) => (
2222
+ <g
2223
+ key={link.key}
2224
+ data-recent-row={link.key}
2225
+ style={{ cursor: 'pointer' }}
2226
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
2227
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
2228
+ >
2229
+ <rect x="6" y={38 + index * 16 - 10} width="218" height="14" fill="transparent" />
2230
+ <text
2231
+ x="13" y={38 + index * 16}
2232
+ fill={hoveredEdgeKey === link.key ? pal.legendHeadline : pal.legendText}
2233
+ fontSize="9"
2234
+ fontFamily="monospace"
2235
+ style={{ transition: 'fill 150ms ease-out' }}
2236
+ >
2237
+ {truncate(link.from, 6)} {'->'} {truncate(link.to, 6)} / {link.count} / {truncate(link.content, 12)}
2238
+ </text>
2239
+ </g>
2240
+ ))
2241
+ )}
1183
2242
  </g>
1184
2243
 
1185
- {/* legend */}
1186
- <g transform="translate(720, 34)">
1187
- <rect x="0" y="0" width="250" height="112" rx="8" fill={pal.legendBox.fill} stroke={pal.legendBox.stroke} opacity={isLight ? 1 : 0.94} />
1188
- <circle cx="18" cy="26" r="6" fill={isLight ? '#059669' : '#22c55e'} />
1189
- <text x="34" y="30" fill={pal.legendText} fontSize="11" fontFamily="monospace">working node</text>
1190
- <circle cx="18" cy="52" r="6" fill={isLight ? '#0d9488' : '#2dd4bf'} />
1191
- <text x="34" y="56" fill={pal.legendText} fontSize="11" fontFamily="monospace">online idle</text>
1192
- <circle cx="18" cy="78" r="6" fill={isLight ? '#94a3b8' : '#6b7280'} />
1193
- <text x="34" y="82" fill={pal.legendText} fontSize="11" fontFamily="monospace">offline / no SSE</text>
1194
- <path d="M150,78 Q176,52 210,78" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" />
1195
- </g>
2244
+ {/* legend — Round 55 / Loop: each status row is now a hover
2245
+ target. Pointer enter sets `hoveredStatus`; pointer leave
2246
+ clears it. Node opacity formula composes the match below.
2247
+ The row text brightens to legendHeadline while hovered as
2248
+ a small affordance hint. Geometry unchanged — the new
2249
+ <g> wrappers only carry pointer handlers. */}
2250
+ <g transform="translate(760, 16)">
2251
+ {/* R57: matching drop-shadow elevation to the legend panel. */}
2252
+ <rect
2253
+ x="0" y="0" width="224" height="96" rx="10"
2254
+ fill={pal.legendBox.fill}
2255
+ stroke={pal.legendBox.stroke}
2256
+ opacity={isLight ? 0.97 : 0.92}
2257
+ 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))' }}
2258
+ data-topo-panel-elevation="legend"
2259
+ />
2260
+ {([
2261
+ { key: 'working' as const, y0: 24, y1: 28, fill: isLight ? '#059669' : '#22c55e', label: 'working node' },
2262
+ { key: 'idle' as const, y0: 48, y1: 52, fill: isLight ? '#0d9488' : '#2dd4bf', label: 'online idle' },
2263
+ { key: 'offline' as const, y0: 72, y1: 76, fill: isLight ? '#94a3b8' : '#6b7280', label: 'offline / no SSE' },
2264
+ ]).map(row => {
2265
+ // Round 61 / Loop: legend rows pin too — symmetric with the
2266
+ // R60 pressure-bar segments. R55 hover stays transient; the
2267
+ // new onClick toggles pinnedStatus so users can lock a
2268
+ // filter without holding the cursor still. Pinned row gets
2269
+ // an inset ring on the swatch (same vocab as R60).
2270
+ const isPinned = pinnedStatus === row.key;
2271
+ return (
2272
+ <g
2273
+ key={row.key}
2274
+ data-legend-status={row.key}
2275
+ role="button"
2276
+ tabIndex={0}
2277
+ aria-pressed={isPinned}
2278
+ style={{ cursor: 'pointer' }}
2279
+ // R61: stopPropagation on pointerdown so the SVG-level
2280
+ // pan handler (R103) doesn't setPointerCapture and
2281
+ // redirect the follow-up click away from this <g>.
2282
+ // Same trick the node <g> uses (and R52 hub).
2283
+ onPointerDown={(e) => e.stopPropagation()}
2284
+ onMouseEnter={() => setHoveredStatus(row.key)}
2285
+ onMouseLeave={() => setHoveredStatus(prev => prev === row.key ? null : prev)}
2286
+ onClick={() => setPinnedStatus(prev => prev === row.key ? null : row.key)}
2287
+ >
2288
+ {/* invisible hitbox covers the row so cursor doesn't need
2289
+ to be exactly on the 5-px swatch */}
2290
+ <rect x="6" y={row.y0 - 12} width="170" height="22" fill="transparent" />
2291
+ <circle cx="16" cy={row.y0} r="5.5" fill={row.fill} />
2292
+ {/* R61 pinned-state ring — concentric stroke at r=8 in
2293
+ the row colour, draws OUTSIDE the swatch so it
2294
+ doesn't fight the fill colour the user is matching. */}
2295
+ {isPinned && (
2296
+ <circle cx="16" cy={row.y0} r="8" fill="none" stroke={row.fill} strokeWidth="1.5" />
2297
+ )}
2298
+ <text
2299
+ x="30" y={row.y1}
2300
+ fill={hoveredStatus === row.key || isPinned ? pal.legendHeadline : pal.legendText}
2301
+ fontSize="11"
2302
+ fontFamily="monospace"
2303
+ style={{ transition: 'fill 150ms ease-out' }}
2304
+ >{row.label}</text>
2305
+ </g>
2306
+ );
2307
+ })}
2308
+ {/* Flow-arrow swatch sits at y=72 — same row as the offline
2309
+ hitbox. Drop its pointerEvents so the offline legend row
2310
+ stays hoverable (R55). It's decoration, no need to
2311
+ receive events. */}
2312
+ <path d="M140,72 Q164,48 196,72" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" style={{ pointerEvents: 'none' }} />
1196
2313
  </g>
1197
2314
  </svg>
1198
2315
 
2316
+ {/* Round 30 / Loop: minimap. Big fleets in fullscreen mode at high
2317
+ zoom let users lose their position — the minimap shows the
2318
+ whole topology miniaturised plus a viewport rectangle so the
2319
+ user always knows where they are. Click anywhere to recenter
2320
+ the canvas there. Only mounted when the view is non-default
2321
+ (zoomed or panned) since at 1× centered the minimap and the
2322
+ canvas show the same thing. HTML overlay so it stays fixed
2323
+ while the SVG transforms. */}
2324
+ {(() => {
2325
+ const isDefaultView = Math.abs(view.zoom - 1) < 0.01 && Math.abs(view.x) < 1 && Math.abs(view.y) < 1;
2326
+ if (isDefaultView || (onlineNodes.length + offlineNodes.length) === 0) return null;
2327
+ const MW = 120, MH = 82;
2328
+ const sx = MW / VIEWBOX_W, sy = MH / VIEWBOX_H;
2329
+ const rectX = (-view.x / view.zoom) * sx;
2330
+ const rectY = (-view.y / view.zoom) * sy;
2331
+ const rectW = (VIEWBOX_W / view.zoom) * sx;
2332
+ const rectH = (VIEWBOX_H / view.zoom) * sy;
2333
+ return (
2334
+ <div
2335
+ className="absolute right-3 rounded-md border shadow-lg shadow-black/30 overflow-hidden anet-fade-in"
2336
+ style={{ bottom: 56, background: pal.legendBox.fill, borderColor: pal.containerBorder, cursor: 'crosshair' }}
2337
+ role="img"
2338
+ aria-label="Topology minimap — click to recenter"
2339
+ title="Minimap · click to recenter"
2340
+ onClick={(e) => {
2341
+ const r = e.currentTarget.getBoundingClientRect();
2342
+ const fx = (e.clientX - r.left) / r.width;
2343
+ const fy = (e.clientY - r.top) / r.height;
2344
+ setView(prev => ({
2345
+ ...prev,
2346
+ x: VIEWBOX_W / 2 - fx * VIEWBOX_W * prev.zoom,
2347
+ y: VIEWBOX_H / 2 - fy * VIEWBOX_H * prev.zoom,
2348
+ }));
2349
+ }}
2350
+ data-topo-minimap
2351
+ >
2352
+ <svg width={MW} height={MH} viewBox={`0 0 ${MW} ${MH}`} style={{ display: 'block' }}>
2353
+ {[...onlineNodes, ...offlineNodes].map(s => {
2354
+ const p = nodePositions[s.alias];
2355
+ if (!p) return null;
2356
+ const sseN = (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
2357
+ const isOn = s.status !== 'offline' || !!sseN;
2358
+ const st = nodeStatus(s, isOn, isLight);
2359
+ 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} />;
2360
+ })}
2361
+ {/* viewport rectangle */}
2362
+ <rect
2363
+ x={Math.max(0, rectX)} y={Math.max(0, rectY)}
2364
+ width={Math.max(0, Math.min(MW - Math.max(0, rectX), rectW))}
2365
+ height={Math.max(0, Math.min(MH - Math.max(0, rectY), rectH))}
2366
+ fill="none" stroke={pal.legendAccent} strokeWidth="1" opacity="0.9"
2367
+ />
2368
+ </svg>
2369
+ </div>
2370
+ );
2371
+ })()}
1199
2372
  {/* Round 103 (issue #81): zoom / pan / fullscreen controls — HTML
1200
2373
  overlay so they stay fixed while the SVG content transforms.
1201
2374
  Round 104: Vincent 实测 — the reset action used to be hidden
@@ -1203,6 +2376,26 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1203
2376
  Split into a plain % readout + an explicit reset button with
1204
2377
  its own icon + tooltip. */}
1205
2378
  <div className="absolute bottom-3 right-3 flex items-center gap-1.5 text-xs select-none">
2379
+ {/* #113: node size — S / M / L segmented control (Vincent 4727). */}
2380
+ <div
2381
+ className="flex items-center rounded-md border overflow-hidden"
2382
+ style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder }}
2383
+ role="group"
2384
+ aria-label="Node size"
2385
+ >
2386
+ {([['S', 0.7], ['M', 0.84], ['L', 1]] as const).map(([lbl, v], idx) => (
2387
+ <button
2388
+ key={lbl}
2389
+ onClick={() => pickNodeScale(v)}
2390
+ aria-pressed={nodeScale === v}
2391
+ title={`Node size: ${lbl === 'S' ? 'small' : lbl === 'M' ? 'medium' : 'large'}`}
2392
+ className={`px-2 py-1 transition-colors ${idx > 0 ? 'border-l' : ''} ${nodeScale === v ? 'bg-cyan-500/15 text-cyan-300' : 'hover:bg-white/5'}`}
2393
+ style={{ color: nodeScale === v ? undefined : pal.legendText, borderColor: pal.containerBorder }}
2394
+ >
2395
+ {lbl}
2396
+ </button>
2397
+ ))}
2398
+ </div>
1206
2399
  <div
1207
2400
  className="flex items-center rounded-md border overflow-hidden"
1208
2401
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder }}
@@ -1212,6 +2405,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1212
2405
  className="px-2 py-1 hover:bg-white/5 transition-colors"
1213
2406
  style={{ color: pal.legendText }}
1214
2407
  aria-label="Zoom out"
2408
+ title="Zoom out (−)"
1215
2409
  >
1216
2410
  <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>
1217
2411
  </button>
@@ -1227,6 +2421,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1227
2421
  className="px-2 py-1 hover:bg-white/5 transition-colors"
1228
2422
  style={{ color: pal.legendText }}
1229
2423
  aria-label="Zoom in"
2424
+ title="Zoom in (+)"
1230
2425
  >
1231
2426
  <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>
1232
2427
  </button>
@@ -1236,7 +2431,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1236
2431
  className="p-1.5 rounded-md border hover:bg-white/5 transition-colors"
1237
2432
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
1238
2433
  aria-label="Reset view"
1239
- title="Reset zoom + pan (or double-click the canvas)"
2434
+ title="Reset zoom + pan (0, or double-click the canvas)"
1240
2435
  >
1241
2436
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1242
2437
  <path d="M3 12a9 9 0 1 0 9-9 9 9 0 0 0-6.4 2.6L3 8" />