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

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 (864) 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/05oi25hh7b81r.js +31 -0
  510. package/.next/static/chunks/05uk96gc~9mni.js +1 -0
  511. package/.next/static/chunks/07~8sx3_5uiox.js +1 -0
  512. package/.next/static/chunks/08kf-.bgs10ze.js +1 -0
  513. package/.next/static/chunks/{0pqt~8bl3ukh4.js → 0_pxg.4pebw__.js} +2 -2
  514. package/.next/static/chunks/0b8wnt4put-5n.js +2 -0
  515. package/.next/static/chunks/{01xlw8hd842-c.js → 0eov~nkwaf-qa.js} +1 -1
  516. package/.next/static/chunks/0glmrdj9112tc.js +7 -0
  517. package/.next/static/chunks/0ihb4j2px4pj6.js +1 -0
  518. package/.next/static/chunks/0i~_5k19v1-0q.css +2 -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/0rxrez_cjn.eh.js +1 -0
  522. package/.next/static/chunks/0sev.kqx9f.1x.js +2 -0
  523. package/.next/static/chunks/0tvn2l1pc.h65.js +1 -0
  524. package/.next/static/chunks/0u3b93reo9xci.js +1 -0
  525. package/.next/static/chunks/0u7v~vrs7ikg..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 +1423 -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-filter-pill-test.mjs +101 -0
  548. package/scripts/topo-active-links-chip-test.mjs +73 -0
  549. package/scripts/topo-arrow-tier-test.mjs +61 -0
  550. package/scripts/topo-auto-fit-test.mjs +73 -0
  551. package/scripts/topo-chat-focus-ring-test.mjs +77 -0
  552. package/scripts/topo-click-ripple-test.mjs +66 -0
  553. package/scripts/topo-cwd-tooltip-test.mjs +64 -0
  554. package/scripts/topo-dblclick-reset-test.mjs +74 -0
  555. package/scripts/topo-edge-endpoint-highlight-test.mjs +111 -0
  556. package/scripts/topo-edge-focus-test.mjs +113 -0
  557. package/scripts/topo-edge-hover-test.mjs +93 -0
  558. package/scripts/topo-edge-tooltip-test.mjs +70 -0
  559. package/scripts/topo-esc-clears-pin-test.mjs +98 -0
  560. package/scripts/topo-fit-key-test.mjs +106 -0
  561. package/scripts/topo-freshness-chip-test.mjs +74 -0
  562. package/scripts/topo-group-count-test.mjs +68 -0
  563. package/scripts/topo-group-hover-test.mjs +82 -0
  564. package/scripts/topo-group-pin-test.mjs +119 -0
  565. package/scripts/topo-group-status-pips-test.mjs +103 -0
  566. package/scripts/topo-groupbox-test.mjs +5 -2
  567. package/scripts/topo-help-overlay-r59-test.mjs +67 -0
  568. package/scripts/topo-hub-click-fit-test.mjs +137 -0
  569. package/scripts/topo-hub-title-test.mjs +93 -0
  570. package/scripts/topo-ingroup-edge-test.mjs +103 -0
  571. package/scripts/topo-label-lift-test.mjs +119 -0
  572. package/scripts/topo-label-scale-test.mjs +78 -0
  573. package/scripts/topo-lastseen-tooltip-test.mjs +59 -0
  574. package/scripts/topo-layout-key-test.mjs +91 -0
  575. package/scripts/topo-layout-test.mjs +1 -0
  576. package/scripts/topo-legend-hover-test.mjs +113 -0
  577. package/scripts/topo-legend-pin-test.mjs +128 -0
  578. package/scripts/topo-minimap-test.mjs +68 -0
  579. package/scripts/topo-mobile-chips-test.mjs +103 -0
  580. package/scripts/topo-node-hover-lift-test.mjs +85 -0
  581. package/scripts/topo-offline-dim-test.mjs +70 -0
  582. package/scripts/topo-overlap-test.mjs +164 -0
  583. package/scripts/topo-p0round-test.mjs +166 -0
  584. package/scripts/topo-pan-cursor-test.mjs +62 -0
  585. package/scripts/topo-panel-elevation-test.mjs +76 -0
  586. package/scripts/topo-popover-meta-test.mjs +78 -0
  587. package/scripts/topo-pressure-bar-test.mjs +69 -0
  588. package/scripts/topo-pressure-pin-test.mjs +112 -0
  589. package/scripts/topo-pulse-rate-test.mjs +71 -0
  590. package/scripts/topo-recent-row-hover-test.mjs +122 -0
  591. package/scripts/topo-recent-signal-empty-test.mjs +63 -0
  592. package/scripts/topo-reduced-motion-test.mjs +77 -0
  593. package/scripts/topo-rename-test.mjs +1 -0
  594. package/scripts/topo-tier-ring-test.mjs +69 -0
  595. package/scripts/topo-tz-ghost-test.mjs +69 -0
  596. package/scripts/topo-vendor-test.mjs +1 -0
  597. package/scripts/topo-wheel-zoom-test.mjs +79 -0
  598. package/scripts/topo-zoom-keys-test.mjs +89 -0
  599. package/.next/build/chunks/[turbopack-node]_transforms_postcss_ts_06e.r3r._.js +0 -13
  600. package/.next/build/chunks/node_modules_13sb.px._.js.map +0 -47
  601. package/.next/server/chunks/[root-of-the-server]__0-mykaf._.js +0 -3
  602. package/.next/server/chunks/[root-of-the-server]__0-mykaf._.js.map +0 -1
  603. package/.next/server/chunks/[root-of-the-server]__0.d_kqy._.js +0 -3
  604. package/.next/server/chunks/[root-of-the-server]__0.d_kqy._.js.map +0 -1
  605. package/.next/server/chunks/[root-of-the-server]__02pocyg._.js +0 -3
  606. package/.next/server/chunks/[root-of-the-server]__02pocyg._.js.map +0 -1
  607. package/.next/server/chunks/[root-of-the-server]__03ueci3._.js +0 -3
  608. package/.next/server/chunks/[root-of-the-server]__04aud7x._.js +0 -3
  609. package/.next/server/chunks/[root-of-the-server]__04aud7x._.js.map +0 -1
  610. package/.next/server/chunks/[root-of-the-server]__05fnt3n._.js +0 -3
  611. package/.next/server/chunks/[root-of-the-server]__05fnt3n._.js.map +0 -1
  612. package/.next/server/chunks/[root-of-the-server]__077tsd.._.js +0 -20
  613. package/.next/server/chunks/[root-of-the-server]__077tsd.._.js.map +0 -1
  614. package/.next/server/chunks/[root-of-the-server]__08wy1jr._.js +0 -3
  615. package/.next/server/chunks/[root-of-the-server]__08wy1jr._.js.map +0 -1
  616. package/.next/server/chunks/[root-of-the-server]__0adhu1m._.js +0 -3
  617. package/.next/server/chunks/[root-of-the-server]__0adhu1m._.js.map +0 -1
  618. package/.next/server/chunks/[root-of-the-server]__0aqv02v._.js +0 -3
  619. package/.next/server/chunks/[root-of-the-server]__0aqv02v._.js.map +0 -1
  620. package/.next/server/chunks/[root-of-the-server]__0cawhfq._.js +0 -3
  621. package/.next/server/chunks/[root-of-the-server]__0cawhfq._.js.map +0 -1
  622. package/.next/server/chunks/[root-of-the-server]__0d_-_k.._.js +0 -3
  623. package/.next/server/chunks/[root-of-the-server]__0d_-_k.._.js.map +0 -1
  624. package/.next/server/chunks/[root-of-the-server]__0f6bxfl._.js +0 -3
  625. package/.next/server/chunks/[root-of-the-server]__0f6bxfl._.js.map +0 -1
  626. package/.next/server/chunks/[root-of-the-server]__0h15edq._.js +0 -3
  627. package/.next/server/chunks/[root-of-the-server]__0h15edq._.js.map +0 -1
  628. package/.next/server/chunks/[root-of-the-server]__0k.vegl._.js +0 -3
  629. package/.next/server/chunks/[root-of-the-server]__0k.vegl._.js.map +0 -1
  630. package/.next/server/chunks/[root-of-the-server]__0kltbce._.js +0 -3
  631. package/.next/server/chunks/[root-of-the-server]__0kltbce._.js.map +0 -1
  632. package/.next/server/chunks/[root-of-the-server]__0mn04fw._.js +0 -3
  633. package/.next/server/chunks/[root-of-the-server]__0mn04fw._.js.map +0 -1
  634. package/.next/server/chunks/[root-of-the-server]__0o29v8c._.js +0 -3
  635. package/.next/server/chunks/[root-of-the-server]__0o4qfn9._.js +0 -3
  636. package/.next/server/chunks/[root-of-the-server]__0o7ya7r._.js +0 -3
  637. package/.next/server/chunks/[root-of-the-server]__0o7ya7r._.js.map +0 -1
  638. package/.next/server/chunks/[root-of-the-server]__0p3fo6l._.js +0 -6
  639. package/.next/server/chunks/[root-of-the-server]__0p3fo6l._.js.map +0 -1
  640. package/.next/server/chunks/[root-of-the-server]__0p6tutt._.js +0 -3
  641. package/.next/server/chunks/[root-of-the-server]__0pn3wsx._.js +0 -3
  642. package/.next/server/chunks/[root-of-the-server]__0pn3wsx._.js.map +0 -1
  643. package/.next/server/chunks/[root-of-the-server]__0rg9ifx._.js +0 -3
  644. package/.next/server/chunks/[root-of-the-server]__0x.-194._.js +0 -3
  645. package/.next/server/chunks/[root-of-the-server]__0x.-194._.js.map +0 -1
  646. package/.next/server/chunks/[root-of-the-server]__0x.a_yh._.js +0 -3
  647. package/.next/server/chunks/[root-of-the-server]__0x.a_yh._.js.map +0 -1
  648. package/.next/server/chunks/[root-of-the-server]__0~h2uqi._.js +0 -3
  649. package/.next/server/chunks/[root-of-the-server]__139~d6a._.js +0 -3
  650. package/.next/server/chunks/[root-of-the-server]__139~d6a._.js.map +0 -1
  651. package/.next/server/chunks/_next-internal_server_app_api_anet_config_route_actions_0xzy73j.js +0 -3
  652. package/.next/server/chunks/_next-internal_server_app_api_auth_login_route_actions_0zukc38.js +0 -3
  653. package/.next/server/chunks/_next-internal_server_app_api_auth_logout_route_actions_0fm~3ij.js +0 -3
  654. package/.next/server/chunks/_next-internal_server_app_api_auth_v3_route_actions_0ndzs5l.js +0 -3
  655. package/.next/server/chunks/_next-internal_server_app_api_hub_audit-log_route_actions_11k8fqt.js +0 -3
  656. package/.next/server/chunks/_next-internal_server_app_api_hub_auth_route_actions_0gs7ttk.js +0 -3
  657. package/.next/server/chunks/_next-internal_server_app_api_hub_broadcast_route_actions_0ads-kd.js +0 -3
  658. package/.next/server/chunks/_next-internal_server_app_api_hub_events_route_actions_0opktby.js +0 -3
  659. package/.next/server/chunks/_next-internal_server_app_api_hub_health_route_actions_0tzzt~x.js +0 -3
  660. package/.next/server/chunks/_next-internal_server_app_api_hub_inbox_route_actions_0xvslrg.js +0 -3
  661. package/.next/server/chunks/_next-internal_server_app_api_hub_license_route_actions_0i3tlr3.js +0 -3
  662. package/.next/server/chunks/_next-internal_server_app_api_hub_messages_route_actions_08-gi-s.js +0 -3
  663. package/.next/server/chunks/_next-internal_server_app_api_hub_networks_route_actions_02vejq2.js +0 -3
  664. package/.next/server/chunks/_next-internal_server_app_api_hub_nodes_route_actions_0nx8ryn.js +0 -3
  665. package/.next/server/chunks/_next-internal_server_app_api_hub_register_route_actions_086wi_3.js +0 -3
  666. package/.next/server/chunks/_next-internal_server_app_api_hub_send_route_actions_0-ga_ug.js +0 -3
  667. package/.next/server/chunks/_next-internal_server_app_api_hub_server-logs_route_actions_00euexv.js +0 -3
  668. package/.next/server/chunks/_next-internal_server_app_api_hub_session_route_actions_041wa62.js +0 -3
  669. package/.next/server/chunks/_next-internal_server_app_api_hub_stats_route_actions_1242~od.js +0 -3
  670. package/.next/server/chunks/_next-internal_server_app_api_hub_status_route_actions_0aml09a.js +0 -3
  671. package/.next/server/chunks/_next-internal_server_app_api_hub_task-events_route_actions_02v1tmm.js +0 -3
  672. package/.next/server/chunks/_next-internal_server_app_api_hub_tasks_route_actions_0nl77v4.js +0 -3
  673. package/.next/server/chunks/_next-internal_server_app_api_hub_tmux_route_actions_0u~aaza.js +0 -3
  674. package/.next/server/chunks/_next-internal_server_app_api_hub_tokens_route_actions_09hzf_1.js +0 -3
  675. package/.next/server/chunks/_next-internal_server_app_favicon_ico_route_actions_095lj93.js +0 -3
  676. package/.next/server/chunks/node_modules_next_11synfn._.js +0 -10
  677. package/.next/server/chunks/node_modules_next_11synfn._.js.map +0 -1
  678. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_0yubv2d.js +0 -3
  679. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_0yubv2d.js.map +0 -1
  680. package/.next/server/chunks/ssr/[root-of-the-server]__014uja_._.js +0 -3
  681. package/.next/server/chunks/ssr/[root-of-the-server]__014uja_._.js.map +0 -1
  682. package/.next/server/chunks/ssr/[root-of-the-server]__02hi65f._.js +0 -3
  683. package/.next/server/chunks/ssr/[root-of-the-server]__02hi65f._.js.map +0 -1
  684. package/.next/server/chunks/ssr/[root-of-the-server]__02xqa5z._.js +0 -3
  685. package/.next/server/chunks/ssr/[root-of-the-server]__02xqa5z._.js.map +0 -1
  686. package/.next/server/chunks/ssr/[root-of-the-server]__04ph1de._.js +0 -3
  687. package/.next/server/chunks/ssr/[root-of-the-server]__04ph1de._.js.map +0 -1
  688. package/.next/server/chunks/ssr/[root-of-the-server]__05wc5-z._.js +0 -3
  689. package/.next/server/chunks/ssr/[root-of-the-server]__05wc5-z._.js.map +0 -1
  690. package/.next/server/chunks/ssr/[root-of-the-server]__06-2p1a._.js +0 -33
  691. package/.next/server/chunks/ssr/[root-of-the-server]__06-2p1a._.js.map +0 -1
  692. package/.next/server/chunks/ssr/[root-of-the-server]__08qr2ji._.js.map +0 -1
  693. package/.next/server/chunks/ssr/[root-of-the-server]__09l8xaa._.js +0 -3
  694. package/.next/server/chunks/ssr/[root-of-the-server]__09l8xaa._.js.map +0 -1
  695. package/.next/server/chunks/ssr/[root-of-the-server]__09z7o2x._.js +0 -19
  696. package/.next/server/chunks/ssr/[root-of-the-server]__09z7o2x._.js.map +0 -1
  697. package/.next/server/chunks/ssr/[root-of-the-server]__0a0ffmv._.js +0 -3
  698. package/.next/server/chunks/ssr/[root-of-the-server]__0a0ffmv._.js.map +0 -1
  699. package/.next/server/chunks/ssr/[root-of-the-server]__0b7uu-_._.js +0 -3
  700. package/.next/server/chunks/ssr/[root-of-the-server]__0b7uu-_._.js.map +0 -1
  701. package/.next/server/chunks/ssr/[root-of-the-server]__0bl_wqk._.js +0 -3
  702. package/.next/server/chunks/ssr/[root-of-the-server]__0bl_wqk._.js.map +0 -1
  703. package/.next/server/chunks/ssr/[root-of-the-server]__0ft0rpu._.js +0 -3
  704. package/.next/server/chunks/ssr/[root-of-the-server]__0ft0rpu._.js.map +0 -1
  705. package/.next/server/chunks/ssr/[root-of-the-server]__0i~g-2t._.js +0 -3
  706. package/.next/server/chunks/ssr/[root-of-the-server]__0i~g-2t._.js.map +0 -1
  707. package/.next/server/chunks/ssr/[root-of-the-server]__0jl4ukn._.js +0 -3
  708. package/.next/server/chunks/ssr/[root-of-the-server]__0jl4ukn._.js.map +0 -1
  709. package/.next/server/chunks/ssr/[root-of-the-server]__0mook08._.js +0 -3
  710. package/.next/server/chunks/ssr/[root-of-the-server]__0mook08._.js.map +0 -1
  711. package/.next/server/chunks/ssr/[root-of-the-server]__0n46s~k._.js +0 -3
  712. package/.next/server/chunks/ssr/[root-of-the-server]__0n46s~k._.js.map +0 -1
  713. package/.next/server/chunks/ssr/[root-of-the-server]__0qchqb~._.js +0 -3
  714. package/.next/server/chunks/ssr/[root-of-the-server]__0qchqb~._.js.map +0 -1
  715. package/.next/server/chunks/ssr/[root-of-the-server]__0rrm~-q._.js +0 -3
  716. package/.next/server/chunks/ssr/[root-of-the-server]__0rrm~-q._.js.map +0 -1
  717. package/.next/server/chunks/ssr/[root-of-the-server]__0w2ay2h._.js +0 -3
  718. package/.next/server/chunks/ssr/[root-of-the-server]__0w2ay2h._.js.map +0 -1
  719. package/.next/server/chunks/ssr/[root-of-the-server]__0xjish~._.js +0 -3
  720. package/.next/server/chunks/ssr/[root-of-the-server]__0xjish~._.js.map +0 -1
  721. package/.next/server/chunks/ssr/[root-of-the-server]__0xm3h9x._.js +0 -3
  722. package/.next/server/chunks/ssr/[root-of-the-server]__0xm3h9x._.js.map +0 -1
  723. package/.next/server/chunks/ssr/[root-of-the-server]__0ywihy_._.js +0 -8
  724. package/.next/server/chunks/ssr/[root-of-the-server]__0ywihy_._.js.map +0 -1
  725. package/.next/server/chunks/ssr/[root-of-the-server]__12ad1n.._.js +0 -3
  726. package/.next/server/chunks/ssr/[root-of-the-server]__12ad1n.._.js.map +0 -1
  727. package/.next/server/chunks/ssr/_0nne7mz._.js +0 -3
  728. package/.next/server/chunks/ssr/_0nne7mz._.js.map +0 -1
  729. package/.next/server/chunks/ssr/_next-internal_server_app__global-error_page_actions_0k77kol.js +0 -3
  730. package/.next/server/chunks/ssr/_next-internal_server_app__not-found_page_actions_0eq97pa.js +0 -3
  731. package/.next/server/chunks/ssr/_next-internal_server_app_admin_page_actions_0ycnu8f.js +0 -3
  732. package/.next/server/chunks/ssr/_next-internal_server_app_login_page_actions_02kefem.js +0 -3
  733. package/.next/server/chunks/ssr/_next-internal_server_app_logs_page_actions_0rvsl~d.js +0 -3
  734. package/.next/server/chunks/ssr/_next-internal_server_app_messages_page_actions_0gsqep_.js +0 -3
  735. package/.next/server/chunks/ssr/_next-internal_server_app_node_page_actions_0wd8cgl.js +0 -3
  736. package/.next/server/chunks/ssr/_next-internal_server_app_nodes_page_actions_0-.rv67.js +0 -3
  737. package/.next/server/chunks/ssr/_next-internal_server_app_page_actions_09-gtaw.js +0 -3
  738. package/.next/server/chunks/ssr/_next-internal_server_app_server-logs_page_actions_0g1gb5c.js +0 -3
  739. package/.next/server/chunks/ssr/_next-internal_server_app_settings_networks_page_actions_09uw.sw.js +0 -3
  740. package/.next/server/chunks/ssr/_next-internal_server_app_settings_page_actions_0mr68ai.js +0 -3
  741. package/.next/server/chunks/ssr/_next-internal_server_app_settings_tokens_page_actions_0yi54x8.js +0 -3
  742. package/.next/server/chunks/ssr/_next-internal_server_app_tasks_[id]_page_actions_0k_hm9g.js +0 -3
  743. package/.next/server/chunks/ssr/_next-internal_server_app_tasks_page_actions_0_2o_cr.js +0 -3
  744. package/.next/server/chunks/ssr/app_0.zbtn5._.js +0 -9
  745. package/.next/server/chunks/ssr/app_0.zbtn5._.js.map +0 -1
  746. package/.next/server/chunks/ssr/app_01y-~mp._.js +0 -3
  747. package/.next/server/chunks/ssr/app_01y-~mp._.js.map +0 -1
  748. package/.next/server/chunks/ssr/app_04n78pr._.js +0 -3
  749. package/.next/server/chunks/ssr/app_04n78pr._.js.map +0 -1
  750. package/.next/server/chunks/ssr/app_0_agk0r._.js +0 -3
  751. package/.next/server/chunks/ssr/app_0_agk0r._.js.map +0 -1
  752. package/.next/server/chunks/ssr/app_0aenxef._.js +0 -3
  753. package/.next/server/chunks/ssr/app_0aenxef._.js.map +0 -1
  754. package/.next/server/chunks/ssr/app_0aum_g5._.js +0 -3
  755. package/.next/server/chunks/ssr/app_0aum_g5._.js.map +0 -1
  756. package/.next/server/chunks/ssr/app_0emvkn2._.js +0 -3
  757. package/.next/server/chunks/ssr/app_0emvkn2._.js.map +0 -1
  758. package/.next/server/chunks/ssr/app_0esuljp._.js +0 -3
  759. package/.next/server/chunks/ssr/app_0esuljp._.js.map +0 -1
  760. package/.next/server/chunks/ssr/app_0izi28j._.js +0 -3
  761. package/.next/server/chunks/ssr/app_0izi28j._.js.map +0 -1
  762. package/.next/server/chunks/ssr/app_components_08ys5y3._.js.map +0 -1
  763. package/.next/server/chunks/ssr/app_server-logs_page_tsx_0_kpp1p._.js.map +0 -1
  764. package/.next/server/chunks/ssr/app_tasks_page_tsx_0z-d6mp._.js +0 -3
  765. package/.next/server/chunks/ssr/app_tasks_page_tsx_0z-d6mp._.js.map +0 -1
  766. package/.next/server/chunks/ssr/node_modules_09w7yel._.js.map +0 -1
  767. package/.next/server/chunks/ssr/node_modules_0ck2~9g._.js +0 -3
  768. package/.next/server/chunks/ssr/node_modules_0ck2~9g._.js.map +0 -1
  769. package/.next/server/chunks/ssr/node_modules_0r_.s40._.js.map +0 -1
  770. package/.next/server/chunks/ssr/node_modules_0vtdjw7._.js +0 -3
  771. package/.next/server/chunks/ssr/node_modules_0vtdjw7._.js.map +0 -1
  772. package/.next/server/chunks/ssr/node_modules_next_0w2y7fz._.js +0 -6
  773. package/.next/server/chunks/ssr/node_modules_next_0w2y7fz._.js.map +0 -1
  774. package/.next/server/chunks/ssr/node_modules_next_dist_0e1izl_._.js +0 -6
  775. package/.next/server/chunks/ssr/node_modules_next_dist_0e1izl_._.js.map +0 -1
  776. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_0inhx6q._.js +0 -3
  777. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_0inhx6q._.js.map +0 -1
  778. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_forbidden_0ghu-f7.js +0 -3
  779. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_forbidden_0ghu-f7.js.map +0 -1
  780. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_global-error_0lgvd_..js +0 -3
  781. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_global-error_0lgvd_..js.map +0 -1
  782. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_unauthorized_0cjv-23.js +0 -3
  783. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_unauthorized_0cjv-23.js.map +0 -1
  784. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_002l7yi.js.map +0 -1
  785. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_02suzhc.js.map +0 -1
  786. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_07uu-xq.js.map +0 -1
  787. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0d2yq5t.js.map +0 -1
  788. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0dm~kj8.js.map +0 -1
  789. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0foejvu.js.map +0 -1
  790. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0l-fh0i.js.map +0 -1
  791. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0nsg22r.js.map +0 -1
  792. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0o9vt1n.js.map +0 -1
  793. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0qm1-a1.js.map +0 -1
  794. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0uccf9z.js.map +0 -1
  795. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0uv6jyc.js.map +0 -1
  796. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_10~~o-h.js +0 -4
  797. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_10~~o-h.js.map +0 -1
  798. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_13leyhk.js +0 -4
  799. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_13leyhk.js.map +0 -1
  800. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_13s07li.js.map +0 -1
  801. package/.next/static/chunks/01fk_e9tpe4cr.js +0 -1
  802. package/.next/static/chunks/0257pdz1-imal.js +0 -2
  803. package/.next/static/chunks/02_zpo7taex9o.js +0 -7
  804. package/.next/static/chunks/06n3d~fbbtagk.js +0 -1
  805. package/.next/static/chunks/07eba1lz2u3p4.js +0 -1
  806. package/.next/static/chunks/0_rmxom9~1lmk.css +0 -2
  807. package/.next/static/chunks/0av2~0.96~d~g.js +0 -1
  808. package/.next/static/chunks/0e-.zgw81gela.js +0 -31
  809. package/.next/static/chunks/0e0c5hzwhvzje.js +0 -1
  810. package/.next/static/chunks/0g9aof0ob_fcj.js +0 -1
  811. package/.next/static/chunks/0j6t.m1a6._pk.js +0 -1
  812. package/.next/static/chunks/0s8jo6jypzkx5.js +0 -1
  813. package/.next/static/chunks/0u359llqm1_01.js +0 -1
  814. package/.next/static/chunks/0ubqqhhpsaez4.js +0 -1
  815. package/.next/static/chunks/0yz2othu-3th..js +0 -1
  816. package/.next/static/chunks/0~qw9kxksr1v2.js +0 -1
  817. package/.next/static/chunks/12gprw3l0kvvf.js +0 -1
  818. package/.next/static/chunks/12yuo353giomx.js +0 -1
  819. package/.next/static/chunks/14bsg3_-taiw4.js +0 -1
  820. /package/.next/build/chunks/{[root-of-the-server]__0ubbtyl._.js.map → [root-of-the-server]__0tgc-8p._.js.map} +0 -0
  821. /package/.next/build/chunks/{[turbopack-node]_transforms_postcss_ts_06e.r3r._.js.map → [turbopack-node]_transforms_postcss_ts_0r4x5ys._.js.map} +0 -0
  822. /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
  823. /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
  824. /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
  825. /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
  826. /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
  827. /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
  828. /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
  829. /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
  830. /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
  831. /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
  832. /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
  833. /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
  834. /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
  835. /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
  836. /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
  837. /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
  838. /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
  839. /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
  840. /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
  841. /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
  842. /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
  843. /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
  844. /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
  845. /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
  846. /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
  847. /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
  848. /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
  849. /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
  850. /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
  851. /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
  852. /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
  853. /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
  854. /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
  855. /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
  856. /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
  857. /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
  858. /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
  859. /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
  860. /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
  861. /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
  862. /package/.next/static/{wi8v9sMITbvGOeqsOVwUq → fRqnaYLZjeQTx1-47Rbd5}/_buildManifest.js +0 -0
  863. /package/.next/static/{wi8v9sMITbvGOeqsOVwUq → fRqnaYLZjeQTx1-47Rbd5}/_clientMiddlewareManifest.js +0 -0
  864. /package/.next/static/{wi8v9sMITbvGOeqsOVwUq → fRqnaYLZjeQTx1-47Rbd5}/_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,161 @@ 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
