@sleep2agi/agent-network-dashboard 0.4.6-preview.6 → 0.4.6-preview.61

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 (855) 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/{0w1zwt8kq_7nb.js → 0ku0fjqlm9mca.js} +1 -1
  518. package/.next/static/chunks/0mqwn-l7wtcdo.js +7 -0
  519. package/.next/static/chunks/0rxrez_cjn.eh.js +1 -0
  520. package/.next/static/chunks/0tbpxur_zad6c.js +2 -0
  521. package/.next/static/chunks/0th9~0rwf_hld.js +1 -0
  522. package/.next/static/chunks/0tvn2l1pc.h65.js +1 -0
  523. package/.next/static/chunks/0u3b93reo9xci.js +1 -0
  524. package/.next/static/chunks/0up.3f137t~2-.js +1 -0
  525. package/.next/static/chunks/0x8x.29hkju5a.js +1 -0
  526. package/.next/static/chunks/127kw7~wsnyu7.css +2 -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 +18 -0
  537. package/app/components/ServersDrawer.tsx +206 -0
  538. package/app/components/TopoGraph.tsx +1200 -160
  539. package/app/components/types.ts +3 -0
  540. package/app/components/utils.ts +8 -7
  541. package/app/lib/time.ts +36 -0
  542. package/package.json +1 -1
  543. package/scripts/chat-popover-test.mjs +2 -0
  544. package/scripts/chat-theme-test.mjs +1 -0
  545. package/scripts/help-overlay-topo-test.mjs +44 -0
  546. package/scripts/servers-drawer-live-test.mjs +96 -0
  547. package/scripts/topo-active-links-chip-test.mjs +73 -0
  548. package/scripts/topo-arrow-tier-test.mjs +61 -0
  549. package/scripts/topo-auto-fit-test.mjs +73 -0
  550. package/scripts/topo-chat-focus-ring-test.mjs +77 -0
  551. package/scripts/topo-click-ripple-test.mjs +66 -0
  552. package/scripts/topo-cwd-tooltip-test.mjs +64 -0
  553. package/scripts/topo-dblclick-reset-test.mjs +74 -0
  554. package/scripts/topo-edge-endpoint-highlight-test.mjs +111 -0
  555. package/scripts/topo-edge-focus-test.mjs +113 -0
  556. package/scripts/topo-edge-hover-test.mjs +93 -0
  557. package/scripts/topo-edge-tooltip-test.mjs +70 -0
  558. package/scripts/topo-fit-key-test.mjs +106 -0
  559. package/scripts/topo-freshness-chip-test.mjs +74 -0
  560. package/scripts/topo-group-count-test.mjs +63 -0
  561. package/scripts/topo-group-hover-test.mjs +82 -0
  562. package/scripts/topo-groupbox-test.mjs +118 -0
  563. package/scripts/topo-hub-click-fit-test.mjs +137 -0
  564. package/scripts/topo-hub-title-test.mjs +93 -0
  565. package/scripts/topo-ingroup-edge-test.mjs +103 -0
  566. package/scripts/topo-label-lift-test.mjs +119 -0
  567. package/scripts/topo-label-scale-test.mjs +78 -0
  568. package/scripts/topo-lastseen-tooltip-test.mjs +59 -0
  569. package/scripts/topo-layout-key-test.mjs +91 -0
  570. package/scripts/topo-layout-test.mjs +1 -0
  571. package/scripts/topo-minimap-test.mjs +68 -0
  572. package/scripts/topo-mobile-chips-test.mjs +103 -0
  573. package/scripts/topo-node-hover-lift-test.mjs +85 -0
  574. package/scripts/topo-offline-dim-test.mjs +70 -0
  575. package/scripts/topo-overlap-test.mjs +164 -0
  576. package/scripts/topo-p0round-test.mjs +166 -0
  577. package/scripts/topo-pan-cursor-test.mjs +62 -0
  578. package/scripts/topo-popover-meta-test.mjs +78 -0
  579. package/scripts/topo-pressure-bar-test.mjs +69 -0
  580. package/scripts/topo-pulse-rate-test.mjs +71 -0
  581. package/scripts/topo-recent-signal-empty-test.mjs +63 -0
  582. package/scripts/topo-reduced-motion-test.mjs +77 -0
  583. package/scripts/topo-rename-test.mjs +1 -0
  584. package/scripts/topo-tier-ring-test.mjs +69 -0
  585. package/scripts/topo-tz-ghost-test.mjs +69 -0
  586. package/scripts/topo-vendor-test.mjs +1 -0
  587. package/scripts/topo-wheel-zoom-test.mjs +79 -0
  588. package/scripts/topo-zoom-keys-test.mjs +89 -0
  589. package/.next/build/chunks/[turbopack-node]_transforms_postcss_ts_06e.r3r._.js +0 -13
  590. package/.next/build/chunks/node_modules_13sb.px._.js.map +0 -47
  591. package/.next/server/chunks/[root-of-the-server]__0-mykaf._.js +0 -3
  592. package/.next/server/chunks/[root-of-the-server]__0-mykaf._.js.map +0 -1
  593. package/.next/server/chunks/[root-of-the-server]__0.d_kqy._.js +0 -3
  594. package/.next/server/chunks/[root-of-the-server]__0.d_kqy._.js.map +0 -1
  595. package/.next/server/chunks/[root-of-the-server]__02pocyg._.js +0 -3
  596. package/.next/server/chunks/[root-of-the-server]__02pocyg._.js.map +0 -1
  597. package/.next/server/chunks/[root-of-the-server]__03ueci3._.js +0 -3
  598. package/.next/server/chunks/[root-of-the-server]__04aud7x._.js +0 -3
  599. package/.next/server/chunks/[root-of-the-server]__04aud7x._.js.map +0 -1
  600. package/.next/server/chunks/[root-of-the-server]__05fnt3n._.js +0 -3
  601. package/.next/server/chunks/[root-of-the-server]__05fnt3n._.js.map +0 -1
  602. package/.next/server/chunks/[root-of-the-server]__077tsd.._.js +0 -20
  603. package/.next/server/chunks/[root-of-the-server]__077tsd.._.js.map +0 -1
  604. package/.next/server/chunks/[root-of-the-server]__08wy1jr._.js +0 -3
  605. package/.next/server/chunks/[root-of-the-server]__08wy1jr._.js.map +0 -1
  606. package/.next/server/chunks/[root-of-the-server]__0adhu1m._.js +0 -3
  607. package/.next/server/chunks/[root-of-the-server]__0adhu1m._.js.map +0 -1
  608. package/.next/server/chunks/[root-of-the-server]__0aqv02v._.js +0 -3
  609. package/.next/server/chunks/[root-of-the-server]__0aqv02v._.js.map +0 -1
  610. package/.next/server/chunks/[root-of-the-server]__0cawhfq._.js +0 -3
  611. package/.next/server/chunks/[root-of-the-server]__0cawhfq._.js.map +0 -1
  612. package/.next/server/chunks/[root-of-the-server]__0d_-_k.._.js +0 -3
  613. package/.next/server/chunks/[root-of-the-server]__0d_-_k.._.js.map +0 -1
  614. package/.next/server/chunks/[root-of-the-server]__0f6bxfl._.js +0 -3
  615. package/.next/server/chunks/[root-of-the-server]__0f6bxfl._.js.map +0 -1
  616. package/.next/server/chunks/[root-of-the-server]__0h15edq._.js +0 -3
  617. package/.next/server/chunks/[root-of-the-server]__0h15edq._.js.map +0 -1
  618. package/.next/server/chunks/[root-of-the-server]__0k.vegl._.js +0 -3
  619. package/.next/server/chunks/[root-of-the-server]__0k.vegl._.js.map +0 -1
  620. package/.next/server/chunks/[root-of-the-server]__0kltbce._.js +0 -3
  621. package/.next/server/chunks/[root-of-the-server]__0kltbce._.js.map +0 -1
  622. package/.next/server/chunks/[root-of-the-server]__0mn04fw._.js +0 -3
  623. package/.next/server/chunks/[root-of-the-server]__0mn04fw._.js.map +0 -1
  624. package/.next/server/chunks/[root-of-the-server]__0o29v8c._.js +0 -3
  625. package/.next/server/chunks/[root-of-the-server]__0o4qfn9._.js +0 -3
  626. package/.next/server/chunks/[root-of-the-server]__0o7ya7r._.js +0 -3
  627. package/.next/server/chunks/[root-of-the-server]__0o7ya7r._.js.map +0 -1
  628. package/.next/server/chunks/[root-of-the-server]__0p3fo6l._.js +0 -6
  629. package/.next/server/chunks/[root-of-the-server]__0p3fo6l._.js.map +0 -1
  630. package/.next/server/chunks/[root-of-the-server]__0p6tutt._.js +0 -3
  631. package/.next/server/chunks/[root-of-the-server]__0pn3wsx._.js +0 -3
  632. package/.next/server/chunks/[root-of-the-server]__0pn3wsx._.js.map +0 -1
  633. package/.next/server/chunks/[root-of-the-server]__0rg9ifx._.js +0 -3
  634. package/.next/server/chunks/[root-of-the-server]__0x.-194._.js +0 -3
  635. package/.next/server/chunks/[root-of-the-server]__0x.-194._.js.map +0 -1
  636. package/.next/server/chunks/[root-of-the-server]__0x.a_yh._.js +0 -3
  637. package/.next/server/chunks/[root-of-the-server]__0x.a_yh._.js.map +0 -1
  638. package/.next/server/chunks/[root-of-the-server]__0~h2uqi._.js +0 -3
  639. package/.next/server/chunks/[root-of-the-server]__139~d6a._.js +0 -3
  640. package/.next/server/chunks/[root-of-the-server]__139~d6a._.js.map +0 -1
  641. package/.next/server/chunks/_next-internal_server_app_api_anet_config_route_actions_0xzy73j.js +0 -3
  642. package/.next/server/chunks/_next-internal_server_app_api_auth_login_route_actions_0zukc38.js +0 -3
  643. package/.next/server/chunks/_next-internal_server_app_api_auth_logout_route_actions_0fm~3ij.js +0 -3
  644. package/.next/server/chunks/_next-internal_server_app_api_auth_v3_route_actions_0ndzs5l.js +0 -3
  645. package/.next/server/chunks/_next-internal_server_app_api_hub_audit-log_route_actions_11k8fqt.js +0 -3
  646. package/.next/server/chunks/_next-internal_server_app_api_hub_auth_route_actions_0gs7ttk.js +0 -3
  647. package/.next/server/chunks/_next-internal_server_app_api_hub_broadcast_route_actions_0ads-kd.js +0 -3
  648. package/.next/server/chunks/_next-internal_server_app_api_hub_events_route_actions_0opktby.js +0 -3
  649. package/.next/server/chunks/_next-internal_server_app_api_hub_health_route_actions_0tzzt~x.js +0 -3
  650. package/.next/server/chunks/_next-internal_server_app_api_hub_inbox_route_actions_0xvslrg.js +0 -3
  651. package/.next/server/chunks/_next-internal_server_app_api_hub_license_route_actions_0i3tlr3.js +0 -3
  652. package/.next/server/chunks/_next-internal_server_app_api_hub_messages_route_actions_08-gi-s.js +0 -3
  653. package/.next/server/chunks/_next-internal_server_app_api_hub_networks_route_actions_02vejq2.js +0 -3
  654. package/.next/server/chunks/_next-internal_server_app_api_hub_nodes_route_actions_0nx8ryn.js +0 -3
  655. package/.next/server/chunks/_next-internal_server_app_api_hub_register_route_actions_086wi_3.js +0 -3
  656. package/.next/server/chunks/_next-internal_server_app_api_hub_send_route_actions_0-ga_ug.js +0 -3
  657. package/.next/server/chunks/_next-internal_server_app_api_hub_server-logs_route_actions_00euexv.js +0 -3
  658. package/.next/server/chunks/_next-internal_server_app_api_hub_session_route_actions_041wa62.js +0 -3
  659. package/.next/server/chunks/_next-internal_server_app_api_hub_stats_route_actions_1242~od.js +0 -3
  660. package/.next/server/chunks/_next-internal_server_app_api_hub_status_route_actions_0aml09a.js +0 -3
  661. package/.next/server/chunks/_next-internal_server_app_api_hub_task-events_route_actions_02v1tmm.js +0 -3
  662. package/.next/server/chunks/_next-internal_server_app_api_hub_tasks_route_actions_0nl77v4.js +0 -3
  663. package/.next/server/chunks/_next-internal_server_app_api_hub_tmux_route_actions_0u~aaza.js +0 -3
  664. package/.next/server/chunks/_next-internal_server_app_api_hub_tokens_route_actions_09hzf_1.js +0 -3
  665. package/.next/server/chunks/_next-internal_server_app_favicon_ico_route_actions_095lj93.js +0 -3
  666. package/.next/server/chunks/node_modules_next_11synfn._.js +0 -10
  667. package/.next/server/chunks/node_modules_next_11synfn._.js.map +0 -1
  668. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_0yubv2d.js +0 -3
  669. package/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_0yubv2d.js.map +0 -1
  670. package/.next/server/chunks/ssr/[root-of-the-server]__014uja_._.js +0 -3
  671. package/.next/server/chunks/ssr/[root-of-the-server]__014uja_._.js.map +0 -1
  672. package/.next/server/chunks/ssr/[root-of-the-server]__02hi65f._.js +0 -3
  673. package/.next/server/chunks/ssr/[root-of-the-server]__02hi65f._.js.map +0 -1
  674. package/.next/server/chunks/ssr/[root-of-the-server]__02xqa5z._.js +0 -3
  675. package/.next/server/chunks/ssr/[root-of-the-server]__02xqa5z._.js.map +0 -1
  676. package/.next/server/chunks/ssr/[root-of-the-server]__04ph1de._.js +0 -3
  677. package/.next/server/chunks/ssr/[root-of-the-server]__04ph1de._.js.map +0 -1
  678. package/.next/server/chunks/ssr/[root-of-the-server]__05wc5-z._.js +0 -3
  679. package/.next/server/chunks/ssr/[root-of-the-server]__05wc5-z._.js.map +0 -1
  680. package/.next/server/chunks/ssr/[root-of-the-server]__06-2p1a._.js +0 -33
  681. package/.next/server/chunks/ssr/[root-of-the-server]__06-2p1a._.js.map +0 -1
  682. package/.next/server/chunks/ssr/[root-of-the-server]__08qr2ji._.js.map +0 -1
  683. package/.next/server/chunks/ssr/[root-of-the-server]__09l8xaa._.js +0 -3
  684. package/.next/server/chunks/ssr/[root-of-the-server]__09l8xaa._.js.map +0 -1
  685. package/.next/server/chunks/ssr/[root-of-the-server]__09z7o2x._.js +0 -19
  686. package/.next/server/chunks/ssr/[root-of-the-server]__09z7o2x._.js.map +0 -1
  687. package/.next/server/chunks/ssr/[root-of-the-server]__0a0ffmv._.js +0 -3
  688. package/.next/server/chunks/ssr/[root-of-the-server]__0a0ffmv._.js.map +0 -1
  689. package/.next/server/chunks/ssr/[root-of-the-server]__0b7uu-_._.js +0 -3
  690. package/.next/server/chunks/ssr/[root-of-the-server]__0b7uu-_._.js.map +0 -1
  691. package/.next/server/chunks/ssr/[root-of-the-server]__0bl_wqk._.js +0 -3
  692. package/.next/server/chunks/ssr/[root-of-the-server]__0bl_wqk._.js.map +0 -1
  693. package/.next/server/chunks/ssr/[root-of-the-server]__0ft0rpu._.js +0 -3
  694. package/.next/server/chunks/ssr/[root-of-the-server]__0ft0rpu._.js.map +0 -1
  695. package/.next/server/chunks/ssr/[root-of-the-server]__0i~g-2t._.js +0 -3
  696. package/.next/server/chunks/ssr/[root-of-the-server]__0i~g-2t._.js.map +0 -1
  697. package/.next/server/chunks/ssr/[root-of-the-server]__0jl4ukn._.js +0 -3
  698. package/.next/server/chunks/ssr/[root-of-the-server]__0jl4ukn._.js.map +0 -1
  699. package/.next/server/chunks/ssr/[root-of-the-server]__0mook08._.js +0 -3
  700. package/.next/server/chunks/ssr/[root-of-the-server]__0mook08._.js.map +0 -1
  701. package/.next/server/chunks/ssr/[root-of-the-server]__0n46s~k._.js +0 -3
  702. package/.next/server/chunks/ssr/[root-of-the-server]__0n46s~k._.js.map +0 -1
  703. package/.next/server/chunks/ssr/[root-of-the-server]__0qchqb~._.js +0 -3
  704. package/.next/server/chunks/ssr/[root-of-the-server]__0qchqb~._.js.map +0 -1
  705. package/.next/server/chunks/ssr/[root-of-the-server]__0rrm~-q._.js +0 -3
  706. package/.next/server/chunks/ssr/[root-of-the-server]__0rrm~-q._.js.map +0 -1
  707. package/.next/server/chunks/ssr/[root-of-the-server]__0w2ay2h._.js +0 -3
  708. package/.next/server/chunks/ssr/[root-of-the-server]__0w2ay2h._.js.map +0 -1
  709. package/.next/server/chunks/ssr/[root-of-the-server]__0xjish~._.js +0 -3
  710. package/.next/server/chunks/ssr/[root-of-the-server]__0xjish~._.js.map +0 -1
  711. package/.next/server/chunks/ssr/[root-of-the-server]__0xm3h9x._.js +0 -3
  712. package/.next/server/chunks/ssr/[root-of-the-server]__0xm3h9x._.js.map +0 -1
  713. package/.next/server/chunks/ssr/[root-of-the-server]__0ywihy_._.js +0 -8
  714. package/.next/server/chunks/ssr/[root-of-the-server]__0ywihy_._.js.map +0 -1
  715. package/.next/server/chunks/ssr/[root-of-the-server]__12ad1n.._.js +0 -3
  716. package/.next/server/chunks/ssr/[root-of-the-server]__12ad1n.._.js.map +0 -1
  717. package/.next/server/chunks/ssr/_0nne7mz._.js +0 -3
  718. package/.next/server/chunks/ssr/_0nne7mz._.js.map +0 -1
  719. package/.next/server/chunks/ssr/_next-internal_server_app__global-error_page_actions_0k77kol.js +0 -3
  720. package/.next/server/chunks/ssr/_next-internal_server_app__not-found_page_actions_0eq97pa.js +0 -3
  721. package/.next/server/chunks/ssr/_next-internal_server_app_admin_page_actions_0ycnu8f.js +0 -3
  722. package/.next/server/chunks/ssr/_next-internal_server_app_login_page_actions_02kefem.js +0 -3
  723. package/.next/server/chunks/ssr/_next-internal_server_app_logs_page_actions_0rvsl~d.js +0 -3
  724. package/.next/server/chunks/ssr/_next-internal_server_app_messages_page_actions_0gsqep_.js +0 -3
  725. package/.next/server/chunks/ssr/_next-internal_server_app_node_page_actions_0wd8cgl.js +0 -3
  726. package/.next/server/chunks/ssr/_next-internal_server_app_nodes_page_actions_0-.rv67.js +0 -3
  727. package/.next/server/chunks/ssr/_next-internal_server_app_page_actions_09-gtaw.js +0 -3
  728. package/.next/server/chunks/ssr/_next-internal_server_app_server-logs_page_actions_0g1gb5c.js +0 -3
  729. package/.next/server/chunks/ssr/_next-internal_server_app_settings_networks_page_actions_09uw.sw.js +0 -3
  730. package/.next/server/chunks/ssr/_next-internal_server_app_settings_page_actions_0mr68ai.js +0 -3
  731. package/.next/server/chunks/ssr/_next-internal_server_app_settings_tokens_page_actions_0yi54x8.js +0 -3
  732. package/.next/server/chunks/ssr/_next-internal_server_app_tasks_[id]_page_actions_0k_hm9g.js +0 -3
  733. package/.next/server/chunks/ssr/_next-internal_server_app_tasks_page_actions_0_2o_cr.js +0 -3
  734. package/.next/server/chunks/ssr/app_0.zbtn5._.js +0 -9
  735. package/.next/server/chunks/ssr/app_0.zbtn5._.js.map +0 -1
  736. package/.next/server/chunks/ssr/app_01y-~mp._.js +0 -3
  737. package/.next/server/chunks/ssr/app_01y-~mp._.js.map +0 -1
  738. package/.next/server/chunks/ssr/app_04n78pr._.js +0 -3
  739. package/.next/server/chunks/ssr/app_04n78pr._.js.map +0 -1
  740. package/.next/server/chunks/ssr/app_0_agk0r._.js +0 -3
  741. package/.next/server/chunks/ssr/app_0_agk0r._.js.map +0 -1
  742. package/.next/server/chunks/ssr/app_0aenxef._.js +0 -3
  743. package/.next/server/chunks/ssr/app_0aenxef._.js.map +0 -1
  744. package/.next/server/chunks/ssr/app_0aum_g5._.js +0 -3
  745. package/.next/server/chunks/ssr/app_0aum_g5._.js.map +0 -1
  746. package/.next/server/chunks/ssr/app_0emvkn2._.js +0 -3
  747. package/.next/server/chunks/ssr/app_0emvkn2._.js.map +0 -1
  748. package/.next/server/chunks/ssr/app_0esuljp._.js +0 -3
  749. package/.next/server/chunks/ssr/app_0esuljp._.js.map +0 -1
  750. package/.next/server/chunks/ssr/app_0izi28j._.js +0 -3
  751. package/.next/server/chunks/ssr/app_0izi28j._.js.map +0 -1
  752. package/.next/server/chunks/ssr/app_components_08ys5y3._.js.map +0 -1
  753. package/.next/server/chunks/ssr/app_server-logs_page_tsx_0_kpp1p._.js.map +0 -1
  754. package/.next/server/chunks/ssr/app_tasks_page_tsx_0z-d6mp._.js +0 -3
  755. package/.next/server/chunks/ssr/app_tasks_page_tsx_0z-d6mp._.js.map +0 -1
  756. package/.next/server/chunks/ssr/node_modules_09w7yel._.js.map +0 -1
  757. package/.next/server/chunks/ssr/node_modules_0ck2~9g._.js +0 -3
  758. package/.next/server/chunks/ssr/node_modules_0ck2~9g._.js.map +0 -1
  759. package/.next/server/chunks/ssr/node_modules_0r_.s40._.js.map +0 -1
  760. package/.next/server/chunks/ssr/node_modules_0vtdjw7._.js +0 -3
  761. package/.next/server/chunks/ssr/node_modules_0vtdjw7._.js.map +0 -1
  762. package/.next/server/chunks/ssr/node_modules_next_0w2y7fz._.js +0 -6
  763. package/.next/server/chunks/ssr/node_modules_next_0w2y7fz._.js.map +0 -1
  764. package/.next/server/chunks/ssr/node_modules_next_dist_0e1izl_._.js +0 -6
  765. package/.next/server/chunks/ssr/node_modules_next_dist_0e1izl_._.js.map +0 -1
  766. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_0inhx6q._.js +0 -3
  767. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_0inhx6q._.js.map +0 -1
  768. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_forbidden_0ghu-f7.js +0 -3
  769. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_forbidden_0ghu-f7.js.map +0 -1
  770. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_global-error_0lgvd_..js +0 -3
  771. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_global-error_0lgvd_..js.map +0 -1
  772. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_unauthorized_0cjv-23.js +0 -3
  773. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_unauthorized_0cjv-23.js.map +0 -1
  774. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_002l7yi.js.map +0 -1
  775. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_02suzhc.js.map +0 -1
  776. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_07uu-xq.js.map +0 -1
  777. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0d2yq5t.js.map +0 -1
  778. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0dm~kj8.js.map +0 -1
  779. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0foejvu.js.map +0 -1
  780. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0l-fh0i.js.map +0 -1
  781. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0nsg22r.js.map +0 -1
  782. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0o9vt1n.js.map +0 -1
  783. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0qm1-a1.js.map +0 -1
  784. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0uccf9z.js.map +0 -1
  785. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0uv6jyc.js.map +0 -1
  786. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_10~~o-h.js +0 -4
  787. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_10~~o-h.js.map +0 -1
  788. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_13leyhk.js +0 -4
  789. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_13leyhk.js.map +0 -1
  790. package/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_13s07li.js.map +0 -1
  791. package/.next/static/chunks/0-hlcuoemhe7w.js +0 -7
  792. package/.next/static/chunks/0257pdz1-imal.js +0 -2
  793. package/.next/static/chunks/02_zpo7taex9o.js +0 -7
  794. package/.next/static/chunks/06n3d~fbbtagk.js +0 -1
  795. package/.next/static/chunks/07eba1lz2u3p4.js +0 -1
  796. package/.next/static/chunks/09vpkiy8u1fd9.js +0 -1
  797. package/.next/static/chunks/0_rmxom9~1lmk.css +0 -2
  798. package/.next/static/chunks/0av2~0.96~d~g.js +0 -1
  799. package/.next/static/chunks/0cduea_g_p_y..js +0 -1
  800. package/.next/static/chunks/0e-.zgw81gela.js +0 -31
  801. package/.next/static/chunks/0g9aof0ob_fcj.js +0 -1
  802. package/.next/static/chunks/0j6t.m1a6._pk.js +0 -1
  803. package/.next/static/chunks/0rtpz4a-9r6ab.js +0 -1
  804. package/.next/static/chunks/0s8jo6jypzkx5.js +0 -1
  805. package/.next/static/chunks/0u359llqm1_01.js +0 -1
  806. package/.next/static/chunks/0ubqqhhpsaez4.js +0 -1
  807. package/.next/static/chunks/0~qw9kxksr1v2.js +0 -1
  808. package/.next/static/chunks/12gprw3l0kvvf.js +0 -1
  809. package/.next/static/chunks/12yuo353giomx.js +0 -1
  810. package/.next/static/chunks/14bsg3_-taiw4.js +0 -1
  811. /package/.next/build/chunks/{[root-of-the-server]__0ubbtyl._.js.map → [root-of-the-server]__0tgc-8p._.js.map} +0 -0
  812. /package/.next/build/chunks/{[turbopack-node]_transforms_postcss_ts_06e.r3r._.js.map → [turbopack-node]_transforms_postcss_ts_0r4x5ys._.js.map} +0 -0
  813. /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
  814. /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
  815. /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
  816. /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
  817. /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
  818. /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
  819. /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
  820. /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
  821. /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
  822. /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
  823. /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
  824. /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
  825. /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
  826. /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
  827. /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
  828. /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
  829. /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
  830. /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
  831. /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
  832. /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
  833. /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
  834. /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
  835. /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
  836. /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
  837. /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
  838. /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
  839. /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
  840. /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
  841. /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
  842. /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
  843. /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
  844. /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
  845. /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
  846. /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
  847. /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
  848. /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
  849. /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
  850. /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
  851. /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
  852. /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
  853. /package/.next/static/{5BgbN6YWZJU6fk3Z0cE8n → Obkvt9kZ4jS596ZVOuup0}/_buildManifest.js +0 -0
  854. /package/.next/static/{5BgbN6YWZJU6fk3Z0cE8n → Obkvt9kZ4jS596ZVOuup0}/_clientMiddlewareManifest.js +0 -0
  855. /package/.next/static/{5BgbN6YWZJU6fk3Z0cE8n → Obkvt9kZ4jS596ZVOuup0}/_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 () => {
@@ -336,6 +482,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
336
482
  flowLinks,
337
483
  activeAliases,
338
484
  groupKeys,
485
+ groupBoxes,
486
+ gridContentBottom,
339
487
  } = useMemo(() => {
340
488
  const sseCount = (s: { alias: string; network_id?: string }) =>
341
489
  (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
@@ -345,36 +493,151 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
345
493
  // index, so contiguous-in-array becomes contiguous-in-ring, i.e.
346
494
  // each team visually clusters. localeCompare keeps CJK ordering sane.
347
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
+ };
348
516
  const online = sessions.filter(s => s.status !== 'offline' || sseCount(s)).sort(byAlias);
349
- 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);
350
518
  const positions: Record<string, Point> = {};
351
519
 
352
520
  if (layout === 'grid') {
353
- // Issue #87: N×M grid layout. All nodes share one sorted array (online
354
- // first, then offline) so same-prefix aliases land in adjacent cells
355
- // (#83 prefix-grouping synergy). cols = ⌈√N⌉; the last partial row is
356
- // centred. Grid spans an inset box so node labels don't clip the edge.
521
+ // Issue #87 + #111: group-banded grid. Nodes are sorted by alias so
522
+ // same-prefix aliases are adjacent; each multi-member prefix group then
523
+ // gets its OWN row(s) starting at column 0, while singletons pack into
524
+ // shared rows. Group-banded placement keeps every group's bounding box
525
+ // (#111) a clean rectangle — no box ever overlaps another group's.
357
526
  const all = [...online, ...offline];
358
- const n = all.length;
359
- const cols = Math.max(1, Math.ceil(Math.sqrt(n)));
360
- const rows = Math.max(1, Math.ceil(n / cols));
361
- const gx0 = 150, gx1 = 850, gy0 = 120, gy1 = 560;
527
+ const groupKeys = computeGroups(all);
528
+ const cols = Math.max(1, Math.ceil(Math.sqrt(all.length)));
529
+ // #112: gy0 starts below the (now compact) recent-signal / legend
530
+ // overlay panels — their max bottom edge is y≈112 — so grid row 0 is
531
+ // never occluded; gy1 extends near the canvas bottom for breathing room.
532
+ const gx0 = 150, gx1 = 850, gy0 = 126, gy1 = 652;
362
533
  const cellW = (gx1 - gx0) / cols;
363
- const cellH = (gy1 - gy0) / rows;
364
- all.forEach((s, i) => {
365
- const row = Math.floor(i / cols);
366
- const col = i % cols;
367
- const rowCount = row === rows - 1 ? n - row * cols : cols;
368
- const rowInset = ((cols - rowCount) * cellW) / 2;
369
- positions[s.alias] = {
370
- x: gx0 + rowInset + (col + 0.5) * cellW,
371
- y: gy0 + (row + 0.5) * cellH,
372
- };
373
- });
534
+ // largest node radius at the current size scale — drives the group
535
+ // label band + the row-height floor so nothing ever overlaps.
536
+ const nodeR = Math.round(26 * nodeScale);
537
+
538
+ // ordered runs of consecutive same-group-key nodes (≥2 = real group)
539
+ const runs: { key: string; members: Session[] }[] = [];
540
+ for (const s of all) {
541
+ const gk = groupKeys[s.alias];
542
+ const last = runs[runs.length - 1];
543
+ if (last && last.key === gk) last.members.push(s);
544
+ else runs.push({ key: gk, members: [s] });
545
+ }
546
+
547
+ // Pass 1 — assign each run to a band: a multi-member group owns its
548
+ // rows (left-aligned, so its bounding box is a tidy rect); contiguous
549
+ // singletons pack into shared rows (centred). Collect total row count.
550
+ type Band = { members: Session[]; startRow: number; centred: boolean; isGroup: boolean };
551
+ const bands: Band[] = [];
552
+ let row = 0;
553
+ let i = 0;
554
+ while (i < runs.length) {
555
+ if (runs[i].members.length >= 2) {
556
+ bands.push({ members: runs[i].members, startRow: row, centred: false, isGroup: true });
557
+ row += Math.ceil(runs[i].members.length / cols);
558
+ i++;
559
+ } else {
560
+ const singles: Session[] = [];
561
+ while (i < runs.length && runs[i].members.length < 2) {
562
+ singles.push(runs[i].members[0]);
563
+ i++;
564
+ }
565
+ bands.push({ members: singles, startRow: row, centred: true, isGroup: false });
566
+ row += Math.ceil(singles.length / cols);
567
+ }
568
+ }
569
+ const totalRows = Math.max(1, row);
570
+ // #112: the group label sits in a band ABOVE the topmost node, so the
571
+ // band must clear the node radius — GROUP_TOP is node-relative, never
572
+ // cellH-derived (cellH-derived was the label↔node overlap bug Vincent
573
+ // hit). cellH then floors at GROUP_TOP + 30 so the label band + bottom
574
+ // padding always fit and stacked group boxes never touch; past the
575
+ // floor the grid overflows and zoom/pan handles it.
576
+ const GROUP_TOP = nodeR + 20;
577
+ // Round 27 / P0 (Vincent screenshot preview.29): dense plain-text
578
+ // node labels (`pos.y + radius + denseDrop`, with a 3 px containerBg
579
+ // stroke halo for readability) at the BOTTOM row of band N would
580
+ // paint OVER the start of band N+1's group label, creating the
581
+ // "blueleap → eleap", "agent-network-dashboard → t-network / board"
582
+ // visual chopping. Geometry of the collision:
583
+ // dense label visual bottom = node_N.y + radius + denseDrop + halo
584
+ // group label glyph top = boxY + 4 (text y=boxY+14, ~10px ascent)
585
+ // = node_N+1.y - GROUP_TOP + 4
586
+ // node_N+1.y - node_N.y = cellH for consecutive rows, so no-collide:
587
+ // cellH ≥ radius + denseDrop + halo + GROUP_TOP - 4 + buffer
588
+ // With halo=3 buffer=4: cellH ≥ nodeR + denseDrop + GROUP_TOP + 3.
589
+ const denseDrop = nodeScale < 0.8 ? 12 : 14;
590
+ const cellH = Math.max(
591
+ 2 * nodeR + 22, // node + dense label within band
592
+ GROUP_TOP + 12, // group-label band + box padding
593
+ nodeR + denseDrop + GROUP_TOP + 3, // round-27 dense↔group label clearance
594
+ Math.min(100, (gy1 - gy0) / totalRows),
595
+ );
596
+
597
+ // Pass 2 — place each band's members.
598
+ for (const band of bands) {
599
+ band.members.forEach((s, idx) => {
600
+ const rowInBand = Math.floor(idx / cols);
601
+ const c = idx % cols;
602
+ const inRow = Math.min(cols, band.members.length - rowInBand * cols);
603
+ const inset = band.centred ? ((cols - inRow) * cellW) / 2 : 0;
604
+ positions[s.alias] = {
605
+ x: gx0 + inset + (c + 0.5) * cellW,
606
+ y: gy0 + (band.startRow + rowInBand + 0.5) * cellH,
607
+ };
608
+ });
609
+ }
610
+
374
611
  const links = buildFlowLinks(messages, positions);
375
612
  const active = new Set<string>();
376
613
  links.forEach(link => { active.add(link.from); active.add(link.to); });
377
- const groupKeys = computeGroupKeys([...online, ...offline].map(s => s.alias));
614
+ // #111: one bounding box per multi-member group (Vincent 4722). Each
615
+ // group owns its rows; GROUP_PAD fills the row space left below the
616
+ // nodes after the label band, so stacked group boxes always have a
617
+ // gap between them (GROUP_TOP is defined above, with cellH).
618
+ const GROUP_PAD = Math.max(8, Math.min(26, cellH - GROUP_TOP - 8)); // side/bottom
619
+ const groupBoxes = bands
620
+ .filter(b => b.isGroup)
621
+ .map(band => {
622
+ const pts = band.members.map(s => positions[s.alias]).filter(Boolean);
623
+ const xs = pts.map(p => p.x);
624
+ const ys = pts.map(p => p.y);
625
+ const minX = Math.min(...xs), minY = Math.min(...ys);
626
+ return {
627
+ key: band.members.length ? groupKeys[band.members[0].alias] : '',
628
+ count: band.members.length,
629
+ x: minX - GROUP_PAD,
630
+ y: minY - GROUP_TOP,
631
+ w: Math.max(...xs) - minX + GROUP_PAD * 2,
632
+ h: Math.max(...ys) - minY + GROUP_TOP + GROUP_PAD,
633
+ };
634
+ });
635
+ // Round 28 / Loop: surface the grid's natural content bottom so the
636
+ // mount effect can auto-fit zoom when the layout would overflow the
637
+ // viewBox. = bottom of the last node row + its label drop + a small
638
+ // breathing buffer. Round 27's cellH bump makes this overflow more
639
+ // common (30-node fleets reach ~774 px, viewBox is 680).
640
+ const gridContentBottom = gy0 + totalRows * cellH + 8;
378
641
  return {
379
642
  onlineNodes: online,
380
643
  offlineNodes: offline,
@@ -382,6 +645,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
382
645
  flowLinks: links,
383
646
  activeAliases: active,
384
647
  groupKeys,
648
+ groupBoxes,
649
+ gridContentBottom,
385
650
  };
386
651
  }
387
652
 
@@ -452,7 +717,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
452
717
  });
453
718
 
454
719
  // Round 106 (issue #83): group key per alias → shared hue per team.
455
- const groupKeys = computeGroupKeys([...online, ...offline].map(s => s.alias));
720
+ const groupKeys = computeGroups([...online, ...offline]);
456
721
 
457
722
  return {
458
723
  onlineNodes: online,
@@ -461,15 +726,50 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
461
726
  flowLinks: links,
462
727
  activeAliases: active,
463
728
  groupKeys,
729
+ // #111: group boxes are a grid-layout feature only — radially scattered
730
+ // ring nodes can't be cleanly boxed. Ring keeps the #83 prefix hue.
731
+ groupBoxes: [] as { key: string; count: number; x: number; y: number; w: number; h: number }[],
732
+ // ring fits within VIEWBOX_H by construction (offlineRadius=325 + centre at y=330)
733
+ gridContentBottom: 0,
464
734
  };
465
- }, [messages, sessions, sseSessions, layout]);
735
+ }, [messages, sessions, sseSessions, layout, nodeScale]);
466
736
 
467
737
  const workingCount = onlineNodes.filter(s => s.status === 'working').length;
738
+ // Round 6 / Loop: vendor distribution for the header chip — at a glance
739
+ // "what's in the fleet" (A:5 M:2 O:8 书:12 …) without opening a node.
740
+ // Sorted by count desc; "unknown" vendors collapse into a "?" bucket.
741
+ const vendorDist = useMemo(() => {
742
+ const tally = new Map<string, { initial: string; count: number; color: string }>();
743
+ for (const s of [...onlineNodes, ...offlineNodes]) {
744
+ const v = vendorForModel(s.model);
745
+ const key = v.id === 'unknown' ? '?' : v.initial;
746
+ const cur = tally.get(key);
747
+ if (cur) cur.count++;
748
+ else tally.set(key, { initial: key, count: 1, color: v.mono.text });
749
+ }
750
+ return [...tally.values()].sort((a, b) => b.count - a.count);
751
+ }, [onlineNodes, offlineNodes]);
468
752
  // Round 109 (Vincent 4582 P0): hover-gated labels above this node count