+ {/* Round 64 / Loop: active-filter pills. When pinnedStatus or
1180
+ pinnedGroup is set, show a small "filter: <key> ×" pill so
1181
+ the user can see the pin from the chip row even if they
1182
+ scrolled the canvas off-screen. × button clears the
1183
+ specific pin (Esc still clears all — both paths are
1184
+ valid). pinnedStatus and pinnedGroup pin independently so
1185
+ both pills may render simultaneously. */}
1186
+ {pinnedStatus && (
1187
+ <span
1188
+ data-active-filter="status"
1189
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border"
1190
+ style={{
1191
+ background: pinnedStatus === 'working' ? (isLight ? '#05966914' : '#22c55e1f')
1192
+ : pinnedStatus === 'idle' ? (isLight ? '#0d948814' : '#2dd4bf1f')
1193
+ : (isLight ? '#94a3b814' : '#6b72801f'),
1194
+ color: pinnedStatus === 'working' ? (isLight ? '#047857' : '#86efac')
1195
+ : pinnedStatus === 'idle' ? (isLight ? '#0f766e' : '#5eead4')
1196
+ : (isLight ? '#475569' : '#9ca3af'),
1197
+ borderColor: 'currentColor',
1198
+ }}
1199
+ >
1200
+ <span>filter: {pinnedStatus}</span>
1201
+ <button
1202
+ type="button"
1203
+ aria-label={`Clear ${pinnedStatus} filter`}
1204
+ onClick={() => setPinnedStatus(null)}
1205
+ className="ml-0.5 leading-none hover:opacity-70"
1206
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1207
+ >×</button>
1208
+ </span>
1209
+ )}
1210
+ {pinnedGroup && (
1211
+ <span
1212
+ data-active-filter="group"
1213
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border"
1214
+ style={{
1215
+ background: isLight ? '#67e8f914' : '#67e8f91f',
1216
+ color: pal.legendAccent,
1217
+ borderColor: 'currentColor',
1218
+ }}
1219
+ >
1220
+ <span>filter: {pinnedGroup}</span>
1221
+ <button
1222
+ type="button"
1223
+ aria-label={`Clear group filter ${pinnedGroup}`}
1224
+ onClick={() => setPinnedGroup(null)}
1225
+ className="ml-0.5 leading-none hover:opacity-70"
1226
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1227
+ >×</button>
1228
+ </span>
1229
+ )}
1230
+ {vendorDist.length > 1 && (
1231
+ <span
1232
+ 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"
1233
+ title="Fleet by model vendor"
1234
+ >
1235
+ {vendorDist.map(v => (
1236
+ <span key={v.initial} className="inline-flex items-baseline gap-0.5">
1237
+ <span style={{ color: v.color }}>{v.initial}</span>
1238
+ <span className="text-gray-500">:{v.count}</span>
1239
+ </span>
1240
+ ))}
1241
+ </span>
1242
+ )}
1243
+ {/* Round 42 / Loop: extend active-links chip with the timestamp
1244
+ of the most-recent flow event. Tells the operator at a glance
1245
+ whether the topology is currently humming (last 30s) or has
1246
+ been quiet for a while — the visual flow particles and
1247
+ edge brightness only show that there IS traffic, not when
1248
+ the last one was. Reuses Round 38's relativeAgo. */}
1249
+ {(() => {
1250
+ const recent = flowLinks.reduce<number | null>((acc, l) => {
1251
+ if (!l.last_at) return acc;
1252
+ const t = parseHubTime(l.last_at);
1253
+ if (t === null) return acc;
1254
+ return acc === null || t > acc ? t : acc;
1255
+ }, null);
1256
+ const rel = recent !== null ? relativeAgo(new Date(recent).toISOString()) : null;
1257
+ return (
1258
+ <span className="hidden sm:inline px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20">
1259
+ {flowLinks.length} active link{flowLinks.length === 1 ? '' : 's'}
1260
+ {rel ? <span className="text-gray-500"> · last {rel}</span> : null}
1261
+ </span>
1262
+ );
1263
+ })()}
1264
+ <FreshnessChip sessions={sessions} />
717
1265
  </div>