469
753
  // so dense fleets show clean avatars instead of a wall of overlapping
470
754
  // label cards. 16 ≈ where the triple-tier rings start to crowd.
471
755
  const denseLayout = onlineNodes.length + offlineNodes.length > 16;
472
756
  const [hoveredAlias, setHoveredAlias] = useState<string | null>(null);
757
+ // Round 8 / Loop: which group is currently focused. Nodes outside this
758
+ // group + other group boxes fade so the eye locks onto the team you're
759
+ // pointing at. Singletons use their own alias as the group key.
760
+ const hoveredGroup = hoveredAlias ? (groupKeys[hoveredAlias] ?? hoveredAlias) : null;
761
+ // Round 49 / Loop: reverse-direction of R40's edge-on-node-hover linkage.
762
+ // R48 widened the flow hitbox to 16 px, so edges are precise enough to
763
+ // serve as a state trigger. When the user hovers a flow edge, light up its
764
+ // two endpoint nodes and dim the rest — "who is this edge between" becomes
765
+ // visible without reading the tooltip. The set is the link's two aliases
766
+ // (null when no edge hovered); node opacity composes this after inFocus.
767
+ const [hoveredEdgeKey, setHoveredEdgeKey] = useState<string | null>(null);
768
+ const hoveredEdgeEndpoints = useMemo<Set<string> | null>(() => {
769
+ if (!hoveredEdgeKey) return null;
770
+ const link = flowLinks.find(l => l.key === hoveredEdgeKey);
771
+ return link ? new Set([link.from, link.to]) : null;
772
+ }, [hoveredEdgeKey, flowLinks]);
473
773
 
474
774
  // --- Round 103 (issue #81): fullscreen + zoom + pan interaction layer ---
475
775
  // DIY native (no d3 / svg-pan-zoom): wrap the topology content in a single
@@ -489,6 +789,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
489
789
  // Issue #100: singleton chat popover. One alias at a time — clicking
490
790
  // another node swaps the target and the conversation switches in place.
491
791
  const [chatAlias, setChatAlias] = useState<string | null>(null);
792
+ // Round 14 / Loop: one-shot click ripple — fades outward from the clicked
793
+ // node so the click registers physically before the popover snaps in.
794
+ // Pairs with the Round 11 chat-focus ring: ripple expands, ring locks on.
795
+ // Keyed by ts so re-clicking the same node remounts the <circle> and the
796
+ // SMIL <animate> replays. Cleared 600ms after click (longer than the
797
+ // 500ms animation so a final frame at opacity 0 is still in the tree).
798
+ const [clickRipple, setClickRipple] = useState<{
799
+ ts: number; x: number; y: number; r0: number; color: string;
800
+ } | null>(null);
492
801
  // #84: when a node is renamed while its chat popover is open, follow the
493
802
  // rename so the conversation keeps targeting a live alias.
494
803
  useEffect(() => {
@@ -519,6 +828,42 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
519
828
  } catch {}
520
829
  }, []);
521
830
 
831
+ // Round 28 / Loop: first-paint auto-fit. Round 27's cellH bump means
832
+ // dense grids (≥6-7 rows) overflow the 680-px viewBox at the natural
833
+ // 100% zoom — a new user sees the topology with its bottom rows
834
+ // clipped and no hint that there's more below. Auto-fit on first
835
+ // paint (and only if the user has no persisted view) sets the
836
+ // initial zoom so all content fits. Subsequent zoom changes persist
837
+ // and override the auto-fit on reload; explicit reset (0 key) still
838
+ // goes back to 100% so the gesture's "reset to natural size" semantic
839
+ // is preserved.
840
+ //
841
+ // Capture pre-mount persistence in a useState initializer — the
842
+ // existing `persist` effect (declared below) runs on first render
843
+ // with the default {1,0,0} view and writes it to localStorage before
844
+ // the auto-fit effect (deps on async-arriving sessions) gets a turn.
845
+ // Reading localStorage from the effect would see that write and
846
+ // skip the fit. The useState snapshot fires once, before any effects.
847
+ const [hadPersistedViewOnMount] = useState<boolean>(
848
+ () => typeof window !== 'undefined' && !!localStorage.getItem('anet-topo-view'),
849
+ );
850
+ const autoFitDoneRef = useRef(false);
851
+ useEffect(() => {
852
+ if (autoFitDoneRef.current) return;
853
+ if (hadPersistedViewOnMount) {
854
+ autoFitDoneRef.current = true;
855
+ return;
856
+ }
857
+ if (layout !== 'grid' || sessions.length === 0 || !gridContentBottom) return;
858
+ if (gridContentBottom <= VIEWBOX_H) {
859
+ autoFitDoneRef.current = true; // no overflow → no fit needed
860
+ return;
861
+ }
862
+ const fitZoom = Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
863
+ setView({ zoom: fitZoom, x: 0, y: 0 });
864
+ autoFitDoneRef.current = true;
865
+ }, [layout, sessions.length, gridContentBottom, hadPersistedViewOnMount]);
866
+
522
867
  // persist view
523
868
  useEffect(() => {
524
869
  try { localStorage.setItem('anet-topo-view', JSON.stringify(view)); } catch {}
@@ -538,6 +883,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
538
883
  const svg = svgRef.current;
539
884
  if (!svg) return;
540
885
  const onWheel = (e: WheelEvent) => {
886
+ // Round 23 / Loop: only zoom on Ctrl/Meta+wheel when inline; plain
887
+ // wheel over the canvas keeps scrolling the page (the topo sits at
888
+ // the top of /; trapping page scroll mid-page is the classic
889
+ // "scroll-jail" anti-pattern). In fullscreen the canvas owns the
890
+ // viewport so any wheel zooms — no page scroll to preserve. Pinch-
891
+ // zoom on a trackpad surfaces as ctrlKey=true natively, which is
892
+ // also what we want (zoom the canvas, not the browser).
893
+ if (!isFullscreen && !e.ctrlKey && !e.metaKey) return;
541
894
  e.preventDefault();
542
895
  const rect = svg.getBoundingClientRect();
543
896
  const mx = ((e.clientX - rect.left) / rect.width) * VIEWBOX_W;
@@ -558,8 +911,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
558
911
  };
559
912
  svg.addEventListener('wheel', onWheel, { passive: false });
560
913
  return () => svg.removeEventListener('wheel', onWheel);
561
- }, []);
562
-
914
+ // Re-attach when the fullscreen flag toggles so the handler's
915
+ // closure picks up the new value (capture-by-closure means we'd
916
+ // otherwise read the stale boolean forever).
917
+ }, [isFullscreen]);
918
+
919
+ // Round 21 / Loop: pan-aware cursor. Static `grab` gave no tactile cue
920
+ // that the canvas was actually being dragged — "grabbing" while
921
+ // dragRef.current.active = true tells the hand it's moving the
922
+ // viewport. Mirroring dragRef.active into state is the only way to
923
+ // re-render the SVG style; the ref itself doesn't trigger renders.
924
+ const [isPanning, setIsPanning] = useState(false);
563
925
  const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
564
926
  if (e.button !== 0) return;
565
927
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
@@ -570,6 +932,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
570
932
  baseX: viewRef.current.x,
571
933
  baseY: viewRef.current.y,
572
934
  };
935
+ setIsPanning(true);
573
936
  };
574
937
  const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
575
938
  const d = dragRef.current;
@@ -584,6 +947,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
584
947
  const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
585
948
  if (!dragRef.current.active) return;
586
949
  dragRef.current.active = false;
950
+ setIsPanning(false);
587
951
  try {
588
952
  (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
589
953
  } catch {}
@@ -601,6 +965,48 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
601
965
  };
602
966
  const resetView = () => setView({ zoom: 1, x: 0, y: 0 });
603
967
 
968
+ // Round 29 / Loop: `f` = fit-to-content. Shared by the Round 28
969
+ // first-paint auto-fit effect and the keyboard handler so the math is
970
+ // in one place. When content already fits at natural zoom, this is
971
+ // effectively a "recenter" — `f` always lands on a known good view.
972
+ const fitView = useCallback(() => {
973
+ const zoom = !gridContentBottom || gridContentBottom <= VIEWBOX_H
974
+ ? 1
975
+ : Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
976
+ setView({ zoom, x: 0, y: 0 });
977
+ }, [gridContentBottom]);
978
+
979
+ // Round 22 / Loop: keyboard zoom — +/= zoom in, - zoom out, 0 reset.
980
+ // Round 29 / Loop: +f to fit content.
981
+ // Listen on window so the user doesn't need to focus the SVG first,
982
+ // but only act when no text input has focus (otherwise typing "-" in
983
+ // a chat would zoom the topology — surprising and bad). Modifier keys
984
+ // pass through so Cmd/Ctrl combos still hit their owners. The button
985
+ // titles below document these shortcuts so users discover them.
986
+ useEffect(() => {
987
+ const onKey = (e: KeyboardEvent) => {
988
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
989
+ const ae = document.activeElement as HTMLElement | null;
990
+ if (ae) {
991
+ const tag = ae.tagName;
992
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || ae.isContentEditable) return;
993
+ }
994
+ if (e.key === '+' || e.key === '=') { zoomBy(1.2); e.preventDefault(); }
995
+ else if (e.key === '-' || e.key === '_') { zoomBy(1 / 1.2); e.preventDefault(); }
996
+ else if (e.key === '0') { resetView(); e.preventDefault(); }
997
+ else if (e.key === 'f' || e.key === 'F') { fitView(); e.preventDefault(); }
998
+ // Round 32 / Loop: `l` toggles ring|grid. The vim-style `g l` route
999
+ // (Audit Log) requires a preceding `g` within 1500ms; a bare `l`
1000
+ // outside that window is free for topology use.
1001
+ else if (e.key === 'l' || e.key === 'L') { toggleLayout(); e.preventDefault(); }
1002
+ };
1003
+ window.addEventListener('keydown', onKey);
1004
+ return () => window.removeEventListener('keydown', onKey);
1005
+ // zoomBy / resetView are stable wrt setView callback; fitView changes
1006
+ // with gridContentBottom so deps list catches it.
1007
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1008
+ }, [fitView]);
1009
+
604
1010
  const toggleFullscreen = () => {
605
1011
  const el = containerRef.current;
606
1012
  if (!el) return;
@@ -628,6 +1034,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
628
1034
  <button
629
1035
  onClick={() => { if (layout !== 'ring') toggleLayout(); }}
630
1036
  aria-pressed={layout === 'ring'}
1037
+ title="Ring layout (l to toggle)"
631
1038
  className={`px-2.5 py-1 transition-colors ${layout === 'ring' ? 'bg-cyan-500/15 text-cyan-300' : 'text-gray-500 hover:text-gray-400'}`}
632
1039
  >
633
1040
  Ring
@@ -635,6 +1042,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
635
1042
  <button
636
1043
  onClick={() => { if (layout !== 'grid') toggleLayout(); }}
637
1044
  aria-pressed={layout === 'grid'}
1045
+ title="Grid layout (l to toggle)"
638
1046
  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'}`}
639
1047
  >
640
1048
  Grid
@@ -646,9 +1054,74 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
646
1054
  <span className="px-2.5 py-1 rounded-md bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
647
1055
  {onlineNodes.length} online
648
1056
  </span>
649
- <span className="px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20">
650
- {flowLinks.length} active links
651
- </span>
1057
+ {/* Round 31 / Loop: fleet-health pressure bar. The "X working /
1058
+ Y online" chips already carry the raw counts; the bar lets
1059
+ the eye get the working/idle/offline RATIO in one glance
1060
+ without mental math. Stacked 3-segment chip, ~64 px wide. */}
1061
+ {(() => {
1062
+ const w = workingCount;
1063
+ const i = onlineNodes.length - workingCount; // idle = online - working
1064
+ const o = offlineNodes.length;
1065
+ const total = w + i + o;
1066
+ if (total === 0) return null;
1067
+ const seg = (n: number, color: string, key: string) => {
1068
+ if (n === 0) return null;
1069
+ return <span key={key} style={{ width: `${(n / total) * 100}%`, background: color, height: '100%' }} />;
1070
+ };
1071
+ // Round 47 / Loop: hidden on mobile — at <640px the chip row
1072
+ // wraps to multiple lines and crowds the topology header;
1073
+ // pressure ratio is best read with the working+online raw
1074
+ // counts (kept visible) anyway.
1075
+ return (
1076
+ <span
1077
+ 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"
1078
+ title={`${w} working · ${i} idle · ${o} offline`}
1079
+ data-fleet-pressure
1080
+ >
1081
+ <span className="text-[10px] tracking-wide">pressure</span>
1082
+ <span className="inline-flex h-1.5 w-16 rounded-full overflow-hidden" style={{ background: 'rgb(75 85 99 / 0.25)' }}>
1083
+ {seg(w, isLight ? '#059669' : '#22c55e', 'w')}
1084
+ {seg(i, isLight ? '#0d9488' : '#2dd4bf', 'i')}
1085
+ {seg(o, isLight ? '#94a3b8' : '#6b7280', 'o')}
1086
+ </span>
1087
+ </span>
1088
+ );
1089
+ })()}
1090
+ {vendorDist.length > 1 && (
1091
+ <span
1092
+ 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"
1093
+ title="Fleet by model vendor"
1094
+ >
1095
+ {vendorDist.map(v => (
1096
+ <span key={v.initial} className="inline-flex items-baseline gap-0.5">
1097
+ <span style={{ color: v.color }}>{v.initial}</span>
1098
+ <span className="text-gray-500">:{v.count}</span>
1099
+ </span>
1100
+ ))}
1101
+ </span>
1102
+ )}
1103
+ {/* Round 42 / Loop: extend active-links chip with the timestamp
1104
+ of the most-recent flow event. Tells the operator at a glance
1105
+ whether the topology is currently humming (last 30s) or has
1106
+ been quiet for a while — the visual flow particles and
1107
+ edge brightness only show that there IS traffic, not when
1108
+ the last one was. Reuses Round 38's relativeAgo. */}
1109
+ {(() => {
1110
+ const recent = flowLinks.reduce<number | null>((acc, l) => {
1111
+ if (!l.last_at) return acc;
1112
+ const t = parseHubTime(l.last_at);
1113
+ if (t === null) return acc;
1114
+ return acc === null || t > acc ? t : acc;
1115
+ }, null);
1116
+ const rel = recent !== null ? relativeAgo(new Date(recent).toISOString()) : null;
1117
+ return (
1118
+ <span className="hidden sm:inline px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20">
1119
+ {flowLinks.length} active link{flowLinks.length === 1 ? '' : 's'}
1120
+ {rel ? <span className="text-gray-500"> · last {rel}</span> : null}
1121
+ </span>
1122
+ );
1123
+ })()}
1124
+ <FreshnessChip sessions={sessions} />
652
1125
  </div>
653
1126
  </div>
654
1127
 
@@ -668,7 +1141,18 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
668
1141
  onPointerMove={onPointerMove}
669
1142
  onPointerUp={onPointerUp}
670
1143
  onPointerLeave={onPointerUp}
671
- style={{ cursor: 'grab', touchAction: 'none' }}
1144
+ // Round 41 / Loop: the reset-button title (R22) and the Help
1145
+ // overlay (R25) both advertise "double-click the canvas to
1146
+ // reset", but the handler was never actually wired. The text
1147
+ // was lying. Wire it here, guarded so dbl-clicking a node
1148
+ // (which would also trigger the SVG-level handler via event
1149
+ // bubbling) doesn't unexpectedly reset the view on the user.
1150
+ onDoubleClick={(e) => {
1151
+ const t = e.target as Element | null;
1152
+ if (t?.closest('g[data-node]')) return;
1153
+ resetView();
1154
+ }}
1155
+ style={{ cursor: isPanning ? 'grabbing' : 'grab', touchAction: 'none' }}
672
1156
  >
673
1157
  <defs>
674
1158
  <linearGradient id="topo-panel" x1="0" x2="1" y1="0" y2="1">
@@ -690,9 +1174,37 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
690
1174
  </feMerge>
691
1175
  </filter>
692
1176
  )}