718
1266
  </div>
719
1267
 
@@ -733,7 +1281,18 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
733
1281
  onPointerMove={onPointerMove}
734
1282
  onPointerUp={onPointerUp}
735
1283
  onPointerLeave={onPointerUp}
736
- style={{ cursor: 'grab', touchAction: 'none' }}
1284
+ // Round 41 / Loop: the reset-button title (R22) and the Help
1285
+ // overlay (R25) both advertise "double-click the canvas to
1286
+ // reset", but the handler was never actually wired. The text
1287
+ // was lying. Wire it here, guarded so dbl-clicking a node
1288
+ // (which would also trigger the SVG-level handler via event
1289
+ // bubbling) doesn't unexpectedly reset the view on the user.
1290
+ onDoubleClick={(e) => {
1291
+ const t = e.target as Element | null;
1292
+ if (t?.closest('g[data-node]')) return;
1293
+ resetView();
1294
+ }}
1295
+ style={{ cursor: isPanning ? 'grabbing' : 'grab', touchAction: 'none' }}
737
1296
  >
738
1297
  <defs>
739
1298
  <linearGradient id="topo-panel" x1="0" x2="1" y1="0" y2="1">
@@ -755,9 +1314,37 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
755
1314
  </feMerge>
756
1315
  </filter>