693
- <marker id="topo-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
694
- <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
695
- </marker>
1177
+ {/* Round 16 / Loop: 3-tier flow-link arrow markers.
1178
+ The single marker had `markerUnits` defaulting to
1179
+ `strokeWidth`, so heavy edges (stroke=7) rendered 35-user-
1180
+ unit arrowheads — visually dominant, the head outweighed
1181
+ the line. Switching to `userSpaceOnUse` decouples arrow
1182
+ size from stroke; binning by count gives a clearer
1183
+ hierarchy than continuous linear scaling:
1184
+ s (count 1-2) → 12 user units
1185
+ m (count 3-4) → 16 user units (alias for `topo-arrow`)
1186
+ l (count 5+) → 22 user units
1187
+ `topo-arrow` stays bound to the medium tier so the legend
1188
+ swatch (line ~1500) renders without change. */}
1189
+ {[
1190
+ { id: 'topo-arrow-s', size: 12 },
1191
+ { id: 'topo-arrow', size: 16 },
1192
+ { id: 'topo-arrow-l', size: 22 },
1193
+ ].map(m => (
1194
+ <marker
1195
+ key={m.id}
1196
+ id={m.id}
1197
+ viewBox="0 0 10 10"
1198
+ refX="8"
1199
+ refY="5"
1200
+ markerWidth={m.size}
1201
+ markerHeight={m.size}
1202
+ markerUnits="userSpaceOnUse"
1203
+ orient="auto-start-reverse"
1204
+ >
1205
+ <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
1206
+ </marker>
1207
+ ))}
696
1208
  {/* Round 45: radial radar sweep gradient — bright at center
697
1209
  (where it meets the hub) fading to leading edge. */}
698
1210
  <radialGradient id="topo-sweep" cx="0%" cy="50%" r="100%">
@@ -712,13 +1224,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
712
1224
  grid mode drops it so the concentric rings don't sit behind a
713
1225
  rectangular grid. */}
714
1226
  {layout === 'ring' && (<>
715
- <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" />
1227
+ {/* R52: radar bg is pure decoration — drop its pointer events so
1228
+ the hub <g> under it (and any future inner-disk affordances)
1229
+ can receive clicks. Previously the r=330 disc intercepted
1230
+ the hub click outright. */}
1231
+ <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" style={{ pointerEvents: 'none' }} />
716
1232
 
717
1233
  {/* Round 45: subtle star field — 24 deterministic dots scattered
718
1234
  across the canvas give the radar bg some depth. Skipped on
719
1235
  light theme so the white surface stays clean. */}
720
1236
  {!isLight && (
721
- <g opacity="0.5">
1237
+ <g opacity="0.5" style={{ pointerEvents: 'none' }}>
722
1238
  {Array.from({ length: 28 }).map((_, i) => {
723
1239
  // Deterministic pseudo-random scatter so positions are
724
1240
  // stable between renders (no JS hydration mismatch).
@@ -740,6 +1256,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
740
1256
  style={{
741
1257
  transformOrigin: `${cx}px ${cy}px`,
742
1258
  transformBox: 'view-box',
1259
+ pointerEvents: 'none',
743
1260
  }}
744
1261
  className="anet-topo-sweep"
745
1262
  opacity={isLight ? 0.7 : 1}
@@ -750,11 +1267,44 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
750
1267
  />
751
1268
  </g>
752
1269
 
753
- {/* radar rings */}
1270
+ {/* radar rings — pure decoration at fixed radii, independent of
1271
+ node positions so the radar aesthetic is preserved across tier
1272
+ changes. */}
754
1273
  {[90, 170, 250, 330].map(radius => (
755
1274
  <circle key={radius} cx={cx} cy={cy} r={radius} fill="none" stroke={pal.ringStroke} strokeWidth="1" opacity={isLight ? 0.6 : 0.35} />
756
1275
  ))}
757
1276
 
1277
+ {/* Round 54 / Loop: tier-radius guide rings. The radar rings above
1278
+ are decorative and don't match the actual tier radii nodes sit
1279
+ on (single 220 / dual 175,260 / triple 145,215,285). Drawing
1280
+ a faint dashed ring at each ACTIVE tier radius lets the eye
1281
+ anchor "this is the inner / outer ring" without inferring from
1282
+ node spacing. Picked based on online node count so only the
1283
+ tiers currently in use draw — empty tiers stay quiet. pointer-
1284
+ events:none so they never intercept hub or node clicks. The
1285
+ 0.7 stroke + dashed pattern reads as guide, not feature. */}
1286
+ {(
1287
+ onlineNodes.length > onlineTripleThreshold
1288
+ ? [onlineTripleInnerR, onlineTripleMidR, onlineTripleOuterR]
1289
+ : onlineNodes.length > onlineTierThreshold
1290
+ ? [onlineInnerRadius, onlineOuterRadius]
1291
+ : onlineNodes.length > 0
1292
+ ? [onlineRadius]
1293
+ : []
1294
+ ).map(r => (
1295
+ <circle
1296
+ key={`tier-${r}`}
1297
+ cx={cx} cy={cy} r={r}
1298
+ fill="none"
1299
+ stroke={pal.ringStroke}
1300
+ strokeWidth="0.7"
1301
+ strokeDasharray="2 8"
1302
+ opacity={isLight ? 0.32 : 0.42}
1303
+ style={{ pointerEvents: 'none' }}
1304
+ data-tier-ring={r}
1305
+ />
1306
+ ))}
1307
+
758
1308
  {/* Round 50: 4 small particles slowly orbiting the outer ring
759
1309
  (r=330). Each starts at a different angle (offset 0/0.25/0.5/0.75
760
1310
  of the cycle) so they're evenly spaced. 16s per revolution is
@@ -820,27 +1370,173 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
820
1370
  );
821
1371
  })}
822
1372
 
1373
+ {/* #111: prefix-group boundary boxes (Vincent 4722). Grid layout
1374
+ only — groupBoxes is empty in ring mode. Rendered behind the
1375
+ flow links + nodes; pointer-events off so they never intercept
1376
+ a node click. Restrained dashed container + group-name label. */}
1377
+ {groupBoxes.map(box => {
1378
+ const isHovered = hoveredGroup === box.key;
1379
+ // Round 18 / Loop: group-box hover linkage. The Round 8 fade
1380
+ // already dropped OUT-of-focus groups to 0.28, but the IN-focus
1381
+ // group sat at its baseline appearance — no positive emphasis.
1382
+ // Hovering now upgrades the box to an "accent" treatment:
1383
+ // solid stroke (not dashed), thicker, accent-coloured; brighter
1384
+ // text and slightly stronger fill. Label and box read as one
1385
+ // selected unit. Geometry unchanged → overlap test untouched.
1386
+ return (
1387
+ <g
1388
+ key={`grp-${box.key}`}
1389
+ data-group={box.key}
1390
+ className="transition-opacity"
1391
+ style={{ pointerEvents: 'none', opacity: !hoveredGroup || isHovered ? 1 : 0.28 }}
1392
+ >
1393
+ <rect
1394
+ x={box.x}
1395
+ y={box.y}
1396
+ width={box.w}
1397
+ height={box.h}
1398
+ rx="14"
1399
+ fill={isLight ? '#0f172a' : '#a5b4fc'}
1400
+ fillOpacity={isHovered ? (isLight ? 0.05 : 0.09) : (isLight ? 0.025 : 0.045)}
1401
+ stroke={isHovered ? pal.legendAccent : pal.ringStroke}
1402
+ strokeWidth={isHovered ? 2 : 1.5}
1403
+ strokeDasharray={isHovered ? 'none' : '6 6'}
1404
+ style={{ transition: 'stroke 200ms ease-out, stroke-width 200ms ease-out, fill-opacity 200ms ease-out' }}
1405
+ />
1406
+ <text
1407
+ x={box.x + 12}
1408
+ y={box.y + 14}
1409
+ fill={isHovered ? pal.legendHeadline : pal.legendText}
1410
+ fontSize="13"
1411
+ fontFamily="monospace"
1412
+ fontWeight="700"
1413
+ style={{ transition: 'fill 200ms ease-out' }}
1414
+ >
1415
+ {box.key}
1416
+ {/* Round 19 / Loop: member-count chip. Inline tspan stays
1417
+ in the single <text> bbox the overlap test reads, so
1418
+ the node↔label guard still catches if the chip ever
1419
+ pushes the label far enough right to clip a node.
1420
+ Smaller + lighter weight reads as metadata, not name. */}
1421
+ <tspan dx="6" fill={pal.legendText} fontSize="11" fontWeight="400">· {box.count}</tspan>
1422
+ </text>
1423
+ </g>
1424
+ );
1425
+ })}
1426
+
823
1427
  {/* directed message flows */}
824
1428
  {flowLinks.map((link, index) => {
825
1429
  const from = nodePositions[link.from];
826
1430
  const to = nodePositions[link.to];
827
1431
  if (!from || !to) return null;
828
1432
 
829
- const lift = index % 2 === 0 ? 36 : -36;
1433
+ // Round 7 / Loop: lift now scales with distance so short links
1434
+ // aren't over-bent (long links keep the ~36px hump), and the
1435
+ // particle period shortens with link.count so busier edges
1436
+ // visibly pulse faster — instant info density on top of the
1437
+ // existing stroke-width-by-count chip.
1438
+ const dist = Math.hypot(to.x - from.x, to.y - from.y);
1439
+ const lift = (index % 2 === 0 ? 1 : -1) * Math.min(36, dist * 0.18);
830
1440
  const path = curvePath(from, to, lift);
831
1441
  const width = Math.min(2 + link.count, 7);
832
- const duration = 2.2 + (index % 5) * 0.32;
833
-
1442
+ const duration = Math.max(0.9, 2.6 / Math.sqrt(link.count));
1443
+ // Round 10 / Loop: freshness fade. An edge that fired ≤30s ago
1444
+ // stays at full intensity; over 5 minutes it decays to ~35%.
1445
+ // Surfaces "what's happening now" vs background chatter without
1446
+ // hiding old flow entirely (some context still useful). `now`
1447
+ // captured at useMemo-recompute time (every 5s message refresh)
1448
+ // — accuracy is within the poll interval, plenty.
1449
+ const ageMs = link.last_at ? Math.max(0, Date.now() - Date.parse(link.last_at)) : 0;
1450
+ const fresh = Math.max(0.35, 1 - ageMs / (5 * 60 * 1000));
1451
+ // Round 16 arrow-tier binning — keep `topo-arrow` as the
1452
+ // medium tier id so the legend swatch picks it up unchanged.
1453
+ const arrowId = link.count <= 2 ? 'topo-arrow-s'
1454
+ : link.count <= 4 ? 'topo-arrow'
1455
+ : 'topo-arrow-l';
1456
+
1457
+ // Round 39 / Loop: edge hover tooltip — surface the same
1458
+ // last_at + count info the freshness fade and arrow tier
1459
+ // already encode visually, in plain text. The stroke is the
1460
+ // hover target; SVG `<title>` honours newlines on every
1461
+ // browser the dashboard targets.
1462
+ const lastAt = relativeAgo(link.last_at);
1463
+ const tooltip = `${link.from} → ${link.to}\n${link.count} message${link.count === 1 ? '' : 's'}${lastAt ? ` · last ${lastAt}` : ''}`;
1464
+ // Round 40 / Loop: edges follow node hover — when an alias is
1465
+ // hovered, every edge touching it brightens, the rest fade.
1466
+ // Pairs with the Round 8 group-focus fade on nodes: hover a
1467
+ // node to find "who is this agent talking to" at a glance.
1468
+ // No hover → multiplier is 1.0 (current behaviour preserved).
1469
+ // Round 50 / Loop: edge-on-self priority. When the user hovers
1470
+ // a flow edge directly (R48 widened the hitbox so this is now
1471
+ // a precise gesture), THAT edge gets the strongest boost (2.0)
1472
+ // and every other edge dims to 0.35. Node-hover (R40) keeps
1473
+ // its own ladder. Edge-hover and node-hover are mutually
1474
+ // exclusive in practice — the cursor is over one or the
1475
+ // other — but the order below makes edge-hover win if both
1476
+ // ever read truthy at the same React tick.
1477
+ // Round 53 / Loop: in-group edges follow team focus. R40
1478
+ // brightened only edges touching the exact hovered alias —
1479
+ // but with R8/R18 prefix-clustering, a user hovering one
1480
+ // member is asking about the team. So when BOTH endpoints of
1481
+ // an edge share the hoveredGroup (and neither is the exact
1482
+ // hovered alias — that gets the stronger 1.7×), the edge
1483
+ // boosts to 1.3×. Edges leaving the team (one endpoint in,
1484
+ // one out) still dim to 0.35× per R40 since they read as
1485
+ // background to the focus. Singletons fall through unchanged
1486
+ // (their group key is the alias itself, so bothInHoveredGroup
1487
+ // is impossible for a non-self-edge).
1488
+ const isHoveredEdge = hoveredEdgeKey === link.key;
1489
+ const fromGroup = groupKeys[link.from] ?? link.from;
1490
+ const toGroup = groupKeys[link.to] ?? link.to;
1491
+ const bothInHoveredGroup = !!hoveredGroup && fromGroup === hoveredGroup && toGroup === hoveredGroup;
1492
+ const edgeOpacityMul = isHoveredEdge
1493
+ ? 2.0
1494
+ : hoveredEdgeKey
1495
+ ? 0.35
1496
+ : !hoveredAlias
1497
+ ? 1
1498
+ : (link.from === hoveredAlias || link.to === hoveredAlias)
1499
+ ? 1.7
1500
+ : bothInHoveredGroup
1501
+ ? 1.3
1502
+ : 0.35;
1503
+ // Round 50: the hovered edge also visibly thickens so the eye
1504
+ // tracks it even at low message counts (width was 3 → 4.5 on
1505
+ // hover). 1.4× is enough to read as "lifted" without
1506
+ // breaching the 16-px hitbox bound.
1507
+ const renderWidth = isHoveredEdge ? Math.min(width * 1.4, 10) : width;
834
1508
  return (
835
1509
  <g key={link.key}>
1510
+ {/* Round 48 / Loop: invisible hover hitbox — visible flow
1511
+ path is 3-7 px wide and damn hard to hover precisely.
1512
+ Stack a transparent 16-px-wide stroke behind it so the
1513
+ cursor only needs to be ~8 px from the line for the
1514
+ tooltip to fire. Native <title> moves here; the
1515
+ visible path no longer needs it. pointer-events on
1516
+ the visible path drop to "none" since the hitbox
1517
+ owns the hover surface. */}
1518
+ <path
1519
+ d={path}
1520
+ fill="none"
1521
+ stroke="transparent"
1522
+ strokeWidth={Math.max(width + 10, 16)}
1523
+ style={{ pointerEvents: 'stroke' }}
1524
+ data-edge-hitbox
1525
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
1526
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
1527
+ >
1528
+ <title>{tooltip}</title>
1529
+ </path>
836
1530
  <path
837
1531
  d={path}
838
1532
  fill="none"
839
1533
  stroke={pal.flowEdge}
840
- strokeWidth={width}
841
- opacity={isLight ? 0.22 : 0.28}
1534
+ strokeWidth={renderWidth}
1535
+ opacity={Math.min(1, (isLight ? 0.22 : 0.28) * fresh * edgeOpacityMul)}
842
1536
  filter={isLight ? undefined : 'url(#topo-glow)'}
843
- markerEnd="url(#topo-arrow)"
1537
+ markerEnd={`url(#${arrowId})`}
1538
+ className="transition-opacity duration-300"
1539
+ style={{ pointerEvents: 'none' }}
844
1540
  />
845
1541
  <path
846
1542
  id={`flow-path-${index}`}
@@ -849,37 +1545,75 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
849
1545
  stroke={pal.flowPath}
850
1546
  strokeWidth="1"
851
1547
  strokeDasharray="2 12"
852
- opacity={isLight ? 0.4 : 0.75}
1548
+ opacity={Math.min(1, (isLight ? 0.4 : 0.75) * fresh * edgeOpacityMul)}
1549
+ className="transition-opacity duration-300"
853
1550
  />
854
- <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'}>
855
- <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
856
- </circle>
1551
+ {!reducedMotion && (
1552
+ <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'} opacity={Math.min(1, fresh * edgeOpacityMul)}>
1553
+ <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
1554
+ </circle>
1555
+ )}
857
1556
  </g>
858
1557
  );
859
1558
  })}
860
1559
 
861
- {/* center hub — round 39: enlarged from r=6 (12px) to r=10 (20px)
862
- core so the "control plane" reads as the network's anchor,
863
- not a stray particle. Static halo ring at r=18 grounds it
864
- visually; two outward pulses keep the "signal source" idea
865
- from r17. Light theme gets a paler core to avoid hot-spotting
866
- the bg. */}
867
- {layout === 'ring' && (<g>
868
- {/* static grounding halo — sits underneath, low opacity */}
1560
+ {/* center hub — round 39 sized + round 13 restraint.
1561
+ The hub is the control-plane anchor. r39 gave it two outward
1562
+ pulses (r 10→38, 2.4s, double-phase) which read as "loudest
1563
+ node in the room" — wrong semantics: anchors don't emit,
1564
+ they hold. r13 swaps the kinetic pulse for a slow opacity
1565
+ breath on the static halo (4s, ±15% from base), so the hub
1566
+ stays alive without throwing kinetic energy outward. The
1567
+ dual-circle "lit lamp" core is unchanged. */}
1568
+ {layout === 'ring' && (<g
1569
+ data-topo-hub
1570
+ style={{ cursor: 'pointer' }}
1571
+ // Stop pointerdown from reaching the SVG pan handler — same
1572
+ // reason as the node <g>: a captured pointer makes Chromium
1573
+ // fire the follow-up click on the SVG, not this group.
1574
+ onPointerDown={(e) => e.stopPropagation()}
1575
+ // Round 52 / Loop: hub is the visual anchor but had no click
1576
+ // action — users discovered fit-to-content only via the `f`
1577
+ // key or the chrome button. Wire the hub to fitView() so the
1578
+ // most prominent element in the canvas is also the "re-
1579
+ // center" affordance. A click-ripple keyed by ts confirms
1580
+ // the gesture; the hub <title> now ends with a hint.
1581
+ onClick={() => {
1582
+ fitView();
1583
+ setClickRipple({ ts: Date.now(), x: cx, y: cy, r0: 18, color: isLight ? '#059669' : '#10b981' });
1584
+ setTimeout(() => setClickRipple(prev => prev && prev.x === cx && prev.y === cy ? null : prev), 600);
1585
+ }}
1586
+ >
1587
+ {/* Round 43 / Loop: hub `<title>` summary — hovering the
1588
+ central glow now answers "what is this?" with a one-line
1589
+ fleet snapshot. Duplicates the header chips by design;
1590
+ hovering the most visually prominent element is the
1591
+ most natural impulse, so satisfy it where the cursor
1592
+ already is. Falls clean: omits sub-clauses when their
1593
+ counts are zero. R52 appends a "click to fit view" hint.
1594
+ */}
1595
+ <title>{(() => {
1596
+ const total = sessions.length;
1597
+ const parts = [`Network hub`, `${total} session${total === 1 ? '' : 's'}`];
1598
+ if (onlineNodes.length > 0) parts.push(`${onlineNodes.length} online`);
1599
+ if (workingCount > 0) parts.push(`${workingCount} working`);
1600
+ if (flowLinks.length > 0) parts.push(`${flowLinks.length} active link${flowLinks.length === 1 ? '' : 's'}`);
1601
+ return parts.join(' · ') + '\nclick to fit view';
1602
+ })()}</title>
1603
+ {/* grounding halo — now breathes in opacity, no expansion */}
869
1604
  <circle
870
1605
  cx={cx} cy={cy} r="18"
871
1606
  fill={isLight ? '#d1fae5' : '#10b981'}
872
- opacity={isLight ? 0.35 : 0.10}
873
- />
874
- {/* outer pulse 1 */}
875
- <circle cx={cx} cy={cy} r="10" fill="none" stroke="#10b981" strokeWidth="1.5">
876
- <animate attributeName="r" values="10;38;10" dur="2.4s" repeatCount="indefinite" />
877
- <animate attributeName="opacity" values="0.55;0;0.55" dur="2.4s" repeatCount="indefinite" />
878
- </circle>
879
- {/* outer pulse 2 (delayed 1.2s) */}
880
- <circle cx={cx} cy={cy} r="10" fill="none" stroke="#10b981" strokeWidth="1.5" opacity="0">
881
- <animate attributeName="r" values="10;38;10" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
882
- <animate attributeName="opacity" values="0.45;0;0.45" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
1607
+ opacity={isLight ? 0.42 : 0.12}
1608
+ >
1609
+ {!reducedMotion && (
1610
+ <animate
1611
+ attributeName="opacity"
1612
+ values={isLight ? '0.32;0.52;0.32' : '0.08;0.16;0.08'}
1613
+ dur="4s"
1614
+ repeatCount="indefinite"
1615
+ />
1616
+ )}
883
1617
  </circle>
884
1618
  {/* core — 20px diameter, larger inner highlight reads as a "lit lamp" */}
885
1619
  <circle cx={cx} cy={cy} r="10" fill={isLight ? '#059669' : '#10b981'} />
@@ -887,7 +1621,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
887
1621
  </g>)}
888
1622
 
889
1623
  {/* agent nodes */}
890
- {[...onlineNodes, ...offlineNodes].map(session => {
1624
+ {[...onlineNodes, ...offlineNodes].map((session, nodeIdx) => {
891
1625
  const pos = nodePositions[session.alias];
892
1626
  if (!pos) return null;
893
1627
 
@@ -895,7 +1629,9 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
895
1629
  const isOnline = session.status !== 'offline' || !!sseCountFor;
896
1630
  const status = nodeStatus(session, isOnline, isLight);
897
1631
  const isActive = activeAliases.has(session.alias);
898
- const radius = isOnline ? 26 : 18;
1632
+ // #113: node size scales with the S/M/L control; halos, labels,
1633
+ // badge and avatar all derive from `radius` so they follow.
1634
+ const radius = Math.round((isOnline ? 26 : 18) * nodeScale);
899
1635
  // Round 109/110 (Vincent 4582 + 4583 P0): at high node counts
900
1636
  // the 100px opaque label cards overlapped each other and
901
1637
  // covered neighbouring avatars. But hiding text entirely went
@@ -905,12 +1641,68 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
905
1641
  // occlude an avatar), and the full card appears only for the
906
1642
  // hovered node or once zoomed in past 1.4×.
907
1643
  const showFullLabel = !denseLayout || hoveredAlias === session.alias || view.zoom >= 1.4;
1644
+ // Round 8: when a node in another group is hovered, fade this
1645
+ // one. Same-group nodes (incl. singletons matching the hovered
1646
+ // alias) stay full. Pure visual focus, geometry unchanged.
1647
+ const inFocus = !hoveredGroup || (groupKeys[session.alias] ?? session.alias) === hoveredGroup;
908
1648
 
909
1649
  return (
910
1650
  <g
911
1651
  key={session.alias}
912
- className="transition-opacity"
913
- style={{ cursor: 'pointer' }}
1652
+ data-node={session.alias}
1653
+ // Round 3 / Loop: `anet-fade-in` runs once when the <g>
1654
+ // mounts — a new session entering the fleet (or the topology
1655
+ // first rendering) eases in instead of popping. Re-renders of
1656
+ // an existing node don't re-trigger (React preserves the <g>
1657
+ // via the alias key), so status changes don't flicker. The
1658
+ // global prefers-reduced-motion sweep already neutralises it.
1659
+ className="group transition-opacity anet-fade-in"
1660
+ style={{
1661
+ cursor: 'pointer',
1662
+ // Round 17 / Loop: offline nodes drop to 0.6 at rest so
1663
+ // online nodes pop without losing the offline-as-ghost
1664
+ // information. The dashed stroke + smaller radius already
1665
+ // say "offline"; dimming the whole group strengthens the
1666
+ // online-vs-offline hierarchy at first glance. Exempt the
1667
+ // chat-focused node — if the user explicitly opened a
1668
+ // popover targeting an offline alias, that node stays
1669
+ // full-brightness so the focus ring + popover read as one
1670
+ // selected thing rather than a dimmed selection. Group-
1671
+ // hover fade (Round 8) still wins when inFocus is false.
1672
+ // Round 49 / Loop: edge-hover endpoint highlight composes
1673
+ // OVER the inFocus/online formula — a non-endpoint node
1674
+ // dims to 0.28 (just below the inFocus 0.32 to read as a
1675
+ // stronger "not relevant" signal), endpoints keep their
1676
+ // base opacity. chatAlias still wins to keep the focus
1677
+ // ring legible if the user clicked through.
1678
+ opacity: hoveredEdgeEndpoints && !hoveredEdgeEndpoints.has(session.alias) && chatAlias !== session.alias
1679
+ ? 0.28
1680
+ : !inFocus
1681
+ ? 0.32
1682
+ : chatAlias === session.alias
1683
+ ? 1
1684
+ : isOnline ? 1 : 0.6,
1685
+ // Round 9 / Loop: stagger the anet-fade-in so the topology
1686
+ // reveals as a wave on first paint instead of one big pop.
1687
+ // Cap at 24 indices (≈600ms tail) so 50-node fleets still
1688
+ // finish revealing within a beat. CSS animation-delay only
1689
+ // applies during the keyframe — re-renders without a new
1690
+ // mount (same alias key) don't replay, so status changes
1691
+ // never trigger the stagger again.
1692
+ animationDelay: `${Math.min(nodeIdx, 24) * 25}ms`,
1693
+ // Round 51 / Loop: hover micro-lift. The label already
1694
+ // lifts on group-hover (R26). The node body — circle,
1695
+ // avatar, status ring — stays planted, so the gesture
1696
+ // reads "label moves, body doesn't". Now the whole <g>
1697
+ // translates -2px when hovered: avatar + ring + label
1698
+ // move as one unit. 2 px is well inside the inter-row
1699
+ // gap (cellH headroom ≥22), and CSS transform on SVG
1700
+ // <g> never affects the overlap-test geometry (the
1701
+ // test never simulates hover). useReducedMotion drops
1702
+ // the lift to 0 for prefers-reduced-motion users.
1703
+ transform: !reducedMotion && hoveredAlias === session.alias ? 'translateY(-2px)' : undefined,
1704
+ transition: 'transform 180ms cubic-bezier(0.4,0,0.2,1)',
1705
+ }}
914
1706
  // Stop the pointerdown from reaching the SVG pan handler: the
915
1707
  // SVG calls setPointerCapture, and a captured pointer makes
916
1708
  // Chromium fire the follow-up `click` on the SVG instead of
@@ -918,21 +1710,106 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
918
1710
  // Side effect (intended): you pan from empty canvas, not by
919
1711
  // grabbing a node.
920
1712
  onPointerDown={(e) => e.stopPropagation()}
921
- onPointerEnter={() => denseLayout && setHoveredAlias(session.alias)}
922
- onPointerLeave={() => denseLayout && setHoveredAlias(prev => (prev === session.alias ? null : prev))}
923
- onClick={() => setChatAlias(session.alias)}
1713
+ // Track hover globally (not just under denseLayout) so the
1714
+ // Round 8 group-focus fade works at any fleet size.
1715
+ onPointerEnter={() => setHoveredAlias(session.alias)}
1716
+ onPointerLeave={() => setHoveredAlias(prev => (prev === session.alias ? null : prev))}
1717
+ onClick={() => {
1718
+ setChatAlias(session.alias);
1719
+ // Round 14 ripple — capture position, radius and status
1720
+ // colour at click time so the one-shot circle is self-
1721
+ // contained (no re-render-time recomputation needed).
1722
+ setClickRipple({
1723
+ ts: Date.now(),
1724
+ x: pos.x, y: pos.y, r0: radius,
1725
+ color: status.primary,
1726
+ });
1727
+ setTimeout(() => setClickRipple(prev =>
1728
+ prev && Date.now() - prev.ts >= 590 ? null : prev), 600);
1729
+ }}
924
1730
  >
925
1731
  {/* Issue #96: native hover tooltip — "Vendor · model · Runtime".
926
1732
  Falls back to just the alias when the node reports no
927
- model/runtime. */}
928
- <title>{identityLine(session.model, session.runtime) || session.alias}</title>
929
- {isActive && (
1733
+ model/runtime.
1734
+ Round 33 / Loop: cwd line answers "what is this agent on?".
1735
+ Round 34 / Loop: for offline nodes, append "last seen: 6m ago"
1736
+ so the operator knows whether to wait or chase. Online nodes
1737
+ skip the line (Round 27's 1 h ghost age-out means anything
1738
+ still online has heartbeated recently — the line would just
1739
+ be noise). Accepts both ISO ("…T06:00:28Z") and SQL-style
1740
+ ("… 06:00:28" assumed UTC) formats. */}
1741
+ {(() => {
1742
+ // Round 35 / Loop: TZ-safe parsing via the shared lib helper
1743
+ // — same parseHubTime is used by isGhost above (Round 38
1744
+ // factored it to app/lib/time.ts so both paths interpret SQL
1745
+ // bare timestamps as UTC on every browser).
1746
+ const lastSeen = !isOnline && session.last_seen_at ? relativeAgo(session.last_seen_at) : null;
1747
+ return (
1748
+ <title>{[
1749
+ identityLine(session.model, session.runtime) || session.alias,
1750
+ session.project_dir ? `cwd: ${session.project_dir}` : null,
1751
+ lastSeen ? `last seen: ${lastSeen}` : null,
1752
+ ].filter(Boolean).join('\n')}</title>
1753
+ );
1754
+ })()}
1755
+ {/* Round 2 / Loop: hover ring — a thin outer stroke that fades
1756
+ in when the cursor enters the node, signalling clickability
1757
+ (real-user feedback for the chat-popover open). Pure CSS via
1758
+ Tailwind group-hover, so it costs nothing per frame and
1759
+ respects prefers-reduced-motion via the global media query. */}
1760
+ <circle
1761
+ cx={pos.x}
1762
+ cy={pos.y}
1763
+ r={radius + 12}
1764
+ fill="none"
1765
+ stroke={status.primary}
1766
+ // strokeWidth must NOT be 1.5 (offline status ring) or 3
1767
+ // (online status ring) — the overlap test selects by those
1768
+ // exact widths and would mis-count this invisible hover
1769
+ // ring as a node footprint.
1770
+ strokeWidth="2"
1771
+ className="opacity-0 group-hover:opacity-70 transition-opacity duration-150"
1772
+ style={{ pointerEvents: 'none' }}
1773
+ />
1774
+ {/* Round 11 / Loop: chat-focus ring — when the ChatPopover is
1775
+ open targeting this node, anchor a persistent ring around
1776
+ it so the floating popover visibly links back to its source
1777
+ node. Static (not pulsing) so it reads as "selected state"
1778
+ rather than "this node is active". strokeWidth=2.5 stays
1779
+ clear of the overlap-test selectors (1.5 / 3). Sits just
1780
+ outside the halo radius+8 so it never overlaps a neighbour
1781
+ (halos already pack flush in dense grids). */}
1782
+ {chatAlias === session.alias && (
1783
+ <circle
1784
+ cx={pos.x}
1785
+ cy={pos.y}
1786
+ r={radius + 14}
1787
+ fill="none"
1788
+ stroke={status.primary}
1789
+ strokeWidth="2.5"
1790
+ opacity={isLight ? 0.85 : 0.95}
1791
+ filter={!isLight ? 'url(#topo-glow)' : undefined}
1792
+ className="transition-opacity duration-200"
1793
+ style={{ pointerEvents: 'none' }}
1794
+ />
1795
+ )}
1796
+ {isActive && !reducedMotion && (
930
1797
  <circle cx={pos.x} cy={pos.y} r={radius + 14} fill={status.primary} opacity={isLight ? 0.08 : 0.12}>
931
1798
  <animate attributeName="r" values={`${radius + 8};${radius + 22};${radius + 8}`} dur="2.4s" repeatCount="indefinite" />
932
1799
  <animate attributeName="opacity" values={isLight ? '0.12;0.02;0.12' : '0.18;0.04;0.18'} dur="2.4s" repeatCount="indefinite" />
933
1800
  </circle>
934
1801
  )}
935
- <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)} />
1802
+ {/* Round 4 / Loop: transition-[fill,stroke,opacity] smooths
1803
+ status colour changes so idle↔working↔offline doesn't snap
1804
+ — task replies / node-rename / SSE updates ease in. */}
1805
+ <circle
1806
+ cx={pos.x}
1807
+ cy={pos.y}
1808
+ r={radius + 8}
1809
+ fill={status.halo}
1810
+ opacity={isOnline ? (isLight ? 0.85 : 0.65) : (isLight ? 0.4 : 0.25)}
1811
+ className="transition-[fill,opacity] duration-300 ease-out"
1812
+ />
936
1813
  <circle
937
1814
  cx={pos.x}
938
1815
  cy={pos.y}
@@ -942,6 +1819,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
942
1819
  strokeWidth={isOnline ? 3 : 1.5}
943
1820
  strokeDasharray={isOnline ? 'none' : '5 5'}
944
1821
  filter={isOnline && !isLight ? 'url(#topo-glow)' : undefined}
1822
+ className="transition-[fill,stroke] duration-300 ease-out"
945
1823
  />
946
1824
  {/* Issue #96: node "avatar" is now driven by the model
947
1825
  vendor. Decision order:
@@ -954,7 +1832,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
954
1832
  4. unknown vendor / null model → the prefix-group
955
1833
  hue-hashed initial (#83/#99 behaviour, unchanged). */}
956
1834
  {(() => {
957
- const ar = isOnline ? 14 : 10;
1835
+ const ar = Math.round((isOnline ? 14 : 10) * nodeScale);
958
1836
  const size = radius * 2;
959
1837
  const vendor = vendorForModel(session.model);
960
1838
  const internByAlias = /书生|书小生|intern/i.test(session.alias);
@@ -979,7 +1857,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
979
1857
  <circle cx={pos.x} cy={pos.y} r={ar} fill={vendor.mono.bg} stroke={vendor.mono.ring} strokeWidth="1" />
980
1858
  <text
981
1859
  x={pos.x} y={pos.y} dy="0.34em" textAnchor="middle"
982
- fill={vendor.mono.text} fontSize={isOnline ? 14 : 10}
1860
+ fill={vendor.mono.text} fontSize={ar}
983
1861
  fontFamily="monospace" fontWeight="700"
984
1862
  >
985
1863
  {vendor.initial}
@@ -999,7 +1877,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
999
1877
  dy="0.34em"
1000
1878
  textAnchor="middle"
1001
1879
  fill={c.text}
1002
- fontSize={isOnline ? 14 : 10}
1880
+ fontSize={ar}
1003
1881
  fontFamily="monospace"
1004
1882
  fontWeight="700"
1005
1883
  >
@@ -1028,78 +1906,218 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1028
1906
  </g>
1029
1907
  );
1030
1908
  })()}
1031
- {session.status === 'working' && (
1032
- // working pulse — small dot just inside the status ring,
1033
- // above the avatar text. Was a 18px horizontal bar at
1034
- // y+11 which now collides with the avatar; the dot reads
1035
- // as the same "active" cue without occluding the initial.
1036
- <circle cx={pos.x} cy={pos.y - (isOnline ? 18 : 13)} r="2.5" fill={pal.flowParticle}>
1037
- <animate attributeName="opacity" values="1;0.25;1" dur="1.1s" repeatCount="indefinite" />
1038
- </circle>
1039
- )}
1909
+ {session.status === 'working' && (() => {
1910
+ // Round 24 / Loop: pulse rate ↔ traffic. The dot's dur
1911
+ // was a flat 1.1s — every working node looked equally
1912
+ // busy. Mapping it to sse:N lets the eye land on what's
1913
+ // actually hot. 3 discrete tiers read cleaner than
1914
+ // continuous easing at a glance; thresholds picked so
1915
+ // typical 1-2-SSE Claude/Codex sessions stay in the
1916
+ // "active" tier and only multi-pane orchestrators pop
1917
+ // into the "busy" tier. Geometry unchanged (r=2.5).
1918
+ const sse = sseCountFor ?? 0;
1919
+ const dur = sse >= 4 ? '0.7s' : sse >= 2 ? '0.9s' : '1.2s';
1920
+ return (
1921
+ <circle cx={pos.x} cy={pos.y - (radius - 6)} r="2.5" fill={pal.flowParticle} data-pulse-dur={dur} opacity={reducedMotion ? 0.6 : undefined}>
1922
+ {!reducedMotion && (
1923
+ <animate attributeName="opacity" values="1;0.25;1" dur={dur} repeatCount="indefinite" />
1924
+ )}
1925
+ </circle>
1926
+ );
1927
+ })()}
1040
1928
 
1041
1929
  {/* Round 98 (issue #61): label rect 124px → 100px.
1042
1930
  Round 109/110 (Vincent P0): full opaque card below the
1043
1931
  density threshold / on hover / when zoomed; otherwise a
1044
1932
  lightweight plain-text alias that keeps every node
1045
- labelled without an opaque box covering its neighbours. */}
1046
- {showFullLabel ? (
1047
- <g transform={`translate(${pos.x}, ${pos.y + radius + 22})`} style={{ pointerEvents: 'none' }}>
1048
- <rect x="-50" y="-14" width="100" height="42" rx="6" fill={pal.labelBox.fill} stroke={pal.labelBox.stroke} opacity={isLight ? 1 : 0.94} />
1049
- <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize="12" fontFamily="monospace" fontWeight="700">
1050
- {truncate(session.alias, 12)}
1051
- </text>
1052
- <text x="0" y="17" textAnchor="middle" fill={status.primary} fontSize="9" fontFamily="monospace">
1053
- {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
1933
+ labelled without an opaque box covering its neighbours.
1934
+ Round 15 / Loop: when nodeScale=S the node shrinks 30%
1935
+ but the label card was staying full-size, so the label
1936
+ visually outweighed the small node. Tighten the card
1937
+ frame, alias / sub fontSize, drop-offset and truncate
1938
+ length specifically for S; M and L keep their existing
1939
+ sizes (M ≈ L for labels by design — the S user is the
1940
+ one who explicitly asked for a denser view). */}
1941
+ {(() => {
1942
+ const isSmall = nodeScale < 0.8;
1943
+ const cardW = isSmall ? 88 : 100;
1944
+ const cardH = isSmall ? 36 : 42;
1945
+ const cardTopY = isSmall ? -12 : -14;
1946
+ const aliasFs = isSmall ? 11 : 12;
1947
+ const subFs = isSmall ? 8 : 9;
1948
+ const subY = isSmall ? 15 : 17;
1949
+ const dropY = isSmall ? 18 : 22;
1950
+ const fullMax = isSmall ? 11 : 12;
1951
+ const denseFs = isSmall ? 9 : 10;
1952
+ const denseDrop = isSmall ? 12 : 14;
1953
+ // Round 26 / Loop: micro-lift the label group on hover —
1954
+ // 1.5 px upward, 200 ms ease. Pairs with the Round 18
1955
+ // group-box hover-accent treatment to give the same
1956
+ // "this is the focused element" feedback at the per-
1957
+ // node level. CSS transform stacks onto the SVG
1958
+ // positioning transform attribute (SVG 2 cascade);
1959
+ // bbox is unchanged at rest, so the overlap-test gate
1960
+ // continues to see the geometric layout it expects.
1961
+ return showFullLabel ? (
1962
+ <g transform={`translate(${pos.x}, ${pos.y + radius + dropY})`} style={{ pointerEvents: 'none' }}
1963
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]">
1964
+ <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} />
1965
+ <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize={aliasFs} fontFamily="monospace" fontWeight="700">
1966
+ {truncate(session.alias, fullMax)}
1967
+ </text>
1968
+ <text x="0" y={subY} textAnchor="middle" fill={status.primary} fontSize={subFs} fontFamily="monospace">
1969
+ {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
1970
+ </text>
1971
+ </g>
1972
+ ) : (
1973
+ <text
1974
+ x={pos.x}
1975
+ y={pos.y + radius + denseDrop}
1976
+ textAnchor="middle"
1977
+ fill={status.text}
1978
+ fontSize={denseFs}
1979
+ fontFamily="monospace"
1980
+ fontWeight="700"
1981
+ opacity={0.9}
1982
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]"
1983
+ style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
1984
+ stroke={pal.containerBg}
1985
+ strokeWidth="3"
1986
+ >
1987
+ {truncate(session.alias, isSmall ? 9 : 10)}
1054
1988
  </text>
1055
- </g>
1056
- ) : (
1057
- <text
1058
- x={pos.x}
1059
- y={pos.y + radius + 14}
1060
- textAnchor="middle"
1061
- fill={status.text}
1062
- fontSize="10"
1063
- fontFamily="monospace"
1064
- fontWeight="700"
1065
- opacity={0.9}
1066
- style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
1067
- stroke={pal.containerBg}
1068
- strokeWidth="3"
1069
- >
1070
- {truncate(session.alias, 10)}
1071
- </text>
1072
- )}
1989
+ );
1990
+ })()}
1073
1991
  </g>
1074
1992
  );
1075
1993
  })}
1076
1994
 
1995
+ {/* Round 14 / Loop: click ripple — one-shot expanding ring from
1996
+ the clicked node, ~500ms, status-coloured. Sits inside the
1997
+ zoom/pan <g> so it scales / pans with the topology. Keyed by
1998
+ ts so a re-click on any node (same or different) remounts the
1999
+ <circle> and the SMIL <animate> elements replay from t=0.
2000
+ strokeWidth=2 doesn't match the overlap-test selectors. */}
2001
+ {clickRipple && !reducedMotion && (
2002
+ <circle
2003
+ key={clickRipple.ts}
2004
+ cx={clickRipple.x}
2005
+ cy={clickRipple.y}
2006
+ r={clickRipple.r0 + 4}
2007
+ fill="none"
2008
+ stroke={clickRipple.color}
2009
+ strokeWidth="2"
2010
+ opacity="0"
2011
+ style={{ pointerEvents: 'none' }}
2012
+ >
2013
+ <animate attributeName="r" values={`${clickRipple.r0 + 4};${clickRipple.r0 + 30}`} dur="0.5s" fill="freeze" />
2014
+ <animate attributeName="opacity" values="0.7;0" dur="0.5s" fill="freeze" />
2015
+ </circle>
2016
+ )}
2017
+
2018
+ </g>
2019
+
2020
+ {/* #112: overlay panels (recent-signal + legend) render OUTSIDE the
2021
+ zoom/pan <g> so they stay fixed while the topology pans/zooms.
2022
+ They're sized + tucked into the top corners so every corner of
2023
+ each panel is >325px from the canvas centre — i.e. fully outside
2024
+ the outermost (offline) ring. No node on any ring can reach the
2025
+ corner triangles, so the panels never overlap a node, in ring
2026
+ OR grid layout (Vincent 4727 zero-overlap criterion). */}
1077
2027
  {/* latest flow labels */}
1078
- <g transform="translate(28, 34)">
1079
- <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} />
1080
- <text x="16" y="26" fill={pal.legendHeadline} fontSize="13" fontFamily="monospace" fontWeight="700">recent signal</text>
1081
- <text x="178" y="26" fill={pal.legendAccent} fontSize="11" fontFamily="monospace">{messages.length} messages</text>
1082
- {flowLinks.slice(0, 4).map((link, index) => (
1083
- <text key={link.key} x="16" y={54 + index * 20} fill={pal.legendText} fontSize="10" fontFamily="monospace">
1084
- {truncate(link.from, 8)} {'->'} {truncate(link.to, 8)} / {link.count} / {truncate(link.content, 18)}
2028
+ <g transform="translate(16, 16)">
2029
+ <rect x="0" y="0" width="230" height="84" rx="10" fill={pal.legendBox.fill} stroke={pal.legendBox.stroke} opacity={isLight ? 0.97 : 0.92} />
2030
+ <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700">recent signal</text>
2031
+ <text x="150" y="21" fill={pal.legendAccent} fontSize="10" fontFamily="monospace">{messages.length} msgs</text>
2032
+ {/* Round 45 / Loop: empty state. The panel used to render
2033
+ "recent signal" + "0 msgs" with three blank slots below
2034
+ when no flow yet — read as "broken" rather than "quiet".
2035
+ A muted centred placeholder makes the empty state
2036
+ deliberate. Messages count CAN diverge from flowLinks
2037
+ count (raw count vs. deduped pairs), so the placeholder
2038
+ fires on flowLinks.length=0 specifically. */}
2039
+ {flowLinks.length === 0 ? (
2040
+ <text x="115" y="60" textAnchor="middle" fill={pal.legendText} fontSize="10" fontFamily="monospace" fontStyle="italic" opacity={0.65} data-recent-signal-empty>
2041
+ no flow yet
1085
2042
  </text>
1086
- ))}
2043
+ ) : (
2044
+ flowLinks.slice(0, 3).map((link, index) => (
2045
+ <text key={link.key} x="13" y={38 + index * 16} fill={pal.legendText} fontSize="9" fontFamily="monospace">
2046
+ {truncate(link.from, 6)} {'->'} {truncate(link.to, 6)} / {link.count} / {truncate(link.content, 12)}
2047
+ </text>
2048
+ ))
2049
+ )}
1087
2050
  </g>
1088
2051
 
1089
2052
  {/* legend */}
1090
- <g transform="translate(720, 34)">
1091
- <rect x="0" y="0" width="250" height="112" rx="8" fill={pal.legendBox.fill} stroke={pal.legendBox.stroke} opacity={isLight ? 1 : 0.94} />
1092
- <circle cx="18" cy="26" r="6" fill={isLight ? '#059669' : '#22c55e'} />
1093
- <text x="34" y="30" fill={pal.legendText} fontSize="11" fontFamily="monospace">working node</text>
1094
- <circle cx="18" cy="52" r="6" fill={isLight ? '#0d9488' : '#2dd4bf'} />
1095
- <text x="34" y="56" fill={pal.legendText} fontSize="11" fontFamily="monospace">online idle</text>
1096
- <circle cx="18" cy="78" r="6" fill={isLight ? '#94a3b8' : '#6b7280'} />
1097
- <text x="34" y="82" fill={pal.legendText} fontSize="11" fontFamily="monospace">offline / no SSE</text>
1098
- <path d="M150,78 Q176,52 210,78" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" />
1099
- </g>
2053
+ <g transform="translate(760, 16)">
2054
+ <rect x="0" y="0" width="224" height="96" rx="10" fill={pal.legendBox.fill} stroke={pal.legendBox.stroke} opacity={isLight ? 0.97 : 0.92} />
2055
+ <circle cx="16" cy="24" r="5.5" fill={isLight ? '#059669' : '#22c55e'} />
2056
+ <text x="30" y="28" fill={pal.legendText} fontSize="11" fontFamily="monospace">working node</text>
2057
+ <circle cx="16" cy="48" r="5.5" fill={isLight ? '#0d9488' : '#2dd4bf'} />
2058
+ <text x="30" y="52" fill={pal.legendText} fontSize="11" fontFamily="monospace">online idle</text>
2059
+ <circle cx="16" cy="72" r="5.5" fill={isLight ? '#94a3b8' : '#6b7280'} />
2060
+ <text x="30" y="76" fill={pal.legendText} fontSize="11" fontFamily="monospace">offline / no SSE</text>
2061
+ <path d="M140,72 Q164,48 196,72" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" />
1100
2062
  </g>
1101
2063
  </svg>
1102
2064
 
2065
+ {/* Round 30 / Loop: minimap. Big fleets in fullscreen mode at high
2066
+ zoom let users lose their position — the minimap shows the
2067
+ whole topology miniaturised plus a viewport rectangle so the
2068
+ user always knows where they are. Click anywhere to recenter
2069
+ the canvas there. Only mounted when the view is non-default
2070
+ (zoomed or panned) since at 1× centered the minimap and the
2071
+ canvas show the same thing. HTML overlay so it stays fixed
2072
+ while the SVG transforms. */}
2073
+ {(() => {
2074
+ const isDefaultView = Math.abs(view.zoom - 1) < 0.01 && Math.abs(view.x) < 1 && Math.abs(view.y) < 1;
2075
+ if (isDefaultView || (onlineNodes.length + offlineNodes.length) === 0) return null;
2076
+ const MW = 120, MH = 82;
2077
+ const sx = MW / VIEWBOX_W, sy = MH / VIEWBOX_H;
2078
+ const rectX = (-view.x / view.zoom) * sx;
2079
+ const rectY = (-view.y / view.zoom) * sy;
2080
+ const rectW = (VIEWBOX_W / view.zoom) * sx;
2081
+ const rectH = (VIEWBOX_H / view.zoom) * sy;
2082
+ return (
2083
+ <div
2084
+ className="absolute right-3 rounded-md border shadow-lg shadow-black/30 overflow-hidden anet-fade-in"
2085
+ style={{ bottom: 56, background: pal.legendBox.fill, borderColor: pal.containerBorder, cursor: 'crosshair' }}
2086
+ role="img"
2087
+ aria-label="Topology minimap — click to recenter"
2088
+ title="Minimap · click to recenter"
2089
+ onClick={(e) => {
2090
+ const r = e.currentTarget.getBoundingClientRect();
2091
+ const fx = (e.clientX - r.left) / r.width;
2092
+ const fy = (e.clientY - r.top) / r.height;
2093
+ setView(prev => ({
2094
+ ...prev,
2095
+ x: VIEWBOX_W / 2 - fx * VIEWBOX_W * prev.zoom,
2096
+ y: VIEWBOX_H / 2 - fy * VIEWBOX_H * prev.zoom,
2097
+ }));
2098
+ }}
2099
+ data-topo-minimap
2100
+ >
2101
+ <svg width={MW} height={MH} viewBox={`0 0 ${MW} ${MH}`} style={{ display: 'block' }}>
2102
+ {[...onlineNodes, ...offlineNodes].map(s => {
2103
+ const p = nodePositions[s.alias];
2104
+ if (!p) return null;
2105
+ const sseN = (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
2106
+ const isOn = s.status !== 'offline' || !!sseN;
2107
+ const st = nodeStatus(s, isOn, isLight);
2108
+ 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} />;
2109
+ })}
2110
+ {/* viewport rectangle */}
2111
+ <rect
2112
+ x={Math.max(0, rectX)} y={Math.max(0, rectY)}
2113
+ width={Math.max(0, Math.min(MW - Math.max(0, rectX), rectW))}
2114
+ height={Math.max(0, Math.min(MH - Math.max(0, rectY), rectH))}
2115
+ fill="none" stroke={pal.legendAccent} strokeWidth="1" opacity="0.9"
2116
+ />
2117
+ </svg>
2118
+ </div>
2119
+ );
2120
+ })()}
1103
2121
  {/* Round 103 (issue #81): zoom / pan / fullscreen controls — HTML
1104
2122
  overlay so they stay fixed while the SVG content transforms.
1105
2123
  Round 104: Vincent 实测 — the reset action used to be hidden
@@ -1107,6 +2125,26 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1107
2125
  Split into a plain % readout + an explicit reset button with
1108
2126
  its own icon + tooltip. */}
1109
2127
  <div className="absolute bottom-3 right-3 flex items-center gap-1.5 text-xs select-none">
2128
+ {/* #113: node size — S / M / L segmented control (Vincent 4727). */}
2129
+ <div
2130
+ className="flex items-center rounded-md border overflow-hidden"
2131
+ style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder }}
2132
+ role="group"
2133
+ aria-label="Node size"
2134
+ >
2135
+ {([['S', 0.7], ['M', 0.84], ['L', 1]] as const).map(([lbl, v], idx) => (
2136
+ <button
2137
+ key={lbl}
2138
+ onClick={() => pickNodeScale(v)}
2139
+ aria-pressed={nodeScale === v}
2140
+ title={`Node size: ${lbl === 'S' ? 'small' : lbl === 'M' ? 'medium' : 'large'}`}
2141
+ className={`px-2 py-1 transition-colors ${idx > 0 ? 'border-l' : ''} ${nodeScale === v ? 'bg-cyan-500/15 text-cyan-300' : 'hover:bg-white/5'}`}
2142
+ style={{ color: nodeScale === v ? undefined : pal.legendText, borderColor: pal.containerBorder }}
2143
+ >
2144
+ {lbl}
2145
+ </button>
2146
+ ))}
2147
+ </div>
1110
2148
  <div
1111
2149
  className="flex items-center rounded-md border overflow-hidden"
1112
2150
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder }}
@@ -1116,6 +2154,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1116
2154
  className="px-2 py-1 hover:bg-white/5 transition-colors"
1117
2155
  style={{ color: pal.legendText }}
1118
2156
  aria-label="Zoom out"
2157
+ title="Zoom out (−)"
1119
2158
  >
1120
2159
  <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>
1121
2160
  </button>
@@ -1131,6 +2170,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1131
2170
  className="px-2 py-1 hover:bg-white/5 transition-colors"
1132
2171
  style={{ color: pal.legendText }}
1133
2172
  aria-label="Zoom in"
2173
+ title="Zoom in (+)"
1134
2174
  >
1135
2175
  <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>
1136
2176
  </button>
@@ -1140,7 +2180,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1140
2180
  className="p-1.5 rounded-md border hover:bg-white/5 transition-colors"
1141
2181
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
1142
2182
  aria-label="Reset view"
1143
- title="Reset zoom + pan (or double-click the canvas)"
2183
+ title="Reset zoom + pan (0, or double-click the canvas)"
1144
2184
  >
1145
2185
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1146
2186
  <path d="M3 12a9 9 0 1 0 9-9 9 9 0 0 0-6.4 2.6L3 8" />