757
1316
  )}
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>
1317
+ {/* Round 16 / Loop: 3-tier flow-link arrow markers.
1318
+ The single marker had `markerUnits` defaulting to
1319
+ `strokeWidth`, so heavy edges (stroke=7) rendered 35-user-
1320
+ unit arrowheads — visually dominant, the head outweighed
1321
+ the line. Switching to `userSpaceOnUse` decouples arrow
1322
+ size from stroke; binning by count gives a clearer
1323
+ hierarchy than continuous linear scaling:
1324
+ s (count 1-2) → 12 user units
1325
+ m (count 3-4) → 16 user units (alias for `topo-arrow`)
1326
+ l (count 5+) → 22 user units
1327
+ `topo-arrow` stays bound to the medium tier so the legend
1328
+ swatch (line ~1500) renders without change. */}
1329
+ {[
1330
+ { id: 'topo-arrow-s', size: 12 },
1331
+ { id: 'topo-arrow', size: 16 },
1332
+ { id: 'topo-arrow-l', size: 22 },
1333
+ ].map(m => (
1334
+ <marker
1335
+ key={m.id}
1336
+ id={m.id}
1337
+ viewBox="0 0 10 10"
1338
+ refX="8"
1339
+ refY="5"
1340
+ markerWidth={m.size}
1341
+ markerHeight={m.size}
1342
+ markerUnits="userSpaceOnUse"
1343
+ orient="auto-start-reverse"
1344
+ >
1345
+ <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
1346
+ </marker>
1347
+ ))}
761
1348
  {/* Round 45: radial radar sweep gradient — bright at center
762
1349
  (where it meets the hub) fading to leading edge. */}
763
1350
  <radialGradient id="topo-sweep" cx="0%" cy="50%" r="100%">
@@ -777,13 +1364,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
777
1364
  grid mode drops it so the concentric rings don't sit behind a
778
1365
  rectangular grid. */}
779
1366
  {layout === 'ring' && (<>
780
- <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" />
1367
+ {/* R52: radar bg is pure decoration — drop its pointer events so
1368
+ the hub <g> under it (and any future inner-disk affordances)
1369
+ can receive clicks. Previously the r=330 disc intercepted
1370
+ the hub click outright. */}
1371
+ <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" style={{ pointerEvents: 'none' }} />
781
1372
 
782
1373
  {/* Round 45: subtle star field — 24 deterministic dots scattered
783
1374
  across the canvas give the radar bg some depth. Skipped on
784
1375
  light theme so the white surface stays clean. */}
785
1376
  {!isLight && (
786
- <g opacity="0.5">
1377
+ <g opacity="0.5" style={{ pointerEvents: 'none' }}>
787
1378
  {Array.from({ length: 28 }).map((_, i) => {
788
1379
  // Deterministic pseudo-random scatter so positions are
789
1380
  // stable between renders (no JS hydration mismatch).
@@ -805,6 +1396,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
805
1396
  style={{
806
1397
  transformOrigin: `${cx}px ${cy}px`,
807
1398
  transformBox: 'view-box',
1399
+ pointerEvents: 'none',
808
1400
  }}
809
1401
  className="anet-topo-sweep"
810
1402
  opacity={isLight ? 0.7 : 1}
@@ -815,11 +1407,44 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
815
1407
  />
816
1408
  </g>
817
1409
 
818
- {/* radar rings */}
1410
+ {/* radar rings — pure decoration at fixed radii, independent of
1411
+ node positions so the radar aesthetic is preserved across tier
1412
+ changes. */}
819
1413
  {[90, 170, 250, 330].map(radius => (
820
1414
  <circle key={radius} cx={cx} cy={cy} r={radius} fill="none" stroke={pal.ringStroke} strokeWidth="1" opacity={isLight ? 0.6 : 0.35} />
821
1415
  ))}
822
1416
 
1417
+ {/* Round 54 / Loop: tier-radius guide rings. The radar rings above
1418
+ are decorative and don't match the actual tier radii nodes sit
1419
+ on (single 220 / dual 175,260 / triple 145,215,285). Drawing
1420
+ a faint dashed ring at each ACTIVE tier radius lets the eye
1421
+ anchor "this is the inner / outer ring" without inferring from
1422
+ node spacing. Picked based on online node count so only the
1423
+ tiers currently in use draw — empty tiers stay quiet. pointer-
1424
+ events:none so they never intercept hub or node clicks. The
1425
+ 0.7 stroke + dashed pattern reads as guide, not feature. */}
1426
+ {(
1427
+ onlineNodes.length > onlineTripleThreshold
1428
+ ? [onlineTripleInnerR, onlineTripleMidR, onlineTripleOuterR]
1429
+ : onlineNodes.length > onlineTierThreshold
1430
+ ? [onlineInnerRadius, onlineOuterRadius]
1431
+ : onlineNodes.length > 0
1432
+ ? [onlineRadius]
1433
+ : []
1434
+ ).map(r => (
1435
+ <circle
1436
+ key={`tier-${r}`}
1437
+ cx={cx} cy={cy} r={r}
1438
+ fill="none"
1439
+ stroke={pal.ringStroke}
1440
+ strokeWidth="0.7"
1441
+ strokeDasharray="2 8"
1442
+ opacity={isLight ? 0.32 : 0.42}
1443
+ style={{ pointerEvents: 'none' }}
1444
+ data-tier-ring={r}
1445
+ />
1446
+ ))}
1447
+
823
1448
  {/* Round 50: 4 small particles slowly orbiting the outer ring
824
1449
  (r=330). Each starts at a different angle (offset 0/0.25/0.5/0.75
825
1450
  of the cycle) so they're evenly spaced. 16s per revolution is
@@ -889,32 +1514,104 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
889
1514
  only — groupBoxes is empty in ring mode. Rendered behind the
890
1515
  flow links + nodes; pointer-events off so they never intercept
891
1516
  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"
1517
+ {groupBoxes.map(box => {
1518
+ const isHovered = activeGroup === box.key;
1519
+ // Round 18 / Loop: group-box hover linkage. The Round 8 fade
1520
+ // already dropped OUT-of-focus groups to 0.28, but the IN-focus
1521
+ // group sat at its baseline appearance — no positive emphasis.
1522
+ // Hovering now upgrades the box to an "accent" treatment:
1523
+ // solid stroke (not dashed), thicker, accent-coloured; brighter
1524
+ // text and slightly stronger fill. Label and box read as one
1525
+ // selected unit. Geometry unchanged → overlap test untouched.
1526
+ return (
1527
+ <g
1528
+ key={`grp-${box.key}`}
1529
+ data-group={box.key}
1530
+ className="transition-opacity"
1531
+ // R63: drop the blanket pointerEvents:'none' that
1532
+ // previously sat here. Chrome's SVG impl doesn't let a
1533
+ // child override a parent's `none` even though the spec
1534
+ // says it should — moving the property onto just the
1535
+ // rect (where it's needed so nodes underneath stay
1536
+ // clickable) lets the label text receive its own click.
1537
+ style={{ opacity: !activeGroup || isHovered ? 1 : 0.28 }}
913
1538
  >
914
- {box.key}
915
- </text>
916
- </g>
917
- ))}
1539
+ <rect
1540
+ x={box.x}
1541
+ y={box.y}
1542
+ width={box.w}
1543
+ height={box.h}
1544
+ rx="14"
1545
+ fill={isLight ? '#0f172a' : '#a5b4fc'}
1546
+ fillOpacity={isHovered ? (isLight ? 0.05 : 0.09) : (isLight ? 0.025 : 0.045)}
1547
+ stroke={isHovered ? pal.legendAccent : pal.ringStroke}
1548
+ strokeWidth={isHovered ? 2 : 1.5}
1549
+ strokeDasharray={isHovered ? 'none' : '6 6'}
1550
+ style={{ transition: 'stroke 200ms ease-out, stroke-width 200ms ease-out, fill-opacity 200ms ease-out', pointerEvents: 'none' }}
1551
+ />
1552
+ {/* R63: wrap label in a clickable <g> with an invisible
1553
+ rect hitbox. The text alone wasn't getting hit-tested
1554
+ reliably — the SVG-wide topo-panel <rect> intercepts
1555
+ at the label's screen position when the label sits at
1556
+ a high viewBox-y (it lands below where the compositor
1557
+ expects the text to paint on top, same gotcha as the
1558
+ recent-signal panel rows in R56). Hitbox rect + the
1559
+ parent <g> taking the click + onPointerDown stop-
1560
+ propagation match the R55/R56/R61 pattern. */}
1561
+ <g
1562
+ role="button"
1563
+ tabIndex={0}
1564
+ aria-pressed={pinnedGroup === box.key}
1565
+ data-group-label-hit={box.key}
1566
+ style={{ pointerEvents: 'all', cursor: 'pointer' }}
1567
+ onPointerDown={(e) => e.stopPropagation()}
1568
+ onClick={() => setPinnedGroup(prev => prev === box.key ? null : box.key)}
1569
+ >
1570
+ <rect
1571
+ x={box.x + 6}
1572
+ y={box.y + 2}
1573
+ width={Math.min(box.w - 12, 240)}
1574
+ height={20}
1575
+ fill="transparent"
1576
+ />
1577
+ <text
1578
+ x={box.x + 12}
1579
+ y={box.y + 14}
1580
+ fill={isHovered ? pal.legendHeadline : pal.legendText}
1581
+ fontSize="13"
1582
+ fontFamily="monospace"
1583
+ fontWeight="700"
1584
+ style={{ transition: 'fill 200ms ease-out' }}
1585
+ data-group-label={box.key}
1586
+ >
1587
+ {box.key}
1588
+ {/* Round 19 / Loop: member-count chip. Inline tspan stays
1589
+ in the single <text> bbox the overlap test reads, so
1590
+ the node↔label guard still catches if the chip ever
1591
+ pushes the label far enough right to clip a node.
1592
+ Smaller + lighter weight reads as metadata, not name. */}
1593
+ <tspan dx="6" fill={pal.legendText} fontSize="11" fontWeight="400">· {box.count}</tspan>
1594
+ {/* Round 58 / Loop: status mix pip strip. Compact text-
1595
+ based chips (e.g. "2w 1i") so the strip stays inside
1596
+ the same <text> bbox the overlap-test reads — keeps
1597
+ the R27 label↔label and R19 node↔label guards intact.
1598
+ Each tier is colour-coded against the legend swatches
1599
+ and only renders when count > 0, so a healthy all-
1600
+ working group reads simply " · 2w". */}
1601
+ {box.statuses.working > 0 && (
1602
+ <tspan dx="8" fill={isLight ? '#059669' : '#22c55e'} fontSize="11" fontWeight="600">{box.statuses.working}w</tspan>
1603
+ )}
1604
+ {box.statuses.idle > 0 && (
1605
+ <tspan dx="4" fill={isLight ? '#0d9488' : '#2dd4bf'} fontSize="11" fontWeight="600">{box.statuses.idle}i</tspan>
1606
+ )}
1607
+ {box.statuses.offline > 0 && (
1608
+ <tspan dx="4" fill={isLight ? '#94a3b8' : '#6b7280'} fontSize="11" fontWeight="600">{box.statuses.offline}o</tspan>
1609
+ )}
1610
+ </text>
1611
+ </g>
1612
+ </g>
1613
+ );
1614
+ })}
918
1615
 
919
1616
  {/* directed message flows */}
920
1617
  {flowLinks.map((link, index) => {
@@ -922,21 +1619,116 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
922
1619
  const to = nodePositions[link.to];
923
1620
  if (!from || !to) return null;
924
1621
 
925
- const lift = index % 2 === 0 ? 36 : -36;
1622
+ // Round 7 / Loop: lift now scales with distance so short links
1623
+ // aren't over-bent (long links keep the ~36px hump), and the
1624
+ // particle period shortens with link.count so busier edges
1625
+ // visibly pulse faster — instant info density on top of the
1626
+ // existing stroke-width-by-count chip.
1627
+ const dist = Math.hypot(to.x - from.x, to.y - from.y);
1628
+ const lift = (index % 2 === 0 ? 1 : -1) * Math.min(36, dist * 0.18);
926
1629
  const path = curvePath(from, to, lift);
927
1630
  const width = Math.min(2 + link.count, 7);
928
- const duration = 2.2 + (index % 5) * 0.32;
929
-
1631
+ const duration = Math.max(0.9, 2.6 / Math.sqrt(link.count));
1632
+ // Round 10 / Loop: freshness fade. An edge that fired ≤30s ago
1633
+ // stays at full intensity; over 5 minutes it decays to ~35%.
1634
+ // Surfaces "what's happening now" vs background chatter without
1635
+ // hiding old flow entirely (some context still useful). `now`
1636
+ // captured at useMemo-recompute time (every 5s message refresh)
1637
+ // — accuracy is within the poll interval, plenty.
1638
+ const ageMs = link.last_at ? Math.max(0, Date.now() - Date.parse(link.last_at)) : 0;
1639
+ const fresh = Math.max(0.35, 1 - ageMs / (5 * 60 * 1000));
1640
+ // Round 16 arrow-tier binning — keep `topo-arrow` as the
1641
+ // medium tier id so the legend swatch picks it up unchanged.
1642
+ const arrowId = link.count <= 2 ? 'topo-arrow-s'
1643
+ : link.count <= 4 ? 'topo-arrow'
1644
+ : 'topo-arrow-l';
1645
+
1646
+ // Round 39 / Loop: edge hover tooltip — surface the same
1647
+ // last_at + count info the freshness fade and arrow tier
1648
+ // already encode visually, in plain text. The stroke is the
1649
+ // hover target; SVG `<title>` honours newlines on every
1650
+ // browser the dashboard targets.
1651
+ const lastAt = relativeAgo(link.last_at);
1652
+ const tooltip = `${link.from} → ${link.to}\n${link.count} message${link.count === 1 ? '' : 's'}${lastAt ? ` · last ${lastAt}` : ''}`;
1653
+ // Round 40 / Loop: edges follow node hover — when an alias is
1654
+ // hovered, every edge touching it brightens, the rest fade.
1655
+ // Pairs with the Round 8 group-focus fade on nodes: hover a
1656
+ // node to find "who is this agent talking to" at a glance.
1657
+ // No hover → multiplier is 1.0 (current behaviour preserved).
1658
+ // Round 50 / Loop: edge-on-self priority. When the user hovers
1659
+ // a flow edge directly (R48 widened the hitbox so this is now
1660
+ // a precise gesture), THAT edge gets the strongest boost (2.0)
1661
+ // and every other edge dims to 0.35. Node-hover (R40) keeps
1662
+ // its own ladder. Edge-hover and node-hover are mutually
1663
+ // exclusive in practice — the cursor is over one or the
1664
+ // other — but the order below makes edge-hover win if both
1665
+ // ever read truthy at the same React tick.
1666
+ // Round 53 / Loop: in-group edges follow team focus. R40
1667
+ // brightened only edges touching the exact hovered alias —
1668
+ // but with R8/R18 prefix-clustering, a user hovering one
1669
+ // member is asking about the team. So when BOTH endpoints of
1670
+ // an edge share the hoveredGroup (and neither is the exact
1671
+ // hovered alias — that gets the stronger 1.7×), the edge
1672
+ // boosts to 1.3×. Edges leaving the team (one endpoint in,
1673
+ // one out) still dim to 0.35× per R40 since they read as
1674
+ // background to the focus. Singletons fall through unchanged
1675
+ // (their group key is the alias itself, so bothInHoveredGroup
1676
+ // is impossible for a non-self-edge).
1677
+ const isHoveredEdge = hoveredEdgeKey === link.key;
1678
+ const fromGroup = groupKeys[link.from] ?? link.from;
1679
+ const toGroup = groupKeys[link.to] ?? link.to;
1680
+ const bothInHoveredGroup = !!activeGroup && fromGroup === activeGroup && toGroup === activeGroup;
1681
+ // R63: also gate "no filter" on activeGroup so pinnedGroup
1682
+ // alone activates the in-group 1.3× boost + non-group dim.
1683
+ // When neither a node nor a group is in focus, mul is 1.
1684
+ const edgeOpacityMul = isHoveredEdge
1685
+ ? 2.0
1686
+ : hoveredEdgeKey
1687
+ ? 0.35
1688
+ : !hoveredAlias && !activeGroup
1689
+ ? 1
1690
+ : (link.from === hoveredAlias || link.to === hoveredAlias)
1691
+ ? 1.7
1692
+ : bothInHoveredGroup
1693
+ ? 1.3
1694
+ : 0.35;
1695
+ // Round 50: the hovered edge also visibly thickens so the eye
1696
+ // tracks it even at low message counts (width was 3 → 4.5 on
1697
+ // hover). 1.4× is enough to read as "lifted" without
1698
+ // breaching the 16-px hitbox bound.
1699
+ const renderWidth = isHoveredEdge ? Math.min(width * 1.4, 10) : width;
930
1700
  return (
931
1701
  <g key={link.key}>
1702
+ {/* Round 48 / Loop: invisible hover hitbox — visible flow
1703
+ path is 3-7 px wide and damn hard to hover precisely.
1704
+ Stack a transparent 16-px-wide stroke behind it so the
1705
+ cursor only needs to be ~8 px from the line for the
1706
+ tooltip to fire. Native <title> moves here; the
1707
+ visible path no longer needs it. pointer-events on
1708
+ the visible path drop to "none" since the hitbox
1709
+ owns the hover surface. */}
1710
+ <path
1711
+ d={path}
1712
+ fill="none"
1713
+ stroke="transparent"
1714
+ strokeWidth={Math.max(width + 10, 16)}
1715
+ style={{ pointerEvents: 'stroke' }}
1716
+ data-edge-hitbox
1717
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
1718
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
1719
+ >
1720
+ <title>{tooltip}</title>
1721
+ </path>
932
1722
  <path
933
1723
  d={path}
934
1724
  fill="none"
935
1725
  stroke={pal.flowEdge}
936
- strokeWidth={width}
937
- opacity={isLight ? 0.22 : 0.28}
1726
+ strokeWidth={renderWidth}
1727
+ opacity={Math.min(1, (isLight ? 0.22 : 0.28) * fresh * edgeOpacityMul)}
938
1728
  filter={isLight ? undefined : 'url(#topo-glow)'}
939
- markerEnd="url(#topo-arrow)"
1729
+ markerEnd={`url(#${arrowId})`}
1730
+ className="transition-opacity duration-300"
1731
+ style={{ pointerEvents: 'none' }}
940
1732
  />
941
1733
  <path
942
1734
  id={`flow-path-${index}`}
@@ -945,37 +1737,75 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
945
1737
  stroke={pal.flowPath}
946
1738
  strokeWidth="1"
947
1739
  strokeDasharray="2 12"
948
- opacity={isLight ? 0.4 : 0.75}
1740
+ opacity={Math.min(1, (isLight ? 0.4 : 0.75) * fresh * edgeOpacityMul)}
1741
+ className="transition-opacity duration-300"
949
1742
  />
950
- <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'}>
951
- <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
952
- </circle>
1743
+ {!reducedMotion && (
1744
+ <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'} opacity={Math.min(1, fresh * edgeOpacityMul)}>
1745
+ <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
1746
+ </circle>
1747
+ )}
953
1748
  </g>
954
1749
  );
955
1750
  })}
956
1751
 
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 */}
1752
+ {/* center hub — round 39 sized + round 13 restraint.
1753
+ The hub is the control-plane anchor. r39 gave it two outward
1754
+ pulses (r 10→38, 2.4s, double-phase) which read as "loudest
1755
+ node in the room" wrong semantics: anchors don't emit,
1756
+ they hold. r13 swaps the kinetic pulse for a slow opacity
1757
+ breath on the static halo (4s, ±15% from base), so the hub
1758
+ stays alive without throwing kinetic energy outward. The
1759
+ dual-circle "lit lamp" core is unchanged. */}
1760
+ {layout === 'ring' && (<g
1761
+ data-topo-hub
1762
+ style={{ cursor: 'pointer' }}
1763
+ // Stop pointerdown from reaching the SVG pan handler — same
1764
+ // reason as the node <g>: a captured pointer makes Chromium
1765
+ // fire the follow-up click on the SVG, not this group.
1766
+ onPointerDown={(e) => e.stopPropagation()}
1767
+ // Round 52 / Loop: hub is the visual anchor but had no click
1768
+ // action — users discovered fit-to-content only via the `f`
1769
+ // key or the chrome button. Wire the hub to fitView() so the
1770
+ // most prominent element in the canvas is also the "re-
1771
+ // center" affordance. A click-ripple keyed by ts confirms
1772
+ // the gesture; the hub <title> now ends with a hint.
1773
+ onClick={() => {
1774
+ fitView();
1775
+ setClickRipple({ ts: Date.now(), x: cx, y: cy, r0: 18, color: isLight ? '#059669' : '#10b981' });
1776
+ setTimeout(() => setClickRipple(prev => prev && prev.x === cx && prev.y === cy ? null : prev), 600);
1777
+ }}
1778
+ >
1779
+ {/* Round 43 / Loop: hub `<title>` summary — hovering the
1780
+ central glow now answers "what is this?" with a one-line
1781
+ fleet snapshot. Duplicates the header chips by design;
1782
+ hovering the most visually prominent element is the
1783
+ most natural impulse, so satisfy it where the cursor
1784
+ already is. Falls clean: omits sub-clauses when their
1785
+ counts are zero. R52 appends a "click to fit view" hint.
1786
+ */}
1787
+ <title>{(() => {
1788
+ const total = sessions.length;
1789
+ const parts = [`Network hub`, `${total} session${total === 1 ? '' : 's'}`];
1790
+ if (onlineNodes.length > 0) parts.push(`${onlineNodes.length} online`);
1791
+ if (workingCount > 0) parts.push(`${workingCount} working`);
1792
+ if (flowLinks.length > 0) parts.push(`${flowLinks.length} active link${flowLinks.length === 1 ? '' : 's'}`);
1793
+ return parts.join(' · ') + '\nclick to fit view';
1794
+ })()}</title>
1795
+ {/* grounding halo — now breathes in opacity, no expansion */}
965
1796
  <circle
966
1797
  cx={cx} cy={cy} r="18"
967
1798
  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" />
1799
+ opacity={isLight ? 0.42 : 0.12}
1800
+ >
1801
+ {!reducedMotion && (
1802
+ <animate
1803
+ attributeName="opacity"
1804
+ values={isLight ? '0.32;0.52;0.32' : '0.08;0.16;0.08'}
1805
+ dur="4s"
1806
+ repeatCount="indefinite"
1807
+ />
1808
+ )}
979
1809
  </circle>
980
1810
  {/* core — 20px diameter, larger inner highlight reads as a "lit lamp" */}
981
1811
  <circle cx={cx} cy={cy} r="10" fill={isLight ? '#059669' : '#10b981'} />
@@ -983,7 +1813,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
983
1813
  </g>)}
984
1814
 
985
1815
  {/* agent nodes */}
986
- {[...onlineNodes, ...offlineNodes].map(session => {
1816
+ {[...onlineNodes, ...offlineNodes].map((session, nodeIdx) => {
987
1817
  const pos = nodePositions[session.alias];
988
1818
  if (!pos) return null;
989
1819
 
@@ -991,7 +1821,9 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
991
1821
  const isOnline = session.status !== 'offline' || !!sseCountFor;
992
1822
  const status = nodeStatus(session, isOnline, isLight);
993
1823
  const isActive = activeAliases.has(session.alias);
994
- const radius = isOnline ? 26 : 18;
1824
+ // #113: node size scales with the S/M/L control; halos, labels,
1825
+ // badge and avatar all derive from `radius` so they follow.
1826
+ const radius = Math.round((isOnline ? 26 : 18) * nodeScale);
995
1827
  // Round 109/110 (Vincent 4582 + 4583 P0): at high node counts
996
1828
  // the 100px opaque label cards overlapped each other and
997
1829
  // covered neighbouring avatars. But hiding text entirely went
@@ -1001,12 +1833,82 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1001
1833
  // occlude an avatar), and the full card appears only for the
1002
1834
  // hovered node or once zoomed in past 1.4×.
1003
1835
  const showFullLabel = !denseLayout || hoveredAlias === session.alias || view.zoom >= 1.4;
1836
+ // Round 8: when a node in another group is hovered, fade this
1837
+ // one. Same-group nodes (incl. singletons matching the hovered
1838
+ // alias) stay full. Pure visual focus, geometry unchanged.
1839
+ const inFocus = !activeGroup || (groupKeys[session.alias] ?? session.alias) === activeGroup;
1004
1840
 
1005
1841
  return (
1006
1842
  <g
1007
1843
  key={session.alias}
1008
- className="transition-opacity"
1009
- style={{ cursor: 'pointer' }}
1844
+ data-node={session.alias}
1845
+ // Round 3 / Loop: `anet-fade-in` runs once when the <g>
1846
+ // mounts — a new session entering the fleet (or the topology
1847
+ // first rendering) eases in instead of popping. Re-renders of
1848
+ // an existing node don't re-trigger (React preserves the <g>
1849
+ // via the alias key), so status changes don't flicker. The
1850
+ // global prefers-reduced-motion sweep already neutralises it.
1851
+ className="group transition-opacity anet-fade-in"
1852
+ style={{
1853
+ cursor: 'pointer',
1854
+ // Round 17 / Loop: offline nodes drop to 0.6 at rest so
1855
+ // online nodes pop without losing the offline-as-ghost
1856
+ // information. The dashed stroke + smaller radius already
1857
+ // say "offline"; dimming the whole group strengthens the
1858
+ // online-vs-offline hierarchy at first glance. Exempt the
1859
+ // chat-focused node — if the user explicitly opened a
1860
+ // popover targeting an offline alias, that node stays
1861
+ // full-brightness so the focus ring + popover read as one
1862
+ // selected thing rather than a dimmed selection. Group-
1863
+ // hover fade (Round 8) still wins when inFocus is false.
1864
+ // Round 49 / Loop: edge-hover endpoint highlight composes
1865
+ // OVER the inFocus/online formula — a non-endpoint node
1866
+ // dims to 0.28 (just below the inFocus 0.32 to read as a
1867
+ // stronger "not relevant" signal), endpoints keep their
1868
+ // base opacity. chatAlias still wins to keep the focus
1869
+ // ring legible if the user clicked through.
1870
+ // Round 55 / Loop: legend-status hover composes on the
1871
+ // SAME level as edge-hover-endpoint. Non-matching nodes
1872
+ // dim to 0.28; matching nodes stay at their base.
1873
+ // activeStatus matches: working = status==='working',
1874
+ // idle = online but not working, offline = !isOnline.
1875
+ // Round 60 / Loop: activeStatus = hoveredStatus ?? pinnedStatus
1876
+ // so the pressure-bar segment pins (R60) and the legend
1877
+ // row hover (R55) feed the same branch.
1878
+ opacity: hoveredEdgeEndpoints && !hoveredEdgeEndpoints.has(session.alias) && chatAlias !== session.alias
1879
+ ? 0.28
1880
+ : activeStatus && chatAlias !== session.alias && !(
1881
+ activeStatus === 'working' ? session.status === 'working'
1882
+ : activeStatus === 'idle' ? (isOnline && session.status !== 'working')
1883
+ : /* offline */ !isOnline
1884
+ )
1885
+ ? 0.28
1886
+ : !inFocus
1887
+ ? 0.32
1888
+ : chatAlias === session.alias
1889
+ ? 1
1890
+ : isOnline ? 1 : 0.6,
1891
+ // Round 9 / Loop: stagger the anet-fade-in so the topology
1892
+ // reveals as a wave on first paint instead of one big pop.
1893
+ // Cap at 24 indices (≈600ms tail) so 50-node fleets still
1894
+ // finish revealing within a beat. CSS animation-delay only
1895
+ // applies during the keyframe — re-renders without a new
1896
+ // mount (same alias key) don't replay, so status changes
1897
+ // never trigger the stagger again.
1898
+ animationDelay: `${Math.min(nodeIdx, 24) * 25}ms`,
1899
+ // Round 51 / Loop: hover micro-lift. The label already
1900
+ // lifts on group-hover (R26). The node body — circle,
1901
+ // avatar, status ring — stays planted, so the gesture
1902
+ // reads "label moves, body doesn't". Now the whole <g>
1903
+ // translates -2px when hovered: avatar + ring + label
1904
+ // move as one unit. 2 px is well inside the inter-row
1905
+ // gap (cellH headroom ≥22), and CSS transform on SVG
1906
+ // <g> never affects the overlap-test geometry (the
1907
+ // test never simulates hover). useReducedMotion drops
1908
+ // the lift to 0 for prefers-reduced-motion users.
1909
+ transform: !reducedMotion && hoveredAlias === session.alias ? 'translateY(-2px)' : undefined,
1910
+ transition: 'transform 180ms cubic-bezier(0.4,0,0.2,1)',
1911
+ }}
1010
1912
  // Stop the pointerdown from reaching the SVG pan handler: the
1011
1913
  // SVG calls setPointerCapture, and a captured pointer makes
1012
1914
  // Chromium fire the follow-up `click` on the SVG instead of
@@ -1014,21 +1916,106 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1014
1916
  // Side effect (intended): you pan from empty canvas, not by
1015
1917
  // grabbing a node.
1016
1918
  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)}
1919
+ // Track hover globally (not just under denseLayout) so the
1920
+ // Round 8 group-focus fade works at any fleet size.
1921
+ onPointerEnter={() => setHoveredAlias(session.alias)}
1922
+ onPointerLeave={() => setHoveredAlias(prev => (prev === session.alias ? null : prev))}
1923
+ onClick={() => {
1924
+ setChatAlias(session.alias);
1925
+ // Round 14 ripple — capture position, radius and status
1926
+ // colour at click time so the one-shot circle is self-
1927
+ // contained (no re-render-time recomputation needed).
1928
+ setClickRipple({
1929
+ ts: Date.now(),
1930
+ x: pos.x, y: pos.y, r0: radius,
1931
+ color: status.primary,
1932
+ });
1933
+ setTimeout(() => setClickRipple(prev =>
1934
+ prev && Date.now() - prev.ts >= 590 ? null : prev), 600);
1935
+ }}
1020
1936
  >
1021
1937
  {/* Issue #96: native hover tooltip — "Vendor · model · Runtime".
1022
1938
  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 && (
1939
+ model/runtime.
1940
+ Round 33 / Loop: cwd line answers "what is this agent on?".
1941
+ Round 34 / Loop: for offline nodes, append "last seen: 6m ago"
1942
+ so the operator knows whether to wait or chase. Online nodes
1943
+ skip the line (Round 27's 1 h ghost age-out means anything
1944
+ still online has heartbeated recently — the line would just
1945
+ be noise). Accepts both ISO ("…T06:00:28Z") and SQL-style
1946
+ ("… 06:00:28" assumed UTC) formats. */}
1947
+ {(() => {
1948
+ // Round 35 / Loop: TZ-safe parsing via the shared lib helper
1949
+ // — same parseHubTime is used by isGhost above (Round 38
1950
+ // factored it to app/lib/time.ts so both paths interpret SQL
1951
+ // bare timestamps as UTC on every browser).
1952
+ const lastSeen = !isOnline && session.last_seen_at ? relativeAgo(session.last_seen_at) : null;
1953
+ return (
1954
+ <title>{[
1955
+ identityLine(session.model, session.runtime) || session.alias,
1956
+ session.project_dir ? `cwd: ${session.project_dir}` : null,
1957
+ lastSeen ? `last seen: ${lastSeen}` : null,
1958
+ ].filter(Boolean).join('\n')}</title>
1959
+ );
1960
+ })()}
1961
+ {/* Round 2 / Loop: hover ring — a thin outer stroke that fades
1962
+ in when the cursor enters the node, signalling clickability
1963
+ (real-user feedback for the chat-popover open). Pure CSS via
1964
+ Tailwind group-hover, so it costs nothing per frame and
1965
+ respects prefers-reduced-motion via the global media query. */}
1966
+ <circle
1967
+ cx={pos.x}
1968
+ cy={pos.y}
1969
+ r={radius + 12}
1970
+ fill="none"
1971
+ stroke={status.primary}
1972
+ // strokeWidth must NOT be 1.5 (offline status ring) or 3
1973
+ // (online status ring) — the overlap test selects by those
1974
+ // exact widths and would mis-count this invisible hover
1975
+ // ring as a node footprint.
1976
+ strokeWidth="2"
1977
+ className="opacity-0 group-hover:opacity-70 transition-opacity duration-150"
1978
+ style={{ pointerEvents: 'none' }}
1979
+ />
1980
+ {/* Round 11 / Loop: chat-focus ring — when the ChatPopover is
1981
+ open targeting this node, anchor a persistent ring around
1982
+ it so the floating popover visibly links back to its source
1983
+ node. Static (not pulsing) so it reads as "selected state"
1984
+ rather than "this node is active". strokeWidth=2.5 stays
1985
+ clear of the overlap-test selectors (1.5 / 3). Sits just
1986
+ outside the halo radius+8 so it never overlaps a neighbour
1987
+ (halos already pack flush in dense grids). */}
1988
+ {chatAlias === session.alias && (
1989
+ <circle
1990
+ cx={pos.x}
1991
+ cy={pos.y}
1992
+ r={radius + 14}
1993
+ fill="none"
1994
+ stroke={status.primary}
1995
+ strokeWidth="2.5"
1996
+ opacity={isLight ? 0.85 : 0.95}
1997
+ filter={!isLight ? 'url(#topo-glow)' : undefined}
1998
+ className="transition-opacity duration-200"
1999
+ style={{ pointerEvents: 'none' }}
2000
+ />
2001
+ )}
2002
+ {isActive && !reducedMotion && (
1026
2003
  <circle cx={pos.x} cy={pos.y} r={radius + 14} fill={status.primary} opacity={isLight ? 0.08 : 0.12}>
1027
2004
  <animate attributeName="r" values={`${radius + 8};${radius + 22};${radius + 8}`} dur="2.4s" repeatCount="indefinite" />
1028
2005
  <animate attributeName="opacity" values={isLight ? '0.12;0.02;0.12' : '0.18;0.04;0.18'} dur="2.4s" repeatCount="indefinite" />
1029
2006
  </circle>
1030
2007
  )}
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)} />
2008
+ {/* Round 4 / Loop: transition-[fill,stroke,opacity] smooths
2009
+ status colour changes so idle↔working↔offline doesn't snap
2010
+ — task replies / node-rename / SSE updates ease in. */}
2011
+ <circle
2012
+ cx={pos.x}
2013
+ cy={pos.y}
2014
+ r={radius + 8}
2015
+ fill={status.halo}
2016
+ opacity={isOnline ? (isLight ? 0.85 : 0.65) : (isLight ? 0.4 : 0.25)}
2017
+ className="transition-[fill,opacity] duration-300 ease-out"
2018
+ />
1032
2019
  <circle
1033
2020
  cx={pos.x}
1034
2021
  cy={pos.y}
@@ -1038,6 +2025,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1038
2025
  strokeWidth={isOnline ? 3 : 1.5}
1039
2026
  strokeDasharray={isOnline ? 'none' : '5 5'}
1040
2027
  filter={isOnline && !isLight ? 'url(#topo-glow)' : undefined}
2028
+ className="transition-[fill,stroke] duration-300 ease-out"
1041
2029
  />
1042
2030
  {/* Issue #96: node "avatar" is now driven by the model
1043
2031
  vendor. Decision order:
@@ -1050,7 +2038,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1050
2038
  4. unknown vendor / null model → the prefix-group
1051
2039
  hue-hashed initial (#83/#99 behaviour, unchanged). */}
1052
2040
  {(() => {
1053
- const ar = isOnline ? 14 : 10;
2041
+ const ar = Math.round((isOnline ? 14 : 10) * nodeScale);
1054
2042
  const size = radius * 2;
1055
2043
  const vendor = vendorForModel(session.model);
1056
2044
  const internByAlias = /书生|书小生|intern/i.test(session.alias);
@@ -1075,7 +2063,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1075
2063
  <circle cx={pos.x} cy={pos.y} r={ar} fill={vendor.mono.bg} stroke={vendor.mono.ring} strokeWidth="1" />
1076
2064
  <text
1077
2065
  x={pos.x} y={pos.y} dy="0.34em" textAnchor="middle"
1078
- fill={vendor.mono.text} fontSize={isOnline ? 14 : 10}
2066
+ fill={vendor.mono.text} fontSize={ar}
1079
2067
  fontFamily="monospace" fontWeight="700"
1080
2068
  >
1081
2069
  {vendor.initial}
@@ -1095,7 +2083,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1095
2083
  dy="0.34em"
1096
2084
  textAnchor="middle"
1097
2085
  fill={c.text}
1098
- fontSize={isOnline ? 14 : 10}
2086
+ fontSize={ar}
1099
2087
  fontFamily="monospace"
1100
2088
  fontWeight="700"
1101
2089
  >
@@ -1124,78 +2112,314 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1124
2112
  </g>
1125
2113
  );
1126
2114
  })()}
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
- )}
2115
+ {session.status === 'working' && (() => {
2116
+ // Round 24 / Loop: pulse rate traffic. The dot's dur
2117
+ // was a flat 1.1s every working node looked equally
2118
+ // busy. Mapping it to sse:N lets the eye land on what's
2119
+ // actually hot. 3 discrete tiers read cleaner than
2120
+ // continuous easing at a glance; thresholds picked so
2121
+ // typical 1-2-SSE Claude/Codex sessions stay in the
2122
+ // "active" tier and only multi-pane orchestrators pop
2123
+ // into the "busy" tier. Geometry unchanged (r=2.5).
2124
+ const sse = sseCountFor ?? 0;
2125
+ const dur = sse >= 4 ? '0.7s' : sse >= 2 ? '0.9s' : '1.2s';
2126
+ return (
2127
+ <circle cx={pos.x} cy={pos.y - (radius - 6)} r="2.5" fill={pal.flowParticle} data-pulse-dur={dur} opacity={reducedMotion ? 0.6 : undefined}>
2128
+ {!reducedMotion && (
2129
+ <animate attributeName="opacity" values="1;0.25;1" dur={dur} repeatCount="indefinite" />
2130
+ )}
2131
+ </circle>
2132
+ );
2133
+ })()}
1136
2134
 
1137
2135
  {/* Round 98 (issue #61): label rect 124px → 100px.
1138
2136
  Round 109/110 (Vincent P0): full opaque card below the
1139
2137
  density threshold / on hover / when zoomed; otherwise a
1140
2138
  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}` : ''}
2139
+ labelled without an opaque box covering its neighbours.
2140
+ Round 15 / Loop: when nodeScale=S the node shrinks 30%
2141
+ but the label card was staying full-size, so the label
2142
+ visually outweighed the small node. Tighten the card
2143
+ frame, alias / sub fontSize, drop-offset and truncate
2144
+ length specifically for S; M and L keep their existing
2145
+ sizes (M ≈ L for labels by design — the S user is the
2146
+ one who explicitly asked for a denser view). */}
2147
+ {(() => {
2148
+ const isSmall = nodeScale < 0.8;
2149
+ const cardW = isSmall ? 88 : 100;
2150
+ const cardH = isSmall ? 36 : 42;
2151
+ const cardTopY = isSmall ? -12 : -14;
2152
+ const aliasFs = isSmall ? 11 : 12;
2153
+ const subFs = isSmall ? 8 : 9;
2154
+ const subY = isSmall ? 15 : 17;
2155
+ const dropY = isSmall ? 18 : 22;
2156
+ const fullMax = isSmall ? 11 : 12;
2157
+ const denseFs = isSmall ? 9 : 10;
2158
+ const denseDrop = isSmall ? 12 : 14;
2159
+ // Round 26 / Loop: micro-lift the label group on hover —
2160
+ // 1.5 px upward, 200 ms ease. Pairs with the Round 18
2161
+ // group-box hover-accent treatment to give the same
2162
+ // "this is the focused element" feedback at the per-
2163
+ // node level. CSS transform stacks onto the SVG
2164
+ // positioning transform attribute (SVG 2 cascade);
2165
+ // bbox is unchanged at rest, so the overlap-test gate
2166
+ // continues to see the geometric layout it expects.
2167
+ return showFullLabel ? (
2168
+ <g transform={`translate(${pos.x}, ${pos.y + radius + dropY})`} style={{ pointerEvents: 'none' }}
2169
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]">
2170
+ <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} />
2171
+ <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize={aliasFs} fontFamily="monospace" fontWeight="700">
2172
+ {truncate(session.alias, fullMax)}
2173
+ </text>
2174
+ <text x="0" y={subY} textAnchor="middle" fill={status.primary} fontSize={subFs} fontFamily="monospace">
2175
+ {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
2176
+ </text>
2177
+ </g>
2178
+ ) : (
2179
+ <text
2180
+ x={pos.x}
2181
+ y={pos.y + radius + denseDrop}
2182
+ textAnchor="middle"
2183
+ fill={status.text}
2184
+ fontSize={denseFs}
2185
+ fontFamily="monospace"
2186
+ fontWeight="700"
2187
+ opacity={0.9}
2188
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]"
2189
+ style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
2190
+ stroke={pal.containerBg}
2191
+ strokeWidth="3"
2192
+ >
2193
+ {truncate(session.alias, isSmall ? 9 : 10)}
1150
2194
  </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
- )}
2195
+ );
2196
+ })()}
1169
2197
  </g>
1170
2198
  );
1171
2199
  })}
1172
2200
 
2201
+ {/* Round 14 / Loop: click ripple — one-shot expanding ring from
2202
+ the clicked node, ~500ms, status-coloured. Sits inside the
2203
+ zoom/pan <g> so it scales / pans with the topology. Keyed by
2204
+ ts so a re-click on any node (same or different) remounts the
2205
+ <circle> and the SMIL <animate> elements replay from t=0.
2206
+ strokeWidth=2 doesn't match the overlap-test selectors. */}
2207
+ {clickRipple && !reducedMotion && (
2208
+ <circle
2209
+ key={clickRipple.ts}
2210
+ cx={clickRipple.x}
2211
+ cy={clickRipple.y}
2212
+ r={clickRipple.r0 + 4}
2213
+ fill="none"
2214
+ stroke={clickRipple.color}
2215
+ strokeWidth="2"
2216
+ opacity="0"
2217
+ style={{ pointerEvents: 'none' }}
2218
+ >
2219
+ <animate attributeName="r" values={`${clickRipple.r0 + 4};${clickRipple.r0 + 30}`} dur="0.5s" fill="freeze" />
2220
+ <animate attributeName="opacity" values="0.7;0" dur="0.5s" fill="freeze" />
2221
+ </circle>
2222
+ )}
2223
+
2224
+ </g>
2225
+
2226
+ {/* #112: overlay panels (recent-signal + legend) render OUTSIDE the
2227
+ zoom/pan <g> so they stay fixed while the topology pans/zooms.
2228
+ They're sized + tucked into the top corners so every corner of
2229
+ each panel is >325px from the canvas centre — i.e. fully outside
2230
+ the outermost (offline) ring. No node on any ring can reach the
2231
+ corner triangles, so the panels never overlap a node, in ring
2232
+ OR grid layout (Vincent 4727 zero-overlap criterion). */}
1173
2233
  {/* 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)}
2234
+ {/* Round 57 / Loop: drop-shadow on the panel rects gives them
2235
+ card-like elevation, especially on light theme where the
2236
+ near-white fill on a near-white canvas read as pasted-on.
2237
+ data-topo-panel-elevation tag so the test can verify both
2238
+ panels carry the filter. The filter is on the rect, not
2239
+ the parent <g>, so it doesn't shadow the rows + text inside
2240
+ only the panel chrome lifts. */}
2241
+ <g transform="translate(16, 16)">
2242
+ <rect
2243
+ x="0" y="0" width="230" height="84" rx="10"
2244
+ fill={pal.legendBox.fill}
2245
+ stroke={pal.legendBox.stroke}
2246
+ opacity={isLight ? 0.97 : 0.92}
2247
+ 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))' }}
2248
+ data-topo-panel-elevation="recent"
2249
+ />
2250
+ <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700">recent signal</text>
2251
+ <text x="150" y="21" fill={pal.legendAccent} fontSize="10" fontFamily="monospace">{messages.length} msgs</text>
2252
+ {/* Round 45 / Loop: empty state. The panel used to render
2253
+ "recent signal" + "0 msgs" with three blank slots below
2254
+ when no flow yet — read as "broken" rather than "quiet".
2255
+ A muted centred placeholder makes the empty state
2256
+ deliberate. Messages count CAN diverge from flowLinks
2257
+ count (raw count vs. deduped pairs), so the placeholder
2258
+ fires on flowLinks.length=0 specifically. */}
2259
+ {flowLinks.length === 0 ? (
2260
+ <text x="115" y="60" textAnchor="middle" fill={pal.legendText} fontSize="10" fontFamily="monospace" fontStyle="italic" opacity={0.65} data-recent-signal-empty>
2261
+ no flow yet
1181
2262
  </text>
1182
- ))}
2263
+ ) : (
2264
+ // Round 56 / Loop: each row is a navigator into the canvas.
2265
+ // Hover a row → set hoveredEdgeKey, which the existing R50
2266
+ // edge-focus + R49 endpoint-highlight ladders consume. The
2267
+ // matching flow edge brightens to 2× + thickens, its two
2268
+ // endpoint nodes stay full opacity, and every other edge +
2269
+ // non-endpoint node dims. Released → all restore. Wrapping
2270
+ // <g> + a transparent 218×14 hitbox so the cursor doesn't
2271
+ // have to land precisely on the truncated text.
2272
+ flowLinks.slice(0, 3).map((link, index) => (
2273
+ <g
2274
+ key={link.key}
2275
+ data-recent-row={link.key}
2276
+ style={{ cursor: 'pointer' }}
2277
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
2278
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
2279
+ >
2280
+ <rect x="6" y={38 + index * 16 - 10} width="218" height="14" fill="transparent" />
2281
+ <text
2282
+ x="13" y={38 + index * 16}
2283
+ fill={hoveredEdgeKey === link.key ? pal.legendHeadline : pal.legendText}
2284
+ fontSize="9"
2285
+ fontFamily="monospace"
2286
+ style={{ transition: 'fill 150ms ease-out' }}
2287
+ >
2288
+ {truncate(link.from, 6)} {'->'} {truncate(link.to, 6)} / {link.count} / {truncate(link.content, 12)}
2289
+ </text>
2290
+ </g>
2291
+ ))
2292
+ )}
1183
2293
  </g>
1184
2294
 
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>
2295
+ {/* legend — Round 55 / Loop: each status row is now a hover
2296
+ target. Pointer enter sets `hoveredStatus`; pointer leave
2297
+ clears it. Node opacity formula composes the match below.
2298
+ The row text brightens to legendHeadline while hovered as
2299
+ a small affordance hint. Geometry unchanged — the new
2300
+ <g> wrappers only carry pointer handlers. */}
2301
+ <g transform="translate(760, 16)">
2302
+ {/* R57: matching drop-shadow elevation to the legend panel. */}
2303
+ <rect
2304
+ x="0" y="0" width="224" height="96" rx="10"
2305
+ fill={pal.legendBox.fill}
2306
+ stroke={pal.legendBox.stroke}
2307
+ opacity={isLight ? 0.97 : 0.92}
2308
+ 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))' }}
2309
+ data-topo-panel-elevation="legend"
2310
+ />
2311
+ {([
2312
+ { key: 'working' as const, y0: 24, y1: 28, fill: isLight ? '#059669' : '#22c55e', label: 'working node' },
2313
+ { key: 'idle' as const, y0: 48, y1: 52, fill: isLight ? '#0d9488' : '#2dd4bf', label: 'online idle' },
2314
+ { key: 'offline' as const, y0: 72, y1: 76, fill: isLight ? '#94a3b8' : '#6b7280', label: 'offline / no SSE' },
2315
+ ]).map(row => {
2316
+ // Round 61 / Loop: legend rows pin too — symmetric with the
2317
+ // R60 pressure-bar segments. R55 hover stays transient; the
2318
+ // new onClick toggles pinnedStatus so users can lock a
2319
+ // filter without holding the cursor still. Pinned row gets
2320
+ // an inset ring on the swatch (same vocab as R60).
2321
+ const isPinned = pinnedStatus === row.key;
2322
+ return (
2323
+ <g
2324
+ key={row.key}
2325
+ data-legend-status={row.key}
2326
+ role="button"
2327
+ tabIndex={0}
2328
+ aria-pressed={isPinned}
2329
+ style={{ cursor: 'pointer' }}
2330
+ // R61: stopPropagation on pointerdown so the SVG-level
2331
+ // pan handler (R103) doesn't setPointerCapture and
2332
+ // redirect the follow-up click away from this <g>.
2333
+ // Same trick the node <g> uses (and R52 hub).
2334
+ onPointerDown={(e) => e.stopPropagation()}
2335
+ onMouseEnter={() => setHoveredStatus(row.key)}
2336
+ onMouseLeave={() => setHoveredStatus(prev => prev === row.key ? null : prev)}
2337
+ onClick={() => setPinnedStatus(prev => prev === row.key ? null : row.key)}
2338
+ >
2339
+ {/* invisible hitbox covers the row so cursor doesn't need
2340
+ to be exactly on the 5-px swatch */}
2341
+ <rect x="6" y={row.y0 - 12} width="170" height="22" fill="transparent" />
2342
+ <circle cx="16" cy={row.y0} r="5.5" fill={row.fill} />
2343
+ {/* R61 pinned-state ring — concentric stroke at r=8 in
2344
+ the row colour, draws OUTSIDE the swatch so it
2345
+ doesn't fight the fill colour the user is matching. */}
2346
+ {isPinned && (
2347
+ <circle cx="16" cy={row.y0} r="8" fill="none" stroke={row.fill} strokeWidth="1.5" />
2348
+ )}
2349
+ <text
2350
+ x="30" y={row.y1}
2351
+ fill={hoveredStatus === row.key || isPinned ? pal.legendHeadline : pal.legendText}
2352
+ fontSize="11"
2353
+ fontFamily="monospace"
2354
+ style={{ transition: 'fill 150ms ease-out' }}
2355
+ >{row.label}</text>
2356
+ </g>
2357
+ );
2358
+ })}
2359
+ {/* Flow-arrow swatch sits at y=72 — same row as the offline
2360
+ hitbox. Drop its pointerEvents so the offline legend row
2361
+ stays hoverable (R55). It's decoration, no need to
2362
+ receive events. */}
2363
+ <path d="M140,72 Q164,48 196,72" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" style={{ pointerEvents: 'none' }} />
1196
2364
  </g>
1197
2365
  </svg>
1198
2366
 
2367
+ {/* Round 30 / Loop: minimap. Big fleets in fullscreen mode at high
2368
+ zoom let users lose their position — the minimap shows the
2369
+ whole topology miniaturised plus a viewport rectangle so the
2370
+ user always knows where they are. Click anywhere to recenter
2371
+ the canvas there. Only mounted when the view is non-default
2372
+ (zoomed or panned) since at 1× centered the minimap and the
2373
+ canvas show the same thing. HTML overlay so it stays fixed
2374
+ while the SVG transforms. */}
2375
+ {(() => {
2376
+ const isDefaultView = Math.abs(view.zoom - 1) < 0.01 && Math.abs(view.x) < 1 && Math.abs(view.y) < 1;
2377
+ if (isDefaultView || (onlineNodes.length + offlineNodes.length) === 0) return null;
2378
+ const MW = 120, MH = 82;
2379
+ const sx = MW / VIEWBOX_W, sy = MH / VIEWBOX_H;
2380
+ const rectX = (-view.x / view.zoom) * sx;
2381
+ const rectY = (-view.y / view.zoom) * sy;
2382
+ const rectW = (VIEWBOX_W / view.zoom) * sx;
2383
+ const rectH = (VIEWBOX_H / view.zoom) * sy;
2384
+ return (
2385
+ <div
2386
+ className="absolute right-3 rounded-md border shadow-lg shadow-black/30 overflow-hidden anet-fade-in"
2387
+ style={{ bottom: 56, background: pal.legendBox.fill, borderColor: pal.containerBorder, cursor: 'crosshair' }}
2388
+ role="img"
2389
+ aria-label="Topology minimap — click to recenter"
2390
+ title="Minimap · click to recenter"
2391
+ onClick={(e) => {
2392
+ const r = e.currentTarget.getBoundingClientRect();
2393
+ const fx = (e.clientX - r.left) / r.width;
2394
+ const fy = (e.clientY - r.top) / r.height;
2395
+ setView(prev => ({
2396
+ ...prev,
2397
+ x: VIEWBOX_W / 2 - fx * VIEWBOX_W * prev.zoom,
2398
+ y: VIEWBOX_H / 2 - fy * VIEWBOX_H * prev.zoom,
2399
+ }));
2400
+ }}
2401
+ data-topo-minimap
2402
+ >
2403
+ <svg width={MW} height={MH} viewBox={`0 0 ${MW} ${MH}`} style={{ display: 'block' }}>
2404
+ {[...onlineNodes, ...offlineNodes].map(s => {
2405
+ const p = nodePositions[s.alias];
2406
+ if (!p) return null;
2407
+ const sseN = (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
2408
+ const isOn = s.status !== 'offline' || !!sseN;
2409
+ const st = nodeStatus(s, isOn, isLight);
2410
+ 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} />;
2411
+ })}
2412
+ {/* viewport rectangle */}
2413
+ <rect
2414
+ x={Math.max(0, rectX)} y={Math.max(0, rectY)}
2415
+ width={Math.max(0, Math.min(MW - Math.max(0, rectX), rectW))}
2416
+ height={Math.max(0, Math.min(MH - Math.max(0, rectY), rectH))}
2417
+ fill="none" stroke={pal.legendAccent} strokeWidth="1" opacity="0.9"
2418
+ />
2419
+ </svg>
2420
+ </div>
2421
+ );
2422
+ })()}
1199
2423
  {/* Round 103 (issue #81): zoom / pan / fullscreen controls — HTML
1200
2424
  overlay so they stay fixed while the SVG content transforms.
1201
2425
  Round 104: Vincent 实测 — the reset action used to be hidden
@@ -1203,6 +2427,26 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1203
2427
  Split into a plain % readout + an explicit reset button with
1204
2428
  its own icon + tooltip. */}
1205
2429
  <div className="absolute bottom-3 right-3 flex items-center gap-1.5 text-xs select-none">
2430
+ {/* #113: node size — S / M / L segmented control (Vincent 4727). */}
2431
+ <div
2432
+ className="flex items-center rounded-md border overflow-hidden"
2433
+ style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder }}
2434
+ role="group"
2435
+ aria-label="Node size"
2436
+ >
2437
+ {([['S', 0.7], ['M', 0.84], ['L', 1]] as const).map(([lbl, v], idx) => (
2438
+ <button
2439
+ key={lbl}
2440
+ onClick={() => pickNodeScale(v)}
2441
+ aria-pressed={nodeScale === v}
2442
+ title={`Node size: ${lbl === 'S' ? 'small' : lbl === 'M' ? 'medium' : 'large'}`}
2443
+ className={`px-2 py-1 transition-colors ${idx > 0 ? 'border-l' : ''} ${nodeScale === v ? 'bg-cyan-500/15 text-cyan-300' : 'hover:bg-white/5'}`}
2444
+ style={{ color: nodeScale === v ? undefined : pal.legendText, borderColor: pal.containerBorder }}
2445
+ >
2446
+ {lbl}
2447
+ </button>
2448
+ ))}
2449
+ </div>
1206
2450
  <div
1207
2451
  className="flex items-center rounded-md border overflow-hidden"
1208
2452
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder }}
@@ -1212,6 +2456,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1212
2456
  className="px-2 py-1 hover:bg-white/5 transition-colors"
1213
2457
  style={{ color: pal.legendText }}
1214
2458
  aria-label="Zoom out"
2459
+ title="Zoom out (−)"
1215
2460
  >
1216
2461
  <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
2462
  </button>
@@ -1227,6 +2472,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1227
2472
  className="px-2 py-1 hover:bg-white/5 transition-colors"
1228
2473
  style={{ color: pal.legendText }}
1229
2474
  aria-label="Zoom in"
2475
+ title="Zoom in (+)"
1230
2476
  >
1231
2477
  <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
2478
  </button>
@@ -1236,7 +2482,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1236
2482
  className="p-1.5 rounded-md border hover:bg-white/5 transition-colors"
1237
2483
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
1238
2484
  aria-label="Reset view"
1239
- title="Reset zoom + pan (or double-click the canvas)"
2485
+ title="Reset zoom + pan (0, or double-click the canvas)"
1240
2486
  >
1241
2487
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1242
2488
  <path d="M3 12a9 9 0 1 0 9-9 9 9 0 0 0-6.4 2.6L3 8" />