@sleep2agi/agent-network-dashboard 0.4.6-preview.9 → 0.4.6-preview.91

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 (296) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +1 -0
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/diagnostics/route-bundle-stats.json +69 -55
  5. package/.next/fallback-build-manifest.json +3 -3
  6. package/.next/prerender-manifest.json +3 -3
  7. package/.next/routes-manifest.json +6 -0
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app/_not-found.html +2 -2
  17. package/.next/server/app/_not-found.rsc +12 -12
  18. package/.next/server/app/_not-found.segments/_full.segment.rsc +12 -12
  19. package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  20. package/.next/server/app/_not-found.segments/_index.segment.rsc +7 -7
  21. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  22. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  23. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  24. package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
  25. package/.next/server/app/admin.html +2 -2
  26. package/.next/server/app/admin.rsc +14 -14
  27. package/.next/server/app/admin.segments/_full.segment.rsc +14 -14
  28. package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
  29. package/.next/server/app/admin.segments/_index.segment.rsc +7 -7
  30. package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
  31. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +4 -4
  32. package/.next/server/app/admin.segments/admin.segment.rsc +3 -3
  33. package/.next/server/app/api/hub/servers/route/app-paths-manifest.json +3 -0
  34. package/.next/server/app/api/hub/servers/route/build-manifest.json +9 -0
  35. package/.next/server/app/api/hub/servers/route/server-reference-manifest.json +4 -0
  36. package/.next/server/app/api/hub/servers/route.js +7 -0
  37. package/.next/server/app/api/hub/servers/route.js.map +5 -0
  38. package/.next/server/app/api/hub/servers/route.js.nft.json +1 -0
  39. package/.next/server/app/api/hub/servers/route_client-reference-manifest.js +3 -0
  40. package/.next/server/app/index.html +2 -2
  41. package/.next/server/app/index.rsc +14 -14
  42. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  43. package/.next/server/app/index.segments/_full.segment.rsc +14 -14
  44. package/.next/server/app/index.segments/_head.segment.rsc +4 -4
  45. package/.next/server/app/index.segments/_index.segment.rsc +7 -7
  46. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  47. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  48. package/.next/server/app/login.html +2 -2
  49. package/.next/server/app/login.rsc +14 -14
  50. package/.next/server/app/login.segments/_full.segment.rsc +14 -14
  51. package/.next/server/app/login.segments/_head.segment.rsc +4 -4
  52. package/.next/server/app/login.segments/_index.segment.rsc +7 -7
  53. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  54. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
  55. package/.next/server/app/login.segments/login.segment.rsc +3 -3
  56. package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
  57. package/.next/server/app/logs.html +2 -2
  58. package/.next/server/app/logs.rsc +14 -14
  59. package/.next/server/app/logs.segments/_full.segment.rsc +14 -14
  60. package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
  61. package/.next/server/app/logs.segments/_index.segment.rsc +7 -7
  62. package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
  63. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
  64. package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
  65. package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
  66. package/.next/server/app/messages.html +2 -2
  67. package/.next/server/app/messages.rsc +14 -14
  68. package/.next/server/app/messages.segments/_full.segment.rsc +14 -14
  69. package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
  70. package/.next/server/app/messages.segments/_index.segment.rsc +7 -7
  71. package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
  72. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
  73. package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
  74. package/.next/server/app/node/page_client-reference-manifest.js +1 -1
  75. package/.next/server/app/node.html +2 -2
  76. package/.next/server/app/node.rsc +14 -14
  77. package/.next/server/app/node.segments/_full.segment.rsc +14 -14
  78. package/.next/server/app/node.segments/_head.segment.rsc +4 -4
  79. package/.next/server/app/node.segments/_index.segment.rsc +7 -7
  80. package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
  81. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +4 -4
  82. package/.next/server/app/node.segments/node.segment.rsc +3 -3
  83. package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
  84. package/.next/server/app/nodes.html +2 -2
  85. package/.next/server/app/nodes.rsc +14 -14
  86. package/.next/server/app/nodes.segments/_full.segment.rsc +14 -14
  87. package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
  88. package/.next/server/app/nodes.segments/_index.segment.rsc +7 -7
  89. package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
  90. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
  91. package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
  92. package/.next/server/app/page_client-reference-manifest.js +1 -1
  93. package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
  94. package/.next/server/app/server-logs.html +2 -2
  95. package/.next/server/app/server-logs.rsc +14 -14
  96. package/.next/server/app/server-logs.segments/_full.segment.rsc +14 -14
  97. package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
  98. package/.next/server/app/server-logs.segments/_index.segment.rsc +7 -7
  99. package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
  100. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +4 -4
  101. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
  102. package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
  103. package/.next/server/app/settings/networks.html +2 -2
  104. package/.next/server/app/settings/networks.rsc +14 -14
  105. package/.next/server/app/settings/networks.segments/_full.segment.rsc +14 -14
  106. package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
  107. package/.next/server/app/settings/networks.segments/_index.segment.rsc +7 -7
  108. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
  109. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +4 -4
  110. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
  111. package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
  112. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  113. package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
  114. package/.next/server/app/settings/tokens.html +2 -2
  115. package/.next/server/app/settings/tokens.rsc +14 -14
  116. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +14 -14
  117. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
  118. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +7 -7
  119. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
  120. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +4 -4
  121. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
  122. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
  123. package/.next/server/app/settings.html +2 -2
  124. package/.next/server/app/settings.rsc +14 -14
  125. package/.next/server/app/settings.segments/_full.segment.rsc +14 -14
  126. package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
  127. package/.next/server/app/settings.segments/_index.segment.rsc +7 -7
  128. package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  129. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
  130. package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
  131. package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  132. package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
  133. package/.next/server/app/tasks.html +2 -2
  134. package/.next/server/app/tasks.rsc +14 -14
  135. package/.next/server/app/tasks.segments/_full.segment.rsc +14 -14
  136. package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
  137. package/.next/server/app/tasks.segments/_index.segment.rsc +7 -7
  138. package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
  139. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
  140. package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
  141. package/.next/server/app-paths-manifest.json +1 -0
  142. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_servers_route_actions_08-m-cs.js +3 -0
  143. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_servers_route_actions_08-m-cs.js.map +1 -0
  144. package/.next/server/chunks/[root-of-the-server]__0tx8s8i._.js +3 -0
  145. package/.next/server/chunks/[root-of-the-server]__0tx8s8i._.js.map +1 -0
  146. package/.next/server/chunks/ssr/[root-of-the-server]__03b.f76._.js +1 -1
  147. package/.next/server/chunks/ssr/[root-of-the-server]__03b.f76._.js.map +1 -1
  148. package/.next/server/chunks/ssr/[root-of-the-server]__0fhoq8i._.js +1 -1
  149. package/.next/server/chunks/ssr/[root-of-the-server]__0fhoq8i._.js.map +1 -1
  150. package/.next/server/chunks/ssr/[root-of-the-server]__0nw~zhp._.js +1 -1
  151. package/.next/server/chunks/ssr/[root-of-the-server]__0nw~zhp._.js.map +1 -1
  152. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js +2 -1
  153. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js.map +1 -1
  154. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js +1 -1
  155. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js.map +1 -1
  156. package/.next/server/chunks/ssr/agent-network-dashboard_app_057q.ne._.js +1 -1
  157. package/.next/server/chunks/ssr/agent-network-dashboard_app_057q.ne._.js.map +1 -1
  158. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js +1 -1
  159. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js.map +1 -1
  160. package/.next/server/chunks/ssr/agent-network-dashboard_app_0_d45-d._.js +1 -1
  161. package/.next/server/chunks/ssr/agent-network-dashboard_app_0_d45-d._.js.map +1 -1
  162. package/.next/server/chunks/ssr/agent-network-dashboard_app_0i3759l._.js +1 -1
  163. package/.next/server/chunks/ssr/agent-network-dashboard_app_0i3759l._.js.map +1 -1
  164. package/.next/server/chunks/ssr/agent-network-dashboard_app_0xgney8._.js +1 -1
  165. package/.next/server/chunks/ssr/agent-network-dashboard_app_0xgney8._.js.map +1 -1
  166. package/.next/server/chunks/ssr/agent-network-dashboard_app_10hjgv4._.js +1 -1
  167. package/.next/server/chunks/ssr/agent-network-dashboard_app_10hjgv4._.js.map +1 -1
  168. package/.next/server/chunks/ssr/agent-network-dashboard_app_1153xeb._.js +1 -1
  169. package/.next/server/chunks/ssr/agent-network-dashboard_app_1153xeb._.js.map +1 -1
  170. package/.next/server/chunks/ssr/agent-network-dashboard_app_12l4oto._.js +1 -1
  171. package/.next/server/chunks/ssr/agent-network-dashboard_app_12l4oto._.js.map +1 -1
  172. package/.next/server/chunks/ssr/agent-network-dashboard_app_components_0mvyi-4._.js +1 -1
  173. package/.next/server/chunks/ssr/agent-network-dashboard_app_components_0mvyi-4._.js.map +1 -1
  174. package/.next/server/middleware-build-manifest.js +3 -3
  175. package/.next/server/pages/404.html +2 -2
  176. package/.next/server/pages/500.html +1 -1
  177. package/.next/server/server-reference-manifest.js +1 -1
  178. package/.next/server/server-reference-manifest.json +1 -1
  179. package/.next/static/chunks/0-mpa_947ipeq.js +1 -0
  180. package/.next/static/chunks/01lmbsd37fybu.js +1 -0
  181. package/.next/static/chunks/03o.h6kvmw4l_.js +1 -0
  182. package/.next/static/chunks/03~~oirxz7~vc.js +1 -0
  183. package/.next/static/chunks/05uk96gc~9mni.js +1 -0
  184. package/.next/static/chunks/08kf-.bgs10ze.js +1 -0
  185. package/.next/static/chunks/08pu6p-oh~oa1.js +1 -0
  186. package/.next/static/chunks/0glmrdj9112tc.js +7 -0
  187. package/.next/static/chunks/0oknosftxc9lm.css +2 -0
  188. package/.next/static/chunks/0p_caune7bdi8.js +2 -0
  189. package/.next/static/chunks/0rxrez_cjn.eh.js +1 -0
  190. package/.next/static/chunks/0tvn2l1pc.h65.js +1 -0
  191. package/.next/static/chunks/0w3m56zhfnsnx.js +7 -0
  192. package/.next/static/chunks/0x8x.29hkju5a.js +1 -0
  193. package/.next/static/chunks/0yxrc2w2b71zj.js +1 -0
  194. package/.next/static/chunks/174re5ar~kxne.js +1 -0
  195. package/.next/trace +2 -2
  196. package/.next/trace-build +1 -1
  197. package/.next/types/routes.d.ts +2 -1
  198. package/.next/types/validator.ts +9 -0
  199. package/app/api/hub/servers/route.ts +39 -0
  200. package/app/components/AppShell.tsx +2 -0
  201. package/app/components/ChatPopover.tsx +38 -2
  202. package/app/components/CommandPalette.tsx +51 -0
  203. package/app/components/HelpOverlay.tsx +55 -0
  204. package/app/components/ServersDrawer.tsx +206 -0
  205. package/app/components/TopoGraph.tsx +1636 -157
  206. package/app/components/utils.ts +8 -7
  207. package/app/lib/time.ts +36 -0
  208. package/package.json +1 -1
  209. package/scripts/help-overlay-topo-test.mjs +44 -0
  210. package/scripts/servers-drawer-live-test.mjs +96 -0
  211. package/scripts/topo-active-filter-pill-test.mjs +101 -0
  212. package/scripts/topo-active-links-chip-hover-test.mjs +103 -0
  213. package/scripts/topo-active-links-chip-test.mjs +73 -0
  214. package/scripts/topo-arrival-ping-test.mjs +87 -0
  215. package/scripts/topo-arrow-tier-test.mjs +61 -0
  216. package/scripts/topo-auto-fit-test.mjs +73 -0
  217. package/scripts/topo-chat-focus-ring-test.mjs +77 -0
  218. package/scripts/topo-chip-pin-mirror-test.mjs +113 -0
  219. package/scripts/topo-click-ripple-test.mjs +66 -0
  220. package/scripts/topo-cmdk-layout-view-test.mjs +108 -0
  221. package/scripts/topo-cmdk-pin-test.mjs +123 -0
  222. package/scripts/topo-cwd-tooltip-test.mjs +64 -0
  223. package/scripts/topo-dblclick-reset-test.mjs +74 -0
  224. package/scripts/topo-dispatch-pulse-test.mjs +83 -0
  225. package/scripts/topo-edge-endpoint-highlight-test.mjs +111 -0
  226. package/scripts/topo-edge-focus-test.mjs +113 -0
  227. package/scripts/topo-edge-hover-test.mjs +93 -0
  228. package/scripts/topo-edge-tooltip-test.mjs +70 -0
  229. package/scripts/topo-esc-clears-pin-test.mjs +98 -0
  230. package/scripts/topo-filter-pill-body-click-test.mjs +112 -0
  231. package/scripts/topo-filter-pill-count-test.mjs +110 -0
  232. package/scripts/topo-filter-pill-mobile-test.mjs +90 -0
  233. package/scripts/topo-fit-key-test.mjs +106 -0
  234. package/scripts/topo-freshness-chip-test.mjs +74 -0
  235. package/scripts/topo-group-box-pinned-state-test.mjs +95 -0
  236. package/scripts/topo-group-count-test.mjs +68 -0
  237. package/scripts/topo-group-hover-test.mjs +82 -0
  238. package/scripts/topo-group-pin-test.mjs +119 -0
  239. package/scripts/topo-group-status-pips-test.mjs +103 -0
  240. package/scripts/topo-groupbox-test.mjs +4 -2
  241. package/scripts/topo-help-overlay-r59-test.mjs +67 -0
  242. package/scripts/topo-help-overlay-r65-test.mjs +64 -0
  243. package/scripts/topo-help-overlay-r70-test.mjs +63 -0
  244. package/scripts/topo-help-overlay-r78-test.mjs +59 -0
  245. package/scripts/topo-help-overlay-r81-test.mjs +58 -0
  246. package/scripts/topo-hub-busyness-test.mjs +100 -0
  247. package/scripts/topo-hub-click-fit-test.mjs +137 -0
  248. package/scripts/topo-hub-title-test.mjs +93 -0
  249. package/scripts/topo-ingroup-edge-test.mjs +103 -0
  250. package/scripts/topo-label-lift-test.mjs +119 -0
  251. package/scripts/topo-label-scale-test.mjs +78 -0
  252. package/scripts/topo-lastseen-tooltip-test.mjs +59 -0
  253. package/scripts/topo-layout-key-test.mjs +91 -0
  254. package/scripts/topo-legend-hover-test.mjs +113 -0
  255. package/scripts/topo-legend-pin-test.mjs +128 -0
  256. package/scripts/topo-minimap-test.mjs +68 -0
  257. package/scripts/topo-mobile-chips-test.mjs +103 -0
  258. package/scripts/topo-node-hover-lift-test.mjs +85 -0
  259. package/scripts/topo-offline-dim-test.mjs +70 -0
  260. package/scripts/topo-overlap-test.mjs +32 -5
  261. package/scripts/topo-pan-cursor-test.mjs +62 -0
  262. package/scripts/topo-panel-elevation-test.mjs +76 -0
  263. package/scripts/topo-pin-persistence-test.mjs +105 -0
  264. package/scripts/topo-popover-meta-test.mjs +78 -0
  265. package/scripts/topo-pressure-bar-test.mjs +69 -0
  266. package/scripts/topo-pressure-hover-test.mjs +123 -0
  267. package/scripts/topo-pressure-pin-test.mjs +119 -0
  268. package/scripts/topo-pulse-rate-test.mjs +71 -0
  269. package/scripts/topo-recent-row-hover-test.mjs +122 -0
  270. package/scripts/topo-recent-signal-empty-test.mjs +63 -0
  271. package/scripts/topo-reduced-motion-test.mjs +77 -0
  272. package/scripts/topo-status-chip-hover-test.mjs +101 -0
  273. package/scripts/topo-tier-ring-test.mjs +69 -0
  274. package/scripts/topo-tier-stagger-test.mjs +89 -0
  275. package/scripts/topo-tz-ghost-test.mjs +69 -0
  276. package/scripts/topo-vendor-chip-hover-test.mjs +111 -0
  277. package/scripts/topo-wheel-zoom-test.mjs +79 -0
  278. package/scripts/topo-zoom-keys-test.mjs +89 -0
  279. package/.next/static/chunks/0-7iowzu4.798.js +0 -1
  280. package/.next/static/chunks/02t5jr4shh.hy.js +0 -1
  281. package/.next/static/chunks/0_qfg5s5uzs7..js +0 -7
  282. package/.next/static/chunks/0_rmxom9~1lmk.css +0 -2
  283. package/.next/static/chunks/0cj5qr_a8dzky.js +0 -7
  284. package/.next/static/chunks/0elalb9n_.~di.js +0 -1
  285. package/.next/static/chunks/0j_q__o8p6g83.js +0 -1
  286. package/.next/static/chunks/0jl6_w50zh4l5.js +0 -1
  287. package/.next/static/chunks/0kgak.tfh2-~i.js +0 -1
  288. package/.next/static/chunks/0r4q73.-1bkyl.js +0 -1
  289. package/.next/static/chunks/0r_c8eccg3z2o.js +0 -1
  290. package/.next/static/chunks/10oi7l-tycgl9.js +0 -1
  291. package/.next/static/chunks/11uk1izuv3~0t.js +0 -1
  292. package/.next/static/chunks/131.zrqhdbu5x.js +0 -1
  293. package/.next/static/chunks/165lqf5.z.n10.js +0 -1
  294. /package/.next/static/{T0hK2WE0kVtyFouPsSpos → EgsOXmeDYX7rzWQpipoVB}/_buildManifest.js +0 -0
  295. /package/.next/static/{T0hK2WE0kVtyFouPsSpos → EgsOXmeDYX7rzWQpipoVB}/_clientMiddlewareManifest.js +0 -0
  296. /package/.next/static/{T0hK2WE0kVtyFouPsSpos → EgsOXmeDYX7rzWQpipoVB}/_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
 
@@ -312,12 +398,20 @@ function buildFlowLinks(messages: MessageFlow[], positions: Record<string, Point
312
398
  const key = `${message.from_alias}->${message.to_alias}`;
313
399
  const current = links.get(key);
314
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
+
315
408
  links.set(key, {
316
409
  key,
317
410
  from: message.from_alias,
318
411
  to: message.to_alias,
319
412
  count: (current?.count || 0) + 1,
320
413
  content: current?.content || message.content,
414
+ last_at,
321
415
  });
322
416
  });
323
417
 
@@ -327,6 +421,7 @@ function buildFlowLinks(messages: MessageFlow[], positions: Record<string, Point
327
421
  export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProps) {
328
422
  const theme = useTheme();
329
423
  const isLight = theme === 'light';
424
+ const reducedMotion = useReducedMotion();
330
425
  const pal = isLight ? LIGHT_PALETTE : DARK_PALETTE;
331
426
  const brand = useBrand();
332
427
  const isIntern = brand === 'intern';
@@ -388,6 +483,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
388
483
  activeAliases,
389
484
  groupKeys,
390
485
  groupBoxes,
486
+ gridContentBottom,
391
487
  } = useMemo(() => {
392
488
  const sseCount = (s: { alias: string; network_id?: string }) =>
393
489
  (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
@@ -397,17 +493,25 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
397
493
  // index, so contiguous-in-array becomes contiguous-in-ring, i.e.
398
494
  // each team visually clusters. localeCompare keeps CJK ordering sane.
399
495
  const byAlias = (a: Session, b: Session) => a.alias.localeCompare(b.alias);
400
- // #112 umbrella: ghost age-out — an offline node not seen for over a day
401
- // is almost certainly a deleted node the server `/api/status` still
496
+ // #112 umbrella: ghost age-out — an offline node not seen recently is
497
+ // almost certainly a deleted node the server `/api/status` still
402
498
  // returns (#74 root cause is server-side; this is the dashboard-side
403
499
  // fallback so stale ghosts stop cluttering the topology). A missing
404
500
  // last_seen_at is kept (conservative — could be a legitimately new node).
405
- const GHOST_MS = 24 * 60 * 60 * 1000;
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;
406
507
  const now = Date.now();
407
508
  const isGhost = (s: Session) => {
408
509
  if (s.status !== 'offline' || sseCount(s) || !s.last_seen_at) return false;
409
- const t = Date.parse(s.last_seen_at);
410
- return !Number.isNaN(t) && now - t > GHOST_MS;
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;
411
515
  };
412
516
  const online = sessions.filter(s => s.status !== 'offline' || sseCount(s)).sort(byAlias);
413
517
  const offline = sessions.filter(s => s.status === 'offline' && !sseCount(s) && !isGhost(s)).sort(byAlias);
@@ -470,11 +574,25 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
470
574
  // padding always fit and stacked group boxes never touch; past the
471
575
  // floor the grid overflows and zoom/pan handles it.
472
576
  const GROUP_TOP = nodeR + 20;
473
- // Row-height floor = the tightest of: a node + its label below it
474
- // (2·nodeR + ~22), and the label band + min box padding (GROUP_TOP + 12).
475
- // Below this, rows would overlap; past it the grid overflows and
476
- // zoom/pan covers the rest.
477
- const cellH = Math.max(2 * nodeR + 22, GROUP_TOP + 12, Math.min(100, (gy1 - gy0) / totalRows));
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
+ );
478
596
 
479
597
  // Pass 2 — place each band's members.
480
598
  for (const band of bands) {
@@ -505,14 +623,36 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
505
623
  const xs = pts.map(p => p.x);
506
624
  const ys = pts.map(p => p.y);
507
625
  const minX = Math.min(...xs), minY = Math.min(...ys);
626
+ // Round 58 / Loop: per-group status mix for the label pip strip.
627
+ // Working = status==='working'. Idle = online but not working.
628
+ // Offline = !isOnline (either status==='offline' AND no SSE, or
629
+ // ghost-purged elsewhere — but ghosts never reach groupBoxes
630
+ // since they're filtered out upstream). Counts feed the label
631
+ // tspans directly so the strip stays inside the label's bbox,
632
+ // preserving the node↔label overlap-test guarantee from R19.
633
+ let w = 0, i = 0, o = 0;
634
+ for (const s of band.members) {
635
+ const isOn = s.status !== 'offline' || !!sseCount(s);
636
+ if (s.status === 'working') w++;
637
+ else if (isOn) i++;
638
+ else o++;
639
+ }
508
640
  return {
509
641
  key: band.members.length ? groupKeys[band.members[0].alias] : '',
642
+ count: band.members.length,
643
+ statuses: { working: w, idle: i, offline: o },
510
644
  x: minX - GROUP_PAD,
511
645
  y: minY - GROUP_TOP,
512
646
  w: Math.max(...xs) - minX + GROUP_PAD * 2,
513
647
  h: Math.max(...ys) - minY + GROUP_TOP + GROUP_PAD,
514
648
  };
515
649
  });
650
+ // Round 28 / Loop: surface the grid's natural content bottom so the
651
+ // mount effect can auto-fit zoom when the layout would overflow the
652
+ // viewBox. = bottom of the last node row + its label drop + a small
653
+ // breathing buffer. Round 27's cellH bump makes this overflow more
654
+ // common (30-node fleets reach ~774 px, viewBox is 680).
655
+ const gridContentBottom = gy0 + totalRows * cellH + 8;
516
656
  return {
517
657
  onlineNodes: online,
518
658
  offlineNodes: offline,
@@ -521,6 +661,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
521
661
  activeAliases: active,
522
662
  groupKeys,
523
663
  groupBoxes,
664
+ gridContentBottom,
524
665
  };
525
666
  }
526
667
 
@@ -602,16 +743,147 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
602
743
  groupKeys,
603
744
  // #111: group boxes are a grid-layout feature only — radially scattered
604
745
  // ring nodes can't be cleanly boxed. Ring keeps the #83 prefix hue.
605
- groupBoxes: [] as { key: string; x: number; y: number; w: number; h: number }[],
746
+ groupBoxes: [] as { key: string; count: number; statuses: { working: number; idle: number; offline: number }; x: number; y: number; w: number; h: number }[],
747
+ // ring fits within VIEWBOX_H by construction (offlineRadius=325 + centre at y=330)
748
+ gridContentBottom: 0,
606
749
  };
607
750
  }, [messages, sessions, sseSessions, layout, nodeScale]);
608
751
 
609
752
  const workingCount = onlineNodes.filter(s => s.status === 'working').length;
753
+ // Round 6 / Loop: vendor distribution for the header chip — at a glance
754
+ // "what's in the fleet" (A:5 M:2 O:8 书:12 …) without opening a node.
755
+ // Sorted by count desc; "unknown" vendors collapse into a "?" bucket.
756
+ const vendorDist = useMemo(() => {
757
+ const tally = new Map<string, { initial: string; count: number; color: string }>();
758
+ for (const s of [...onlineNodes, ...offlineNodes]) {
759
+ const v = vendorForModel(s.model);
760
+ const key = v.id === 'unknown' ? '?' : v.initial;
761
+ const cur = tally.get(key);
762
+ if (cur) cur.count++;
763
+ else tally.set(key, { initial: key, count: 1, color: v.mono.text });
764
+ }
765
+ return [...tally.values()].sort((a, b) => b.count - a.count);
766
+ }, [onlineNodes, offlineNodes]);
610
767
  // Round 109 (Vincent 4582 P0): hover-gated labels above this node count
611
768
  // so dense fleets show clean avatars instead of a wall of overlapping
612
769
  // label cards. 16 ≈ where the triple-tier rings start to crowd.
613
770
  const denseLayout = onlineNodes.length + offlineNodes.length > 16;
614
771
  const [hoveredAlias, setHoveredAlias] = useState<string | null>(null);
772
+ // Round 8 / Loop: which group is currently focused. Nodes outside this
773
+ // group + other group boxes fade so the eye locks onto the team you're
774
+ // pointing at. Singletons use their own alias as the group key.
775
+ const hoveredGroup = hoveredAlias ? (groupKeys[hoveredAlias] ?? hoveredAlias) : null;
776
+ // Round 63 / Loop: sticky group focus. R8 dims non-group nodes while
777
+ // a member is hovered, but releasing the hover lets the focus fade.
778
+ // Clicking the group label pins the group key here; activeGroup =
779
+ // hoveredGroup ?? pinnedGroup so hover transiently overrides the
780
+ // pin (handy for spot-comparing teams). Same compose pattern as
781
+ // R60/R61 pinnedStatus.
782
+ const [pinnedGroup, setPinnedGroup] = useState<string | null>(() => {
783
+ // R66: same per-tab persistence treatment as pinnedStatus above.
784
+ // The group key is opaque text (could be any prefix), so the init
785
+ // reader can't validate it against a known enum — it just reads
786
+ // whatever's stored. A useEffect below clears it if the value no
787
+ // longer matches any current group (sessions changed since save).
788
+ if (typeof window === 'undefined') return null;
789
+ try { return sessionStorage.getItem('anet-topo-pinned-group'); } catch { return null; }
790
+ });
791
+ const activeGroup = hoveredGroup ?? pinnedGroup;
792
+ // R66: sync pinnedGroup into sessionStorage when it changes; clear
793
+ // it if it no longer matches any current group (the session set
794
+ // can change between loads). The pinnedStatus sync sits next to
795
+ // its declaration further down.
796
+ useEffect(() => {
797
+ try {
798
+ if (pinnedGroup) sessionStorage.setItem('anet-topo-pinned-group', pinnedGroup);
799
+ else sessionStorage.removeItem('anet-topo-pinned-group');
800
+ } catch {}
801
+ }, [pinnedGroup]);
802
+ useEffect(() => {
803
+ if (!pinnedGroup) return;
804
+ const known = new Set(Object.values(groupKeys));
805
+ if (!known.has(pinnedGroup)) setPinnedGroup(null);
806
+ }, [pinnedGroup, groupKeys]);
807
+ // Round 49 / Loop: reverse-direction of R40's edge-on-node-hover linkage.
808
+ // R48 widened the flow hitbox to 16 px, so edges are precise enough to
809
+ // serve as a state trigger. When the user hovers a flow edge, light up its
810
+ // two endpoint nodes and dim the rest — "who is this edge between" becomes
811
+ // visible without reading the tooltip. The set is the link's two aliases
812
+ // (null when no edge hovered); node opacity composes this after inFocus.
813
+ const [hoveredEdgeKey, setHoveredEdgeKey] = useState<string | null>(null);
814
+ // Round 77 / Loop: hovering the "N active links" header chip globally
815
+ // brightens every flow edge (1.5× opacity). Transient affordance — the
816
+ // chip already says HOW MANY active flows exist; this answers WHERE
817
+ // they are in one glance, without the user scanning the canvas for
818
+ // moving particles. Reset on mouse leave.
819
+ const [hoveredActiveLinks, setHoveredActiveLinks] = useState(false);
820
+ // Round 80 / Loop: vendor-letter hover in the distribution chip. The
821
+ // chip already names vendor mix (`C:5 G:3 ?:1`); hover a letter and
822
+ // every node from OTHER vendors dims. Surfaces the breakdown spatially
823
+ // without inventing a new pin slot. Stores the vendor `initial`
824
+ // (single char or "?") that matches the tally key.
825
+ const [hoveredVendor, setHoveredVendor] = useState<string | null>(null);
826
+ // Round 55 / Loop: hovering a legend status row dims nodes whose status
827
+ // doesn't match. The legend was passive — "what does this colour mean".
828
+ // Now it answers "show me all of these" the same way R8 group-focus
829
+ // answers "show me this team". Three values match the legend rows.
830
+ const [hoveredStatus, setHoveredStatus] = useState<'working' | 'idle' | 'offline' | null>(null);
831
+ // Round 60 / Loop: sticky variant of `hoveredStatus`. R55 only filters
832
+ // while the user is actively hovering the legend; for sweeping a fleet
833
+ // you want to LOCK the filter. Each segment of the R31 pressure bar
834
+ // toggles a pin — click again on the same segment to release. The node
835
+ // opacity formula reads `activeStatus = hoveredStatus ?? pinnedStatus`
836
+ // so hover transiently overrides a pin (handy for spot-comparison)
837
+ // without nuking it.
838
+ // Round 66 / Loop: pin survives a page reload via sessionStorage —
839
+ // per-tab not per-browser (a new tab starts clean, intentionally).
840
+ // The init reader validates against the known status set so a stale
841
+ // / corrupt value can't paint the canvas with an impossible filter.
842
+ const [pinnedStatus, setPinnedStatus] = useState<'working' | 'idle' | 'offline' | null>(() => {
843
+ if (typeof window === 'undefined') return null;
844
+ try {
845
+ const v = sessionStorage.getItem('anet-topo-pinned-status');
846
+ return (v === 'working' || v === 'idle' || v === 'offline') ? v : null;
847
+ } catch { return null; }
848
+ });
849
+ const activeStatus = hoveredStatus ?? pinnedStatus;
850
+ // R66: sync pinnedStatus into sessionStorage when it changes. Paired
851
+ // with the matching effect for pinnedGroup higher up.
852
+ useEffect(() => {
853
+ try {
854
+ if (pinnedStatus) sessionStorage.setItem('anet-topo-pinned-status', pinnedStatus);
855
+ else sessionStorage.removeItem('anet-topo-pinned-status');
856
+ } catch {}
857
+ }, [pinnedStatus]);
858
+ // R69: listen for Cmd+K palette pin actions. The palette can't reach
859
+ // this component's state directly so it dispatches a CustomEvent;
860
+ // we react by setting pinnedStatus / pinnedGroup. The palette also
861
+ // writes to sessionStorage in lockstep, so the R66 init readers
862
+ // would pick it up on reload — the event handler is what keeps the
863
+ // currently-mounted canvas in sync without one.
864
+ useEffect(() => {
865
+ const onPin = (e: Event) => {
866
+ const detail = (e as CustomEvent).detail || {};
867
+ if (detail.kind === 'clear') {
868
+ setPinnedStatus(null);
869
+ setPinnedGroup(null);
870
+ } else if (detail.kind === 'status') {
871
+ const v = detail.value;
872
+ if (v === 'working' || v === 'idle' || v === 'offline') setPinnedStatus(v);
873
+ } else if (detail.kind === 'group' && typeof detail.value === 'string') {
874
+ setPinnedGroup(detail.value);
875
+ }
876
+ };
877
+ window.addEventListener('anet:topo-pin', onPin);
878
+ return () => window.removeEventListener('anet:topo-pin', onPin);
879
+ }, []);
880
+ // R74 listener for layout + view palette commands lives below
881
+ // fitView's declaration — see further down in the file.
882
+ const hoveredEdgeEndpoints = useMemo<Set<string> | null>(() => {
883
+ if (!hoveredEdgeKey) return null;
884
+ const link = flowLinks.find(l => l.key === hoveredEdgeKey);
885
+ return link ? new Set([link.from, link.to]) : null;
886
+ }, [hoveredEdgeKey, flowLinks]);
615
887
 
616
888
  // --- Round 103 (issue #81): fullscreen + zoom + pan interaction layer ---
617
889
  // DIY native (no d3 / svg-pan-zoom): wrap the topology content in a single
@@ -631,6 +903,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
631
903
  // Issue #100: singleton chat popover. One alias at a time — clicking
632
904
  // another node swaps the target and the conversation switches in place.
633
905
  const [chatAlias, setChatAlias] = useState<string | null>(null);
906
+ // Round 14 / Loop: one-shot click ripple — fades outward from the clicked
907
+ // node so the click registers physically before the popover snaps in.
908
+ // Pairs with the Round 11 chat-focus ring: ripple expands, ring locks on.
909
+ // Keyed by ts so re-clicking the same node remounts the <circle> and the
910
+ // SMIL <animate> replays. Cleared 600ms after click (longer than the
911
+ // 500ms animation so a final frame at opacity 0 is still in the tree).
912
+ const [clickRipple, setClickRipple] = useState<{
913
+ ts: number; x: number; y: number; r0: number; color: string;
914
+ } | null>(null);
634
915
  // #84: when a node is renamed while its chat popover is open, follow the
635
916
  // rename so the conversation keeps targeting a live alias.
636
917
  useEffect(() => {
@@ -661,6 +942,42 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
661
942
  } catch {}
662
943
  }, []);
663
944
 
945
+ // Round 28 / Loop: first-paint auto-fit. Round 27's cellH bump means
946
+ // dense grids (≥6-7 rows) overflow the 680-px viewBox at the natural
947
+ // 100% zoom — a new user sees the topology with its bottom rows
948
+ // clipped and no hint that there's more below. Auto-fit on first
949
+ // paint (and only if the user has no persisted view) sets the
950
+ // initial zoom so all content fits. Subsequent zoom changes persist
951
+ // and override the auto-fit on reload; explicit reset (0 key) still
952
+ // goes back to 100% so the gesture's "reset to natural size" semantic
953
+ // is preserved.
954
+ //
955
+ // Capture pre-mount persistence in a useState initializer — the
956
+ // existing `persist` effect (declared below) runs on first render
957
+ // with the default {1,0,0} view and writes it to localStorage before
958
+ // the auto-fit effect (deps on async-arriving sessions) gets a turn.
959
+ // Reading localStorage from the effect would see that write and
960
+ // skip the fit. The useState snapshot fires once, before any effects.
961
+ const [hadPersistedViewOnMount] = useState<boolean>(
962
+ () => typeof window !== 'undefined' && !!localStorage.getItem('anet-topo-view'),
963
+ );
964
+ const autoFitDoneRef = useRef(false);
965
+ useEffect(() => {
966
+ if (autoFitDoneRef.current) return;
967
+ if (hadPersistedViewOnMount) {
968
+ autoFitDoneRef.current = true;
969
+ return;
970
+ }
971
+ if (layout !== 'grid' || sessions.length === 0 || !gridContentBottom) return;
972
+ if (gridContentBottom <= VIEWBOX_H) {
973
+ autoFitDoneRef.current = true; // no overflow → no fit needed
974
+ return;
975
+ }
976
+ const fitZoom = Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
977
+ setView({ zoom: fitZoom, x: 0, y: 0 });
978
+ autoFitDoneRef.current = true;
979
+ }, [layout, sessions.length, gridContentBottom, hadPersistedViewOnMount]);
980
+
664
981
  // persist view
665
982
  useEffect(() => {
666
983
  try { localStorage.setItem('anet-topo-view', JSON.stringify(view)); } catch {}
@@ -680,6 +997,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
680
997
  const svg = svgRef.current;
681
998
  if (!svg) return;
682
999
  const onWheel = (e: WheelEvent) => {
1000
+ // Round 23 / Loop: only zoom on Ctrl/Meta+wheel when inline; plain
1001
+ // wheel over the canvas keeps scrolling the page (the topo sits at
1002
+ // the top of /; trapping page scroll mid-page is the classic
1003
+ // "scroll-jail" anti-pattern). In fullscreen the canvas owns the
1004
+ // viewport so any wheel zooms — no page scroll to preserve. Pinch-
1005
+ // zoom on a trackpad surfaces as ctrlKey=true natively, which is
1006
+ // also what we want (zoom the canvas, not the browser).
1007
+ if (!isFullscreen && !e.ctrlKey && !e.metaKey) return;
683
1008
  e.preventDefault();
684
1009
  const rect = svg.getBoundingClientRect();
685
1010
  const mx = ((e.clientX - rect.left) / rect.width) * VIEWBOX_W;
@@ -700,8 +1025,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
700
1025
  };
701
1026
  svg.addEventListener('wheel', onWheel, { passive: false });
702
1027
  return () => svg.removeEventListener('wheel', onWheel);
703
- }, []);
704
-
1028
+ // Re-attach when the fullscreen flag toggles so the handler's
1029
+ // closure picks up the new value (capture-by-closure means we'd
1030
+ // otherwise read the stale boolean forever).
1031
+ }, [isFullscreen]);
1032
+
1033
+ // Round 21 / Loop: pan-aware cursor. Static `grab` gave no tactile cue
1034
+ // that the canvas was actually being dragged — "grabbing" while
1035
+ // dragRef.current.active = true tells the hand it's moving the
1036
+ // viewport. Mirroring dragRef.active into state is the only way to
1037
+ // re-render the SVG style; the ref itself doesn't trigger renders.
1038
+ const [isPanning, setIsPanning] = useState(false);
705
1039
  const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
706
1040
  if (e.button !== 0) return;
707
1041
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
@@ -712,6 +1046,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
712
1046
  baseX: viewRef.current.x,
713
1047
  baseY: viewRef.current.y,
714
1048
  };
1049
+ setIsPanning(true);
715
1050
  };
716
1051
  const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
717
1052
  const d = dragRef.current;
@@ -726,6 +1061,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
726
1061
  const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
727
1062
  if (!dragRef.current.active) return;
728
1063
  dragRef.current.active = false;
1064
+ setIsPanning(false);
729
1065
  try {
730
1066
  (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
731
1067
  } catch {}
@@ -743,6 +1079,86 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
743
1079
  };
744
1080
  const resetView = () => setView({ zoom: 1, x: 0, y: 0 });
745
1081
 
1082
+ // Round 29 / Loop: `f` = fit-to-content. Shared by the Round 28
1083
+ // first-paint auto-fit effect and the keyboard handler so the math is
1084
+ // in one place. When content already fits at natural zoom, this is
1085
+ // effectively a "recenter" — `f` always lands on a known good view.
1086
+ const fitView = useCallback(() => {
1087
+ const zoom = !gridContentBottom || gridContentBottom <= VIEWBOX_H
1088
+ ? 1
1089
+ : Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
1090
+ setView({ zoom, x: 0, y: 0 });
1091
+ }, [gridContentBottom]);
1092
+
1093
+ // R74: listen for layout + view palette commands. Sister to R69's
1094
+ // pin listener — palette dispatches a CustomEvent, the reducer here
1095
+ // calls toggleLayout / fitView. Sits below fitView's declaration so
1096
+ // the deps list resolves cleanly.
1097
+ useEffect(() => {
1098
+ const onLayout = (e: Event) => {
1099
+ const detail = (e as CustomEvent).detail || {};
1100
+ if (detail.kind === 'toggle') toggleLayout();
1101
+ };
1102
+ const onView = (e: Event) => {
1103
+ const detail = (e as CustomEvent).detail || {};
1104
+ if (detail.kind === 'fit') fitView();
1105
+ };
1106
+ window.addEventListener('anet:topo-layout', onLayout);
1107
+ window.addEventListener('anet:topo-view', onView);
1108
+ return () => {
1109
+ window.removeEventListener('anet:topo-layout', onLayout);
1110
+ window.removeEventListener('anet:topo-view', onView);
1111
+ };
1112
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1113
+ }, [fitView]);
1114
+
1115
+ // Round 22 / Loop: keyboard zoom — +/= zoom in, - zoom out, 0 reset.
1116
+ // Round 29 / Loop: +f to fit content.
1117
+ // Listen on window so the user doesn't need to focus the SVG first,
1118
+ // but only act when no text input has focus (otherwise typing "-" in
1119
+ // a chat would zoom the topology — surprising and bad). Modifier keys
1120
+ // pass through so Cmd/Ctrl combos still hit their owners. The button
1121
+ // titles below document these shortcuts so users discover them.
1122
+ useEffect(() => {
1123
+ const onKey = (e: KeyboardEvent) => {
1124
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
1125
+ const ae = document.activeElement as HTMLElement | null;
1126
+ if (ae) {
1127
+ const tag = ae.tagName;
1128
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || ae.isContentEditable) return;
1129
+ }
1130
+ if (e.key === '+' || e.key === '=') { zoomBy(1.2); e.preventDefault(); }
1131
+ else if (e.key === '-' || e.key === '_') { zoomBy(1 / 1.2); e.preventDefault(); }
1132
+ else if (e.key === '0') { resetView(); e.preventDefault(); }
1133
+ else if (e.key === 'f' || e.key === 'F') { fitView(); e.preventDefault(); }
1134
+ // Round 32 / Loop: `l` toggles ring|grid. The vim-style `g l` route
1135
+ // (Audit Log) requires a preceding `g` within 1500ms; a bare `l`
1136
+ // outside that window is free for topology use.
1137
+ else if (e.key === 'l' || e.key === 'L') { toggleLayout(); e.preventDefault(); }
1138
+ // Round 62 / Loop: Esc clears the R60/R61 pinned status filter so
1139
+ // users have a universal-cancel keyboard out. Esc on an open chat
1140
+ // is owned by ChatPopover (which only mounts when chatAlias is
1141
+ // set), so this handler is effectively scoped to "no chat open".
1142
+ // We additionally guard on chatAlias to be explicit — if the chat
1143
+ // closed mid-cycle, the pin can still be cleared on the next Esc.
1144
+ // R63: extends to pinnedGroup too. Clears WHATEVER pin is active
1145
+ // (one Esc collapses all topology pins) so the keyboard escape
1146
+ // route stays a single key, not "Esc maybe Esc again".
1147
+ else if (e.key === 'Escape' && !chatAlias && (pinnedStatus || pinnedGroup)) {
1148
+ if (pinnedStatus) setPinnedStatus(null);
1149
+ if (pinnedGroup) setPinnedGroup(null);
1150
+ e.preventDefault();
1151
+ }
1152
+ };
1153
+ window.addEventListener('keydown', onKey);
1154
+ return () => window.removeEventListener('keydown', onKey);
1155
+ // zoomBy / resetView are stable wrt setView callback; fitView changes
1156
+ // with gridContentBottom so deps list catches it. R62 adds chatAlias
1157
+ // and pinnedStatus so the Escape branch reads fresh state (re-binding
1158
+ // the listener on these state changes is sub-ms — cheaper than refs).
1159
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1160
+ }, [fitView, chatAlias, pinnedStatus, pinnedGroup]);
1161
+
746
1162
  const toggleFullscreen = () => {
747
1163
  const el = containerRef.current;
748
1164
  if (!el) return;
@@ -770,6 +1186,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
770
1186
  <button
771
1187
  onClick={() => { if (layout !== 'ring') toggleLayout(); }}
772
1188
  aria-pressed={layout === 'ring'}
1189
+ title="Ring layout (l to toggle)"
773
1190
  className={`px-2.5 py-1 transition-colors ${layout === 'ring' ? 'bg-cyan-500/15 text-cyan-300' : 'text-gray-500 hover:text-gray-400'}`}
774
1191
  >
775
1192
  Ring
@@ -777,20 +1194,281 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
777
1194
  <button
778
1195
  onClick={() => { if (layout !== 'grid') toggleLayout(); }}
779
1196
  aria-pressed={layout === 'grid'}
1197
+ title="Grid layout (l to toggle)"
780
1198
  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'}`}
781
1199
  >
782
1200
  Grid
783
1201
  </button>
784
1202
  </div>
785
- <span className="px-2.5 py-1 rounded-md bg-green-500/10 text-green-300 border border-green-500/20">
1203
+ {/* R79: working + online count chips become hover affordances —
1204
+ extends R77's chip-hover pattern to status counts. Hover the
1205
+ working chip → setHoveredStatus('working') so the R55 dim
1206
+ mechanic kicks in (same as hovering the SVG legend "working"
1207
+ row, just a different surface for the same gesture).
1208
+ The online chip uses the same setHoveredStatus('idle') path
1209
+ when there's idle but no working — falling back to working
1210
+ if any working node exists; "online" without a single bucket
1211
+ isn't part of the R55 type set, so this chip routes to the
1212
+ dominant online sub-tier instead of inventing a new state.
1213
+ Cursor only flips when there's anything to highlight. */}
1214
+ {/* R82: pin-mirror. R60 pressure-bar segments visualise the
1215
+ pinned status via an inset boxShadow; R61 legend rows via a
1216
+ concentric r=8 ring; the chip-row chips next to them did
1217
+ not — so a user who pinned via Cmd+K (R69) or the legend
1218
+ had no chip-row signal that "working" was the current
1219
+ filter. Mirror the pressure-bar treatment here so all
1220
+ status surfaces sing in unison. The online chip mirrors
1221
+ for idle pins (working ⊆ online; the routing in
1222
+ onMouseEnter already treats online as the idle fallback). */}
1223
+ <span
1224
+ className="px-2.5 py-1 rounded-md bg-green-500/10 text-green-300 border border-green-500/20"
1225
+ data-working-chip
1226
+ data-pin-mirror={pinnedStatus === 'working' ? 'true' : 'false'}
1227
+ title={workingCount > 0 ? (pinnedStatus === 'working' ? 'Pinned — Esc to clear' : 'Hover to highlight working nodes') : undefined}
1228
+ style={{
1229
+ cursor: workingCount > 0 ? 'pointer' : undefined,
1230
+ boxShadow: pinnedStatus === 'working' ? 'inset 0 0 0 1px #4ade80, inset 0 0 0 2px rgba(255,255,255,0.45)' : undefined,
1231
+ }}
1232
+ onMouseEnter={() => { if (workingCount > 0) setHoveredStatus('working'); }}
1233
+ onMouseLeave={() => setHoveredStatus(prev => prev === 'working' ? null : prev)}
1234
+ >
786
1235
  {workingCount} working
787
1236
  </span>
788
- <span className="px-2.5 py-1 rounded-md bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
1237
+ <span
1238
+ className="px-2.5 py-1 rounded-md bg-cyan-500/10 text-cyan-300 border border-cyan-500/20"
1239
+ data-online-chip
1240
+ data-pin-mirror={pinnedStatus === 'idle' ? 'true' : 'false'}
1241
+ title={onlineNodes.length > 0 ? (pinnedStatus === 'idle' ? 'Pinned — Esc to clear' : 'Hover to highlight online nodes') : undefined}
1242
+ style={{
1243
+ cursor: onlineNodes.length > 0 ? 'pointer' : undefined,
1244
+ boxShadow: pinnedStatus === 'idle' ? 'inset 0 0 0 1px #67e8f9, inset 0 0 0 2px rgba(255,255,255,0.45)' : undefined,
1245
+ }}
1246
+ onMouseEnter={() => {
1247
+ // If a working filter would isolate nothing, route to idle.
1248
+ const idleCount = onlineNodes.length - workingCount;
1249
+ if (workingCount > 0) setHoveredStatus('working');
1250
+ else if (idleCount > 0) setHoveredStatus('idle');
1251
+ }}
1252
+ onMouseLeave={() => setHoveredStatus(prev => prev === 'working' || prev === 'idle' ? null : prev)}
1253
+ >
789
1254
  {onlineNodes.length} online
790
1255
  </span>
791
- <span className="px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20">
792
- {flowLinks.length} active links
793
- </span>
1256
+ {/* Round 31 / Loop: fleet-health pressure bar. The "X working /
1257
+ Y online" chips already carry the raw counts; the bar lets
1258
+ the eye get the working/idle/offline RATIO in one glance
1259
+ without mental math. Stacked 3-segment chip, ~64 px wide. */}
1260
+ {(() => {
1261
+ const w = workingCount;
1262
+ const i = onlineNodes.length - workingCount; // idle = online - working
1263
+ const o = offlineNodes.length;
1264
+ const total = w + i + o;
1265
+ if (total === 0) return null;
1266
+ // Round 60 / Loop: each segment toggles a sticky filter via
1267
+ // `pinnedStatus`. Click the working segment → all non-working
1268
+ // nodes dim; click again → release. Segments share width with
1269
+ // their proportion of `total`, so a 1-node working share is
1270
+ // ~3 px wide on a 64-px bar. We pad the click target with a
1271
+ // negative-margin overlay wrapper to give thin slices a
1272
+ // 14-px minimum hit area without disturbing the rendered
1273
+ // chip width. cursor:pointer + a one-line title hint at the
1274
+ // affordance.
1275
+ const seg = (n: number, color: string, key: 'working' | 'idle' | 'offline', label: string) => {
1276
+ if (n === 0) return null;
1277
+ const isPinned = pinnedStatus === key;
1278
+ return (
1279
+ <span
1280
+ key={key}
1281
+ data-pressure-seg={key}
1282
+ role="button"
1283
+ tabIndex={0}
1284
+ aria-pressed={isPinned}
1285
+ title={`${n} ${label} — click to ${isPinned ? 'release filter' : 'highlight'}`}
1286
+ style={{
1287
+ width: `${(n / total) * 100}%`,
1288
+ background: color,
1289
+ height: '100%',
1290
+ cursor: 'pointer',
1291
+ boxShadow: isPinned ? `inset 0 0 0 1px ${color}, inset 0 0 0 2px rgba(255,255,255,0.6)` : undefined,
1292
+ }}
1293
+ onClick={(e) => {
1294
+ e.stopPropagation();
1295
+ setPinnedStatus(prev => prev === key ? null : key);
1296
+ }}
1297
+ onKeyDown={(e) => {
1298
+ if (e.key === 'Enter' || e.key === ' ') {
1299
+ e.preventDefault();
1300
+ setPinnedStatus(prev => prev === key ? null : key);
1301
+ }
1302
+ }}
1303
+ // R83: hover preview — segments now match the R55 legend,
1304
+ // R79 working/online chips, and R80 vendor letters in
1305
+ // offering a hover-transient highlight before the click
1306
+ // commits to a pin. Users get to feel what the filter
1307
+ // does before they lock it in. Same activeStatus =
1308
+ // hoveredStatus ?? pinnedStatus formula; releasing the
1309
+ // pointer falls back to the pin if one is set, or to
1310
+ // baseline. Thin (3-px) segments still hit-test fine
1311
+ // because the chip itself is a flex row — span doesn't
1312
+ // need any extra hit padding for hover.
1313
+ onMouseEnter={() => setHoveredStatus(key)}
1314
+ onMouseLeave={() => setHoveredStatus(prev => prev === key ? null : prev)}
1315
+ />
1316
+ );
1317
+ };
1318
+ // Round 47 / Loop: hidden on mobile — at <640px the chip row
1319
+ // wraps to multiple lines and crowds the topology header;
1320
+ // pressure ratio is best read with the working+online raw
1321
+ // counts (kept visible) anyway.
1322
+ return (
1323
+ <span
1324
+ 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"
1325
+ title={`${w} working · ${i} idle · ${o} offline`}
1326
+ data-fleet-pressure
1327
+ >
1328
+ <span className="text-[10px] tracking-wide">pressure</span>
1329
+ <span className="inline-flex h-1.5 w-16 rounded-full overflow-hidden" style={{ background: 'rgb(75 85 99 / 0.25)' }}>
1330
+ {seg(w, isLight ? '#059669' : '#22c55e', 'working', 'working')}
1331
+ {seg(i, isLight ? '#0d9488' : '#2dd4bf', 'idle', 'idle')}
1332
+ {seg(o, isLight ? '#94a3b8' : '#6b7280', 'offline', 'offline')}
1333
+ </span>
1334
+ </span>
1335
+ );
1336
+ })()}
1337
+ {/* Round 64 / Loop: active-filter pills. When pinnedStatus or
1338
+ pinnedGroup is set, show a small "filter: <key> ×" pill so
1339
+ the user can see the pin from the chip row even if they
1340
+ scrolled the canvas off-screen. × button clears the
1341
+ specific pin (Esc still clears all — both paths are
1342
+ valid). pinnedStatus and pinnedGroup pin independently so
1343
+ both pills may render simultaneously. */}
1344
+ {/* R67: pills enter via anet-fade-in so they appear softly
1345
+ instead of popping. The "filter: " prefix collapses below
1346
+ sm — at narrow viewports the chip row is precious real
1347
+ estate (R47 already hides pressure / vendor / freshness),
1348
+ so dropping the redundant label keeps the working/idle/
1349
+ alpha keys readable without overflow. */}
1350
+ {/* R71: each pill picks up a "· N" match count tail. Tells the
1351
+ user at a glance how many sessions the active filter
1352
+ matches without scanning the canvas. Counts come from the
1353
+ already-computed workingCount + onlineNodes + offlineNodes
1354
+ for status, and groupKeys for group. */}
1355
+ {/* R73: entire pill body is a click-to-clear target — matches
1356
+ the Notion / Linear tag UX (the whole chip releases). The
1357
+ × keeps its dedicated <button> + aria-label for screen
1358
+ readers; the outer span just adds an extra mouse-friendly
1359
+ hit area with a title hint. ×'s onClick stopPropagation
1360
+ so the redundant outer onClick doesn't double-fire (no
1361
+ functional difference since both clear, but cleaner
1362
+ event flow). */}
1363
+ {pinnedStatus && (() => {
1364
+ const matchCount = pinnedStatus === 'working' ? workingCount
1365
+ : pinnedStatus === 'idle' ? (onlineNodes.length - workingCount)
1366
+ : offlineNodes.length;
1367
+ return (
1368
+ <span
1369
+ data-active-filter="status"
1370
+ data-filter-match-count={matchCount}
1371
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in"
1372
+ title="Click to clear filter"
1373
+ onClick={() => setPinnedStatus(null)}
1374
+ style={{
1375
+ background: pinnedStatus === 'working' ? (isLight ? '#05966914' : '#22c55e1f')
1376
+ : pinnedStatus === 'idle' ? (isLight ? '#0d948814' : '#2dd4bf1f')
1377
+ : (isLight ? '#94a3b814' : '#6b72801f'),
1378
+ color: pinnedStatus === 'working' ? (isLight ? '#047857' : '#86efac')
1379
+ : pinnedStatus === 'idle' ? (isLight ? '#0f766e' : '#5eead4')
1380
+ : (isLight ? '#475569' : '#9ca3af'),
1381
+ borderColor: 'currentColor',
1382
+ cursor: 'pointer',
1383
+ }}
1384
+ >
1385
+ <span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{pinnedStatus}<span className="opacity-70"> · {matchCount}</span></span>
1386
+ <button
1387
+ type="button"
1388
+ aria-label={`Clear ${pinnedStatus} filter`}
1389
+ onClick={(e) => { e.stopPropagation(); setPinnedStatus(null); }}
1390
+ className="ml-0.5 leading-none hover:opacity-70"
1391
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1392
+ >×</button>
1393
+ </span>
1394
+ );
1395
+ })()}
1396
+ {pinnedGroup && (() => {
1397
+ const matchCount = Object.values(groupKeys).filter(k => k === pinnedGroup).length;
1398
+ return (
1399
+ <span
1400
+ data-active-filter="group"
1401
+ data-filter-match-count={matchCount}
1402
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in"
1403
+ title="Click to clear filter"
1404
+ onClick={() => setPinnedGroup(null)}
1405
+ style={{
1406
+ background: isLight ? '#67e8f914' : '#67e8f91f',
1407
+ color: pal.legendAccent,
1408
+ borderColor: 'currentColor',
1409
+ cursor: 'pointer',
1410
+ }}
1411
+ >
1412
+ <span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{pinnedGroup}<span className="opacity-70"> · {matchCount}</span></span>
1413
+ <button
1414
+ type="button"
1415
+ aria-label={`Clear group filter ${pinnedGroup}`}
1416
+ onClick={(e) => { e.stopPropagation(); setPinnedGroup(null); }}
1417
+ className="ml-0.5 leading-none hover:opacity-70"
1418
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1419
+ >×</button>
1420
+ </span>
1421
+ );
1422
+ })()}
1423
+ {vendorDist.length > 1 && (
1424
+ <span
1425
+ 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"
1426
+ title="Hover a letter to highlight that vendor"
1427
+ >
1428
+ {vendorDist.map(v => (
1429
+ <span
1430
+ key={v.initial}
1431
+ className="inline-flex items-baseline gap-0.5"
1432
+ data-vendor-letter={v.initial}
1433
+ style={{ cursor: 'pointer' }}
1434
+ onMouseEnter={() => setHoveredVendor(v.initial)}
1435
+ onMouseLeave={() => setHoveredVendor(prev => prev === v.initial ? null : prev)}
1436
+ >
1437
+ <span style={{ color: v.color }}>{v.initial}</span>
1438
+ <span className="text-gray-500">:{v.count}</span>
1439
+ </span>
1440
+ ))}
1441
+ </span>
1442
+ )}
1443
+ {/* Round 42 / Loop: extend active-links chip with the timestamp
1444
+ of the most-recent flow event. Tells the operator at a glance
1445
+ whether the topology is currently humming (last 30s) or has
1446
+ been quiet for a while — the visual flow particles and
1447
+ edge brightness only show that there IS traffic, not when
1448
+ the last one was. Reuses Round 38's relativeAgo. */}
1449
+ {(() => {
1450
+ const recent = flowLinks.reduce<number | null>((acc, l) => {
1451
+ if (!l.last_at) return acc;
1452
+ const t = parseHubTime(l.last_at);
1453
+ if (t === null) return acc;
1454
+ return acc === null || t > acc ? t : acc;
1455
+ }, null);
1456
+ const rel = recent !== null ? relativeAgo(new Date(recent).toISOString()) : null;
1457
+ return (
1458
+ <span
1459
+ className="hidden sm:inline px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20"
1460
+ data-active-links-chip
1461
+ title={flowLinks.length > 0 ? 'Hover to brighten all active flows' : undefined}
1462
+ style={{ cursor: flowLinks.length > 0 ? 'pointer' : undefined }}
1463
+ onMouseEnter={() => { if (flowLinks.length > 0) setHoveredActiveLinks(true); }}
1464
+ onMouseLeave={() => setHoveredActiveLinks(false)}
1465
+ >
1466
+ {flowLinks.length} active link{flowLinks.length === 1 ? '' : 's'}
1467
+ {rel ? <span className="text-gray-500"> · last {rel}</span> : null}
1468
+ </span>
1469
+ );
1470
+ })()}
1471
+ <FreshnessChip sessions={sessions} />
794
1472
  </div>
795
1473
  </div>
796
1474
 
@@ -810,7 +1488,18 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
810
1488
  onPointerMove={onPointerMove}
811
1489
  onPointerUp={onPointerUp}
812
1490
  onPointerLeave={onPointerUp}
813
- style={{ cursor: 'grab', touchAction: 'none' }}
1491
+ // Round 41 / Loop: the reset-button title (R22) and the Help
1492
+ // overlay (R25) both advertise "double-click the canvas to
1493
+ // reset", but the handler was never actually wired. The text
1494
+ // was lying. Wire it here, guarded so dbl-clicking a node
1495
+ // (which would also trigger the SVG-level handler via event
1496
+ // bubbling) doesn't unexpectedly reset the view on the user.
1497
+ onDoubleClick={(e) => {
1498
+ const t = e.target as Element | null;
1499
+ if (t?.closest('g[data-node]')) return;
1500
+ resetView();
1501
+ }}
1502
+ style={{ cursor: isPanning ? 'grabbing' : 'grab', touchAction: 'none' }}
814
1503
  >
815
1504
  <defs>
816
1505
  <linearGradient id="topo-panel" x1="0" x2="1" y1="0" y2="1">
@@ -832,9 +1521,37 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
832
1521
  </feMerge>
833
1522
  </filter>
834
1523
  )}
835
- <marker id="topo-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
836
- <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
837
- </marker>
1524
+ {/* Round 16 / Loop: 3-tier flow-link arrow markers.
1525
+ The single marker had `markerUnits` defaulting to
1526
+ `strokeWidth`, so heavy edges (stroke=7) rendered 35-user-
1527
+ unit arrowheads — visually dominant, the head outweighed
1528
+ the line. Switching to `userSpaceOnUse` decouples arrow
1529
+ size from stroke; binning by count gives a clearer
1530
+ hierarchy than continuous linear scaling:
1531
+ s (count 1-2) → 12 user units
1532
+ m (count 3-4) → 16 user units (alias for `topo-arrow`)
1533
+ l (count 5+) → 22 user units
1534
+ `topo-arrow` stays bound to the medium tier so the legend
1535
+ swatch (line ~1500) renders without change. */}
1536
+ {[
1537
+ { id: 'topo-arrow-s', size: 12 },
1538
+ { id: 'topo-arrow', size: 16 },
1539
+ { id: 'topo-arrow-l', size: 22 },
1540
+ ].map(m => (
1541
+ <marker
1542
+ key={m.id}
1543
+ id={m.id}
1544
+ viewBox="0 0 10 10"
1545
+ refX="8"
1546
+ refY="5"
1547
+ markerWidth={m.size}
1548
+ markerHeight={m.size}
1549
+ markerUnits="userSpaceOnUse"
1550
+ orient="auto-start-reverse"
1551
+ >
1552
+ <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
1553
+ </marker>
1554
+ ))}
838
1555
  {/* Round 45: radial radar sweep gradient — bright at center
839
1556
  (where it meets the hub) fading to leading edge. */}
840
1557
  <radialGradient id="topo-sweep" cx="0%" cy="50%" r="100%">
@@ -854,13 +1571,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
854
1571
  grid mode drops it so the concentric rings don't sit behind a
855
1572
  rectangular grid. */}
856
1573
  {layout === 'ring' && (<>
857
- <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" />
1574
+ {/* R52: radar bg is pure decoration — drop its pointer events so
1575
+ the hub <g> under it (and any future inner-disk affordances)
1576
+ can receive clicks. Previously the r=330 disc intercepted
1577
+ the hub click outright. */}
1578
+ <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" style={{ pointerEvents: 'none' }} />
858
1579
 
859
1580
  {/* Round 45: subtle star field — 24 deterministic dots scattered
860
1581
  across the canvas give the radar bg some depth. Skipped on
861
1582
  light theme so the white surface stays clean. */}
862
1583
  {!isLight && (
863
- <g opacity="0.5">
1584
+ <g opacity="0.5" style={{ pointerEvents: 'none' }}>
864
1585
  {Array.from({ length: 28 }).map((_, i) => {
865
1586
  // Deterministic pseudo-random scatter so positions are
866
1587
  // stable between renders (no JS hydration mismatch).
@@ -882,6 +1603,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
882
1603
  style={{
883
1604
  transformOrigin: `${cx}px ${cy}px`,
884
1605
  transformBox: 'view-box',
1606
+ pointerEvents: 'none',
885
1607
  }}
886
1608
  className="anet-topo-sweep"
887
1609
  opacity={isLight ? 0.7 : 1}
@@ -892,11 +1614,44 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
892
1614
  />
893
1615
  </g>
894
1616
 
895
- {/* radar rings */}
1617
+ {/* radar rings — pure decoration at fixed radii, independent of
1618
+ node positions so the radar aesthetic is preserved across tier
1619
+ changes. */}
896
1620
  {[90, 170, 250, 330].map(radius => (
897
1621
  <circle key={radius} cx={cx} cy={cy} r={radius} fill="none" stroke={pal.ringStroke} strokeWidth="1" opacity={isLight ? 0.6 : 0.35} />
898
1622
  ))}
899
1623
 
1624
+ {/* Round 54 / Loop: tier-radius guide rings. The radar rings above
1625
+ are decorative and don't match the actual tier radii nodes sit
1626
+ on (single 220 / dual 175,260 / triple 145,215,285). Drawing
1627
+ a faint dashed ring at each ACTIVE tier radius lets the eye
1628
+ anchor "this is the inner / outer ring" without inferring from
1629
+ node spacing. Picked based on online node count so only the
1630
+ tiers currently in use draw — empty tiers stay quiet. pointer-
1631
+ events:none so they never intercept hub or node clicks. The
1632
+ 0.7 stroke + dashed pattern reads as guide, not feature. */}
1633
+ {(
1634
+ onlineNodes.length > onlineTripleThreshold
1635
+ ? [onlineTripleInnerR, onlineTripleMidR, onlineTripleOuterR]
1636
+ : onlineNodes.length > onlineTierThreshold
1637
+ ? [onlineInnerRadius, onlineOuterRadius]
1638
+ : onlineNodes.length > 0
1639
+ ? [onlineRadius]
1640
+ : []
1641
+ ).map(r => (
1642
+ <circle
1643
+ key={`tier-${r}`}
1644
+ cx={cx} cy={cy} r={r}
1645
+ fill="none"
1646
+ stroke={pal.ringStroke}
1647
+ strokeWidth="0.7"
1648
+ strokeDasharray="2 8"
1649
+ opacity={isLight ? 0.32 : 0.42}
1650
+ style={{ pointerEvents: 'none' }}
1651
+ data-tier-ring={r}
1652
+ />
1653
+ ))}
1654
+
900
1655
  {/* Round 50: 4 small particles slowly orbiting the outer ring
901
1656
  (r=330). Each starts at a different angle (offset 0/0.25/0.5/0.75
902
1657
  of the cycle) so they're evenly spaced. 16s per revolution is
@@ -966,32 +1721,119 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
966
1721
  only — groupBoxes is empty in ring mode. Rendered behind the
967
1722
  flow links + nodes; pointer-events off so they never intercept
968
1723
  a node click. Restrained dashed container + group-name label. */}
969
- {groupBoxes.map(box => (
970
- <g key={`grp-${box.key}`} data-group={box.key} style={{ pointerEvents: 'none' }}>
971
- <rect
972
- x={box.x}
973
- y={box.y}
974
- width={box.w}
975
- height={box.h}
976
- rx="14"
977
- fill={isLight ? '#0f172a' : '#a5b4fc'}
978
- fillOpacity={isLight ? 0.025 : 0.045}
979
- stroke={pal.ringStroke}
980
- strokeWidth="1.5"
981
- strokeDasharray="6 6"
982
- />
983
- <text
984
- x={box.x + 12}
985
- y={box.y + 14}
986
- fill={pal.legendText}
987
- fontSize="13"
988
- fontFamily="monospace"
989
- fontWeight="700"
1724
+ {groupBoxes.map(box => {
1725
+ const isHovered = activeGroup === box.key;
1726
+ // R68: distinguish "locked by click" from "currently hovered".
1727
+ // R63 made pinned and hovered identical (both hit isHovered
1728
+ // via activeGroup). A user with one team pinned should see at
1729
+ // a glance which is the locked one even while their cursor
1730
+ // sweeps elsewhere. isPinned reads pinnedGroup directly
1731
+ // (NOT activeGroup) so the visual is specific to the sticky
1732
+ // state transient hover keeps the R63 isHovered styling.
1733
+ const isPinned = pinnedGroup === box.key;
1734
+ // Round 18 / Loop: group-box hover linkage. The Round 8 fade
1735
+ // already dropped OUT-of-focus groups to 0.28, but the IN-focus
1736
+ // group sat at its baseline appearance — no positive emphasis.
1737
+ // Hovering now upgrades the box to an "accent" treatment:
1738
+ // solid stroke (not dashed), thicker, accent-coloured; brighter
1739
+ // text and slightly stronger fill. Label and box read as one
1740
+ // selected unit. Geometry unchanged → overlap test untouched.
1741
+ return (
1742
+ <g
1743
+ key={`grp-${box.key}`}
1744
+ data-group={box.key}
1745
+ className="transition-opacity"
1746
+ // R63: drop the blanket pointerEvents:'none' that
1747
+ // previously sat here. Chrome's SVG impl doesn't let a
1748
+ // child override a parent's `none` even though the spec
1749
+ // says it should — moving the property onto just the
1750
+ // rect (where it's needed so nodes underneath stay
1751
+ // clickable) lets the label text receive its own click.
1752
+ style={{ opacity: !activeGroup || isHovered ? 1 : 0.28 }}
990
1753
  >
991
- {box.key}
992
- </text>
993
- </g>
994
- ))}
1754
+ <rect
1755
+ x={box.x}
1756
+ y={box.y}
1757
+ width={box.w}
1758
+ height={box.h}
1759
+ rx="14"
1760
+ fill={isLight ? '#0f172a' : '#a5b4fc'}
1761
+ // R68: 3-tier opacity + stroke ladder.
1762
+ // pinned → fill 0.08 / 0.13, stroke 3 px (locked)
1763
+ // hovered → fill 0.05 / 0.09, stroke 2 px (inspecting)
1764
+ // idle → fill 0.025 / 0.045, stroke 1.5 px dashed
1765
+ fillOpacity={isPinned ? (isLight ? 0.08 : 0.13)
1766
+ : isHovered ? (isLight ? 0.05 : 0.09)
1767
+ : (isLight ? 0.025 : 0.045)}
1768
+ stroke={(isPinned || isHovered) ? pal.legendAccent : pal.ringStroke}
1769
+ strokeWidth={isPinned ? 3 : isHovered ? 2 : 1.5}
1770
+ strokeDasharray={(isPinned || isHovered) ? 'none' : '6 6'}
1771
+ data-group-box-pinned={isPinned ? 'true' : 'false'}
1772
+ style={{ transition: 'stroke 200ms ease-out, stroke-width 200ms ease-out, fill-opacity 200ms ease-out', pointerEvents: 'none' }}
1773
+ />
1774
+ {/* R63: wrap label in a clickable <g> with an invisible
1775
+ rect hitbox. The text alone wasn't getting hit-tested
1776
+ reliably — the SVG-wide topo-panel <rect> intercepts
1777
+ at the label's screen position when the label sits at
1778
+ a high viewBox-y (it lands below where the compositor
1779
+ expects the text to paint on top, same gotcha as the
1780
+ recent-signal panel rows in R56). Hitbox rect + the
1781
+ parent <g> taking the click + onPointerDown stop-
1782
+ propagation match the R55/R56/R61 pattern. */}
1783
+ <g
1784
+ role="button"
1785
+ tabIndex={0}
1786
+ aria-pressed={pinnedGroup === box.key}
1787
+ data-group-label-hit={box.key}
1788
+ style={{ pointerEvents: 'all', cursor: 'pointer' }}
1789
+ onPointerDown={(e) => e.stopPropagation()}
1790
+ onClick={() => setPinnedGroup(prev => prev === box.key ? null : box.key)}
1791
+ >
1792
+ <rect
1793
+ x={box.x + 6}
1794
+ y={box.y + 2}
1795
+ width={Math.min(box.w - 12, 240)}
1796
+ height={20}
1797
+ fill="transparent"
1798
+ />
1799
+ <text
1800
+ x={box.x + 12}
1801
+ y={box.y + 14}
1802
+ fill={isHovered ? pal.legendHeadline : pal.legendText}
1803
+ fontSize="13"
1804
+ fontFamily="monospace"
1805
+ fontWeight="700"
1806
+ style={{ transition: 'fill 200ms ease-out' }}
1807
+ data-group-label={box.key}
1808
+ >
1809
+ {box.key}
1810
+ {/* Round 19 / Loop: member-count chip. Inline tspan stays
1811
+ in the single <text> bbox the overlap test reads, so
1812
+ the node↔label guard still catches if the chip ever
1813
+ pushes the label far enough right to clip a node.
1814
+ Smaller + lighter weight reads as metadata, not name. */}
1815
+ <tspan dx="6" fill={pal.legendText} fontSize="11" fontWeight="400">· {box.count}</tspan>
1816
+ {/* Round 58 / Loop: status mix pip strip. Compact text-
1817
+ based chips (e.g. "2w 1i") so the strip stays inside
1818
+ the same <text> bbox the overlap-test reads — keeps
1819
+ the R27 label↔label and R19 node↔label guards intact.
1820
+ Each tier is colour-coded against the legend swatches
1821
+ and only renders when count > 0, so a healthy all-
1822
+ working group reads simply " · 2w". */}
1823
+ {box.statuses.working > 0 && (
1824
+ <tspan dx="8" fill={isLight ? '#059669' : '#22c55e'} fontSize="11" fontWeight="600">{box.statuses.working}w</tspan>
1825
+ )}
1826
+ {box.statuses.idle > 0 && (
1827
+ <tspan dx="4" fill={isLight ? '#0d9488' : '#2dd4bf'} fontSize="11" fontWeight="600">{box.statuses.idle}i</tspan>
1828
+ )}
1829
+ {box.statuses.offline > 0 && (
1830
+ <tspan dx="4" fill={isLight ? '#94a3b8' : '#6b7280'} fontSize="11" fontWeight="600">{box.statuses.offline}o</tspan>
1831
+ )}
1832
+ </text>
1833
+ </g>
1834
+ </g>
1835
+ );
1836
+ })}
995
1837
 
996
1838
  {/* directed message flows */}
997
1839
  {flowLinks.map((link, index) => {
@@ -999,21 +1841,120 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
999
1841
  const to = nodePositions[link.to];
1000
1842
  if (!from || !to) return null;
1001
1843
 
1002
- const lift = index % 2 === 0 ? 36 : -36;
1844
+ // Round 7 / Loop: lift now scales with distance so short links
1845
+ // aren't over-bent (long links keep the ~36px hump), and the
1846
+ // particle period shortens with link.count so busier edges
1847
+ // visibly pulse faster — instant info density on top of the
1848
+ // existing stroke-width-by-count chip.
1849
+ const dist = Math.hypot(to.x - from.x, to.y - from.y);
1850
+ const lift = (index % 2 === 0 ? 1 : -1) * Math.min(36, dist * 0.18);
1003
1851
  const path = curvePath(from, to, lift);
1004
1852
  const width = Math.min(2 + link.count, 7);
1005
- const duration = 2.2 + (index % 5) * 0.32;
1006
-
1853
+ const duration = Math.max(0.9, 2.6 / Math.sqrt(link.count));
1854
+ // Round 10 / Loop: freshness fade. An edge that fired ≤30s ago
1855
+ // stays at full intensity; over 5 minutes it decays to ~35%.
1856
+ // Surfaces "what's happening now" vs background chatter without
1857
+ // hiding old flow entirely (some context still useful). `now`
1858
+ // captured at useMemo-recompute time (every 5s message refresh)
1859
+ // — accuracy is within the poll interval, plenty.
1860
+ const ageMs = link.last_at ? Math.max(0, Date.now() - Date.parse(link.last_at)) : 0;
1861
+ const fresh = Math.max(0.35, 1 - ageMs / (5 * 60 * 1000));
1862
+ // Round 16 arrow-tier binning — keep `topo-arrow` as the
1863
+ // medium tier id so the legend swatch picks it up unchanged.
1864
+ const arrowId = link.count <= 2 ? 'topo-arrow-s'
1865
+ : link.count <= 4 ? 'topo-arrow'
1866
+ : 'topo-arrow-l';
1867
+
1868
+ // Round 39 / Loop: edge hover tooltip — surface the same
1869
+ // last_at + count info the freshness fade and arrow tier
1870
+ // already encode visually, in plain text. The stroke is the
1871
+ // hover target; SVG `<title>` honours newlines on every
1872
+ // browser the dashboard targets.
1873
+ const lastAt = relativeAgo(link.last_at);
1874
+ const tooltip = `${link.from} → ${link.to}\n${link.count} message${link.count === 1 ? '' : 's'}${lastAt ? ` · last ${lastAt}` : ''}`;
1875
+ // Round 40 / Loop: edges follow node hover — when an alias is
1876
+ // hovered, every edge touching it brightens, the rest fade.
1877
+ // Pairs with the Round 8 group-focus fade on nodes: hover a
1878
+ // node to find "who is this agent talking to" at a glance.
1879
+ // No hover → multiplier is 1.0 (current behaviour preserved).
1880
+ // Round 50 / Loop: edge-on-self priority. When the user hovers
1881
+ // a flow edge directly (R48 widened the hitbox so this is now
1882
+ // a precise gesture), THAT edge gets the strongest boost (2.0)
1883
+ // and every other edge dims to 0.35. Node-hover (R40) keeps
1884
+ // its own ladder. Edge-hover and node-hover are mutually
1885
+ // exclusive in practice — the cursor is over one or the
1886
+ // other — but the order below makes edge-hover win if both
1887
+ // ever read truthy at the same React tick.
1888
+ // Round 53 / Loop: in-group edges follow team focus. R40
1889
+ // brightened only edges touching the exact hovered alias —
1890
+ // but with R8/R18 prefix-clustering, a user hovering one
1891
+ // member is asking about the team. So when BOTH endpoints of
1892
+ // an edge share the hoveredGroup (and neither is the exact
1893
+ // hovered alias — that gets the stronger 1.7×), the edge
1894
+ // boosts to 1.3×. Edges leaving the team (one endpoint in,
1895
+ // one out) still dim to 0.35× per R40 since they read as
1896
+ // background to the focus. Singletons fall through unchanged
1897
+ // (their group key is the alias itself, so bothInHoveredGroup
1898
+ // is impossible for a non-self-edge).
1899
+ const isHoveredEdge = hoveredEdgeKey === link.key;
1900
+ const fromGroup = groupKeys[link.from] ?? link.from;
1901
+ const toGroup = groupKeys[link.to] ?? link.to;
1902
+ const bothInHoveredGroup = !!activeGroup && fromGroup === activeGroup && toGroup === activeGroup;
1903
+ // R63: also gate "no filter" on activeGroup so pinnedGroup
1904
+ // alone activates the in-group 1.3× boost + non-group dim.
1905
+ // When neither a node nor a group is in focus, mul is 1.
1906
+ // R77: when the user hovers the "N active links" chip, the
1907
+ // baseline 1× becomes 1.5× — every flow brightens at once.
1908
+ // Sits inside the no-other-hover branch so it doesn't fight
1909
+ // the edge-hover (2.0×) or node-hover (1.7×) priorities.
1910
+ const edgeOpacityMul = isHoveredEdge
1911
+ ? 2.0
1912
+ : hoveredEdgeKey
1913
+ ? 0.35
1914
+ : !hoveredAlias && !activeGroup
1915
+ ? (hoveredActiveLinks ? 1.5 : 1)
1916
+ : (link.from === hoveredAlias || link.to === hoveredAlias)
1917
+ ? 1.7
1918
+ : bothInHoveredGroup
1919
+ ? 1.3
1920
+ : 0.35;
1921
+ // Round 50: the hovered edge also visibly thickens so the eye
1922
+ // tracks it even at low message counts (width was 3 → 4.5 on
1923
+ // hover). 1.4× is enough to read as "lifted" without
1924
+ // breaching the 16-px hitbox bound.
1925
+ const renderWidth = isHoveredEdge ? Math.min(width * 1.4, 10) : width;
1007
1926
  return (
1008
1927
  <g key={link.key}>
1928
+ {/* Round 48 / Loop: invisible hover hitbox — visible flow
1929
+ path is 3-7 px wide and damn hard to hover precisely.
1930
+ Stack a transparent 16-px-wide stroke behind it so the
1931
+ cursor only needs to be ~8 px from the line for the
1932
+ tooltip to fire. Native <title> moves here; the
1933
+ visible path no longer needs it. pointer-events on
1934
+ the visible path drop to "none" since the hitbox
1935
+ owns the hover surface. */}
1936
+ <path
1937
+ d={path}
1938
+ fill="none"
1939
+ stroke="transparent"
1940
+ strokeWidth={Math.max(width + 10, 16)}
1941
+ style={{ pointerEvents: 'stroke' }}
1942
+ data-edge-hitbox
1943
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
1944
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
1945
+ >
1946
+ <title>{tooltip}</title>
1947
+ </path>
1009
1948
  <path
1010
1949
  d={path}
1011
1950
  fill="none"
1012
1951
  stroke={pal.flowEdge}
1013
- strokeWidth={width}
1014
- opacity={isLight ? 0.22 : 0.28}
1952
+ strokeWidth={renderWidth}
1953
+ opacity={Math.min(1, (isLight ? 0.22 : 0.28) * fresh * edgeOpacityMul)}
1015
1954
  filter={isLight ? undefined : 'url(#topo-glow)'}
1016
- markerEnd="url(#topo-arrow)"
1955
+ markerEnd={`url(#${arrowId})`}
1956
+ className="transition-opacity duration-300"
1957
+ style={{ pointerEvents: 'none' }}
1017
1958
  />
1018
1959
  <path
1019
1960
  id={`flow-path-${index}`}
@@ -1022,45 +1963,162 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1022
1963
  stroke={pal.flowPath}
1023
1964
  strokeWidth="1"
1024
1965
  strokeDasharray="2 12"
1025
- opacity={isLight ? 0.4 : 0.75}
1966
+ opacity={Math.min(1, (isLight ? 0.4 : 0.75) * fresh * edgeOpacityMul)}
1967
+ className="transition-opacity duration-300"
1026
1968
  />
1027
- <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'}>
1028
- <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
1029
- </circle>
1969
+ {!reducedMotion && (
1970
+ <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'} opacity={Math.min(1, fresh * edgeOpacityMul)}>
1971
+ <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
1972
+ </circle>
1973
+ )}
1974
+ {/* Round 75 / Loop: arrival ping at the destination. The
1975
+ particle currently fades into the arrow marker
1976
+ silently — adding a small radiating ring synchronised
1977
+ to the particle's period turns message delivery into
1978
+ a visible event. begin = -dur*0.92 offsets the
1979
+ animation so the ring expands NEAR the end of each
1980
+ cycle (≈when the particle arrives). Gated by
1981
+ reducedMotion and on fresh > 0.5 — stale edges that
1982
+ haven't fired in minutes don't need the eye-grab.
1983
+ data-arrival-ping for testability. */}
1984
+ {!reducedMotion && fresh > 0.5 && (
1985
+ <circle
1986
+ cx={to.x}
1987
+ cy={to.y}
1988
+ r="0"
1989
+ fill="none"
1990
+ stroke={pal.flowEdge}
1991
+ strokeWidth="1.5"
1992
+ opacity="0"
1993
+ style={{ pointerEvents: 'none' }}
1994
+ data-arrival-ping={link.key}
1995
+ >
1996
+ <animate attributeName="r" values="0;14;22" dur={`${duration}s`} begin={`-${(duration * 0.92).toFixed(2)}s`} repeatCount="indefinite" />
1997
+ <animate attributeName="opacity" values="0;0.55;0" dur={`${duration}s`} begin={`-${(duration * 0.92).toFixed(2)}s`} repeatCount="indefinite" />
1998
+ </circle>
1999
+ )}
2000
+ {/* Round 76 / Loop: source dispatch pulse — mirror to the
2001
+ R75 arrival ping. begin = 0s (start of cycle) so the
2002
+ ring expands as the particle LEAVES the source. Only
2003
+ fires for high-traffic edges (link.count >= 3) — on
2004
+ quiet conversations the canvas should stay calm; on
2005
+ busy senders the pulse plus arrival ping bookend
2006
+ every message in flight, making the topology feel
2007
+ alive. Same fresh/reducedMotion gates as R75. Slightly
2008
+ smaller radius (0→12→18 vs R75's 0→14→22) so the
2009
+ source reads as "smaller event than arrival" — the
2010
+ destination is the meaningful endpoint. */}
2011
+ {!reducedMotion && fresh > 0.5 && link.count >= 3 && (
2012
+ <circle
2013
+ cx={from.x}
2014
+ cy={from.y}
2015
+ r="0"
2016
+ fill="none"
2017
+ stroke={pal.flowEdge}
2018
+ strokeWidth="1.5"
2019
+ opacity="0"
2020
+ style={{ pointerEvents: 'none' }}
2021
+ data-dispatch-pulse={link.key}
2022
+ >
2023
+ <animate attributeName="r" values="0;12;18" dur={`${duration}s`} begin="0s" repeatCount="indefinite" />
2024
+ <animate attributeName="opacity" values="0;0.45;0" dur={`${duration}s`} begin="0s" repeatCount="indefinite" />
2025
+ </circle>
2026
+ )}
1030
2027
  </g>
1031
2028
  );
1032
2029
  })}
1033
2030
 
1034
- {/* center hub — round 39: enlarged from r=6 (12px) to r=10 (20px)
1035
- core so the "control plane" reads as the network's anchor,
1036
- not a stray particle. Static halo ring at r=18 grounds it
1037
- visually; two outward pulses keep the "signal source" idea
1038
- from r17. Light theme gets a paler core to avoid hot-spotting
1039
- the bg. */}
1040
- {layout === 'ring' && (<g>
1041
- {/* static grounding halo sits underneath, low opacity */}
1042
- <circle
1043
- cx={cx} cy={cy} r="18"
1044
- fill={isLight ? '#d1fae5' : '#10b981'}
1045
- opacity={isLight ? 0.35 : 0.10}
1046
- />
1047
- {/* outer pulse 1 */}
1048
- <circle cx={cx} cy={cy} r="10" fill="none" stroke="#10b981" strokeWidth="1.5">
1049
- <animate attributeName="r" values="10;38;10" dur="2.4s" repeatCount="indefinite" />
1050
- <animate attributeName="opacity" values="0.55;0;0.55" dur="2.4s" repeatCount="indefinite" />
1051
- </circle>
1052
- {/* outer pulse 2 (delayed 1.2s) */}
1053
- <circle cx={cx} cy={cy} r="10" fill="none" stroke="#10b981" strokeWidth="1.5" opacity="0">
1054
- <animate attributeName="r" values="10;38;10" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
1055
- <animate attributeName="opacity" values="0.45;0;0.45" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
1056
- </circle>
2031
+ {/* center hub — round 39 sized + round 13 restraint.
2032
+ The hub is the control-plane anchor. r39 gave it two outward
2033
+ pulses (r 10→38, 2.4s, double-phase) which read as "loudest
2034
+ node in the room" wrong semantics: anchors don't emit,
2035
+ they hold. r13 swaps the kinetic pulse for a slow opacity
2036
+ breath on the static halo (4s, ±15% from base), so the hub
2037
+ stays alive without throwing kinetic energy outward. The
2038
+ dual-circle "lit lamp" core is unchanged. */}
2039
+ {layout === 'ring' && (<g
2040
+ data-topo-hub
2041
+ style={{ cursor: 'pointer' }}
2042
+ // Stop pointerdown from reaching the SVG pan handler — same
2043
+ // reason as the node <g>: a captured pointer makes Chromium
2044
+ // fire the follow-up click on the SVG, not this group.
2045
+ onPointerDown={(e) => e.stopPropagation()}
2046
+ // Round 52 / Loop: hub is the visual anchor but had no click
2047
+ // action users discovered fit-to-content only via the `f`
2048
+ // key or the chrome button. Wire the hub to fitView() so the
2049
+ // most prominent element in the canvas is also the "re-
2050
+ // center" affordance. A click-ripple keyed by ts confirms
2051
+ // the gesture; the hub <title> now ends with a hint.
2052
+ onClick={() => {
2053
+ fitView();
2054
+ setClickRipple({ ts: Date.now(), x: cx, y: cy, r0: 18, color: isLight ? '#059669' : '#10b981' });
2055
+ setTimeout(() => setClickRipple(prev => prev && prev.x === cx && prev.y === cy ? null : prev), 600);
2056
+ }}
2057
+ >
2058
+ {/* Round 43 / Loop: hub `<title>` summary — hovering the
2059
+ central glow now answers "what is this?" with a one-line
2060
+ fleet snapshot. Duplicates the header chips by design;
2061
+ hovering the most visually prominent element is the
2062
+ most natural impulse, so satisfy it where the cursor
2063
+ already is. Falls clean: omits sub-clauses when their
2064
+ counts are zero. R52 appends a "click to fit view" hint.
2065
+ */}
2066
+ <title>{(() => {
2067
+ const total = sessions.length;
2068
+ const parts = [`Network hub`, `${total} session${total === 1 ? '' : 's'}`];
2069
+ if (onlineNodes.length > 0) parts.push(`${onlineNodes.length} online`);
2070
+ if (workingCount > 0) parts.push(`${workingCount} working`);
2071
+ if (flowLinks.length > 0) parts.push(`${flowLinks.length} active link${flowLinks.length === 1 ? '' : 's'}`);
2072
+ return parts.join(' · ') + '\nclick to fit view';
2073
+ })()}</title>
2074
+ {/* grounding halo — breathes in opacity, no expansion.
2075
+ R84: breath amplitude + tempo reflect workingCount. An
2076
+ idle fleet keeps the original gentle 0.32→0.52 (light) /
2077
+ 0.08→0.16 (dark) cycle over 4s — "heart at rest". As
2078
+ working sessions accumulate, the peak climbs and the
2079
+ period shortens, capped at 2.4s so it never feels
2080
+ manic. Quiet fleets see zero change; busy fleets feel
2081
+ the canvas working. */}
2082
+ {(() => {
2083
+ // Bucket workingCount to keep the visual feedback discrete
2084
+ // rather than continuous: 0 / 1-2 / 3-5 / 6+. Three buckets
2085
+ // are enough — finer gradations are imperceptible.
2086
+ const busy = workingCount === 0 ? 0
2087
+ : workingCount <= 2 ? 1
2088
+ : workingCount <= 5 ? 2
2089
+ : 3;
2090
+ const peakLight = [0.52, 0.58, 0.65, 0.72][busy];
2091
+ const peakDark = [0.16, 0.20, 0.26, 0.32][busy];
2092
+ const troughLight = 0.32;
2093
+ const troughDark = 0.08;
2094
+ const dur = [4.0, 3.2, 2.7, 2.4][busy];
2095
+ const valuesLight = `${troughLight};${peakLight};${troughLight}`;
2096
+ const valuesDark = `${troughDark};${peakDark};${troughDark}`;
2097
+ return (
2098
+ <circle
2099
+ cx={cx} cy={cy} r="18"
2100
+ fill={isLight ? '#d1fae5' : '#10b981'}
2101
+ opacity={isLight ? 0.42 : 0.12}
2102
+ data-hub-busyness={busy}
2103
+ >
2104
+ {!reducedMotion && (
2105
+ <animate
2106
+ attributeName="opacity"
2107
+ values={isLight ? valuesLight : valuesDark}
2108
+ dur={`${dur}s`}
2109
+ repeatCount="indefinite"
2110
+ />
2111
+ )}
2112
+ </circle>
2113
+ );
2114
+ })()}
1057
2115
  {/* core — 20px diameter, larger inner highlight reads as a "lit lamp" */}
1058
2116
  <circle cx={cx} cy={cy} r="10" fill={isLight ? '#059669' : '#10b981'} />
1059
2117
  <circle cx={cx} cy={cy} r="5" fill="#d1fae5" opacity="0.9" />
1060
2118
  </g>)}
1061
2119
 
1062
2120
  {/* agent nodes */}
1063
- {[...onlineNodes, ...offlineNodes].map(session => {
2121
+ {[...onlineNodes, ...offlineNodes].map((session, nodeIdx) => {
1064
2122
  const pos = nodePositions[session.alias];
1065
2123
  if (!pos) return null;
1066
2124
 
@@ -1080,13 +2138,118 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1080
2138
  // occlude an avatar), and the full card appears only for the
1081
2139
  // hovered node or once zoomed in past 1.4×.
1082
2140
  const showFullLabel = !denseLayout || hoveredAlias === session.alias || view.zoom >= 1.4;
2141
+ // Round 8: when a node in another group is hovered, fade this
2142
+ // one. Same-group nodes (incl. singletons matching the hovered
2143
+ // alias) stay full. Pure visual focus, geometry unchanged.
2144
+ const inFocus = !activeGroup || (groupKeys[session.alias] ?? session.alias) === activeGroup;
2145
+ // R72: in ring layout, classify the node into a tier by its
2146
+ // distance from hub centre so the first-paint stagger can
2147
+ // emanate inner → outer instead of running clockwise. Grid
2148
+ // layout doesn't have a radial structure; it keeps R9's pure
2149
+ // nodeIdx stagger. Thresholds bracket the actual tier radii
2150
+ // (single 220, dual 175/260, triple 145/215/285, offline 325+).
2151
+ let tierIdx = 0;
2152
+ if (layout === 'ring') {
2153
+ const p = nodePositions[session.alias];
2154
+ if (p) {
2155
+ const d = Math.hypot(p.x - cx, p.y - cy);
2156
+ tierIdx = d < 195 ? 0 : d < 270 ? 1 : d < 310 ? 2 : 3;
2157
+ }
2158
+ }
1083
2159
 
1084
2160
  return (
1085
2161
  <g
1086
2162
  key={session.alias}
1087
2163
  data-node={session.alias}
1088
- className="transition-opacity"
1089
- style={{ cursor: 'pointer' }}
2164
+ data-tier-idx={layout === 'ring' ? tierIdx : -1}
2165
+ // Round 3 / Loop: `anet-fade-in` runs once when the <g>
2166
+ // mounts — a new session entering the fleet (or the topology
2167
+ // first rendering) eases in instead of popping. Re-renders of
2168
+ // an existing node don't re-trigger (React preserves the <g>
2169
+ // via the alias key), so status changes don't flicker. The
2170
+ // global prefers-reduced-motion sweep already neutralises it.
2171
+ className="group transition-opacity anet-fade-in"
2172
+ style={{
2173
+ cursor: 'pointer',
2174
+ // Round 17 / Loop: offline nodes drop to 0.6 at rest so
2175
+ // online nodes pop without losing the offline-as-ghost
2176
+ // information. The dashed stroke + smaller radius already
2177
+ // say "offline"; dimming the whole group strengthens the
2178
+ // online-vs-offline hierarchy at first glance. Exempt the
2179
+ // chat-focused node — if the user explicitly opened a
2180
+ // popover targeting an offline alias, that node stays
2181
+ // full-brightness so the focus ring + popover read as one
2182
+ // selected thing rather than a dimmed selection. Group-
2183
+ // hover fade (Round 8) still wins when inFocus is false.
2184
+ // Round 49 / Loop: edge-hover endpoint highlight composes
2185
+ // OVER the inFocus/online formula — a non-endpoint node
2186
+ // dims to 0.28 (just below the inFocus 0.32 to read as a
2187
+ // stronger "not relevant" signal), endpoints keep their
2188
+ // base opacity. chatAlias still wins to keep the focus
2189
+ // ring legible if the user clicked through.
2190
+ // Round 55 / Loop: legend-status hover composes on the
2191
+ // SAME level as edge-hover-endpoint. Non-matching nodes
2192
+ // dim to 0.28; matching nodes stay at their base.
2193
+ // activeStatus matches: working = status==='working',
2194
+ // idle = online but not working, offline = !isOnline.
2195
+ // Round 60 / Loop: activeStatus = hoveredStatus ?? pinnedStatus
2196
+ // so the pressure-bar segment pins (R60) and the legend
2197
+ // row hover (R55) feed the same branch.
2198
+ // Round 80 / Loop: vendor-letter hover composes ABOVE the
2199
+ // activeStatus branch so hovering the vendor chip's `C`
2200
+ // dims everything except C-vendor nodes. Same dim value
2201
+ // (0.28) as edge-endpoint and status filters — visually
2202
+ // consistent. Vendor lookup uses the same vendorForModel
2203
+ // helper the avatar render uses, keyed by initial so the
2204
+ // chip and the avatar always agree on grouping.
2205
+ opacity: hoveredEdgeEndpoints && !hoveredEdgeEndpoints.has(session.alias) && chatAlias !== session.alias
2206
+ ? 0.28
2207
+ : hoveredVendor && chatAlias !== session.alias && (() => {
2208
+ const v = vendorForModel(session.model);
2209
+ const initial = v.id === 'unknown' ? '?' : v.initial;
2210
+ return initial !== hoveredVendor;
2211
+ })()
2212
+ ? 0.28
2213
+ : activeStatus && chatAlias !== session.alias && !(
2214
+ activeStatus === 'working' ? session.status === 'working'
2215
+ : activeStatus === 'idle' ? (isOnline && session.status !== 'working')
2216
+ : /* offline */ !isOnline
2217
+ )
2218
+ ? 0.28
2219
+ : !inFocus
2220
+ ? 0.32
2221
+ : chatAlias === session.alias
2222
+ ? 1
2223
+ : isOnline ? 1 : 0.6,
2224
+ // Round 9 / Loop: stagger the anet-fade-in so the topology
2225
+ // reveals as a wave on first paint instead of one big pop.
2226
+ // Cap at 24 indices (≈600ms tail) so 50-node fleets still
2227
+ // finish revealing within a beat. CSS animation-delay only
2228
+ // applies during the keyframe — re-renders without a new
2229
+ // mount (same alias key) don't replay, so status changes
2230
+ // never trigger the stagger again.
2231
+ // Round 72 / Loop: in ring layout, stagger by tier radius
2232
+ // so the topology emanates from the hub outward — inner
2233
+ // tier at 0ms, middle at ~180ms, outer at ~360ms, offline
2234
+ // at ~540ms. A small within-tier offset (nodeIdx % 6 * 25)
2235
+ // adds variety so each ring rotates in instead of all-at-
2236
+ // once popping. Grid keeps R9's pure nodeIdx stagger.
2237
+ animationDelay: layout === 'ring'
2238
+ ? `${tierIdx * 180 + (nodeIdx % 6) * 25}ms`
2239
+ : `${Math.min(nodeIdx, 24) * 25}ms`,
2240
+ // Round 51 / Loop: hover micro-lift. The label already
2241
+ // lifts on group-hover (R26). The node body — circle,
2242
+ // avatar, status ring — stays planted, so the gesture
2243
+ // reads "label moves, body doesn't". Now the whole <g>
2244
+ // translates -2px when hovered: avatar + ring + label
2245
+ // move as one unit. 2 px is well inside the inter-row
2246
+ // gap (cellH headroom ≥22), and CSS transform on SVG
2247
+ // <g> never affects the overlap-test geometry (the
2248
+ // test never simulates hover). useReducedMotion drops
2249
+ // the lift to 0 for prefers-reduced-motion users.
2250
+ transform: !reducedMotion && hoveredAlias === session.alias ? 'translateY(-2px)' : undefined,
2251
+ transition: 'transform 180ms cubic-bezier(0.4,0,0.2,1)',
2252
+ }}
1090
2253
  // Stop the pointerdown from reaching the SVG pan handler: the
1091
2254
  // SVG calls setPointerCapture, and a captured pointer makes
1092
2255
  // Chromium fire the follow-up `click` on the SVG instead of
@@ -1094,21 +2257,106 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1094
2257
  // Side effect (intended): you pan from empty canvas, not by
1095
2258
  // grabbing a node.
1096
2259
  onPointerDown={(e) => e.stopPropagation()}
1097
- onPointerEnter={() => denseLayout && setHoveredAlias(session.alias)}
1098
- onPointerLeave={() => denseLayout && setHoveredAlias(prev => (prev === session.alias ? null : prev))}
1099
- onClick={() => setChatAlias(session.alias)}
2260
+ // Track hover globally (not just under denseLayout) so the
2261
+ // Round 8 group-focus fade works at any fleet size.
2262
+ onPointerEnter={() => setHoveredAlias(session.alias)}
2263
+ onPointerLeave={() => setHoveredAlias(prev => (prev === session.alias ? null : prev))}
2264
+ onClick={() => {
2265
+ setChatAlias(session.alias);
2266
+ // Round 14 ripple — capture position, radius and status
2267
+ // colour at click time so the one-shot circle is self-
2268
+ // contained (no re-render-time recomputation needed).
2269
+ setClickRipple({
2270
+ ts: Date.now(),
2271
+ x: pos.x, y: pos.y, r0: radius,
2272
+ color: status.primary,
2273
+ });
2274
+ setTimeout(() => setClickRipple(prev =>
2275
+ prev && Date.now() - prev.ts >= 590 ? null : prev), 600);
2276
+ }}
1100
2277
  >
1101
2278
  {/* Issue #96: native hover tooltip — "Vendor · model · Runtime".
1102
2279
  Falls back to just the alias when the node reports no
1103
- model/runtime. */}
1104
- <title>{identityLine(session.model, session.runtime) || session.alias}</title>
1105
- {isActive && (
2280
+ model/runtime.
2281
+ Round 33 / Loop: cwd line answers "what is this agent on?".
2282
+ Round 34 / Loop: for offline nodes, append "last seen: 6m ago"
2283
+ so the operator knows whether to wait or chase. Online nodes
2284
+ skip the line (Round 27's 1 h ghost age-out means anything
2285
+ still online has heartbeated recently — the line would just
2286
+ be noise). Accepts both ISO ("…T06:00:28Z") and SQL-style
2287
+ ("… 06:00:28" assumed UTC) formats. */}
2288
+ {(() => {
2289
+ // Round 35 / Loop: TZ-safe parsing via the shared lib helper
2290
+ // — same parseHubTime is used by isGhost above (Round 38
2291
+ // factored it to app/lib/time.ts so both paths interpret SQL
2292
+ // bare timestamps as UTC on every browser).
2293
+ const lastSeen = !isOnline && session.last_seen_at ? relativeAgo(session.last_seen_at) : null;
2294
+ return (
2295
+ <title>{[
2296
+ identityLine(session.model, session.runtime) || session.alias,
2297
+ session.project_dir ? `cwd: ${session.project_dir}` : null,
2298
+ lastSeen ? `last seen: ${lastSeen}` : null,
2299
+ ].filter(Boolean).join('\n')}</title>
2300
+ );
2301
+ })()}
2302
+ {/* Round 2 / Loop: hover ring — a thin outer stroke that fades
2303
+ in when the cursor enters the node, signalling clickability
2304
+ (real-user feedback for the chat-popover open). Pure CSS via
2305
+ Tailwind group-hover, so it costs nothing per frame and
2306
+ respects prefers-reduced-motion via the global media query. */}
2307
+ <circle
2308
+ cx={pos.x}
2309
+ cy={pos.y}
2310
+ r={radius + 12}
2311
+ fill="none"
2312
+ stroke={status.primary}
2313
+ // strokeWidth must NOT be 1.5 (offline status ring) or 3
2314
+ // (online status ring) — the overlap test selects by those
2315
+ // exact widths and would mis-count this invisible hover
2316
+ // ring as a node footprint.
2317
+ strokeWidth="2"
2318
+ className="opacity-0 group-hover:opacity-70 transition-opacity duration-150"
2319
+ style={{ pointerEvents: 'none' }}
2320
+ />
2321
+ {/* Round 11 / Loop: chat-focus ring — when the ChatPopover is
2322
+ open targeting this node, anchor a persistent ring around
2323
+ it so the floating popover visibly links back to its source
2324
+ node. Static (not pulsing) so it reads as "selected state"
2325
+ rather than "this node is active". strokeWidth=2.5 stays
2326
+ clear of the overlap-test selectors (1.5 / 3). Sits just
2327
+ outside the halo radius+8 so it never overlaps a neighbour
2328
+ (halos already pack flush in dense grids). */}
2329
+ {chatAlias === session.alias && (
2330
+ <circle
2331
+ cx={pos.x}
2332
+ cy={pos.y}
2333
+ r={radius + 14}
2334
+ fill="none"
2335
+ stroke={status.primary}
2336
+ strokeWidth="2.5"
2337
+ opacity={isLight ? 0.85 : 0.95}
2338
+ filter={!isLight ? 'url(#topo-glow)' : undefined}
2339
+ className="transition-opacity duration-200"
2340
+ style={{ pointerEvents: 'none' }}
2341
+ />
2342
+ )}
2343
+ {isActive && !reducedMotion && (
1106
2344
  <circle cx={pos.x} cy={pos.y} r={radius + 14} fill={status.primary} opacity={isLight ? 0.08 : 0.12}>
1107
2345
  <animate attributeName="r" values={`${radius + 8};${radius + 22};${radius + 8}`} dur="2.4s" repeatCount="indefinite" />
1108
2346
  <animate attributeName="opacity" values={isLight ? '0.12;0.02;0.12' : '0.18;0.04;0.18'} dur="2.4s" repeatCount="indefinite" />
1109
2347
  </circle>
1110
2348
  )}
1111
- <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)} />
2349
+ {/* Round 4 / Loop: transition-[fill,stroke,opacity] smooths
2350
+ status colour changes so idle↔working↔offline doesn't snap
2351
+ — task replies / node-rename / SSE updates ease in. */}
2352
+ <circle
2353
+ cx={pos.x}
2354
+ cy={pos.y}
2355
+ r={radius + 8}
2356
+ fill={status.halo}
2357
+ opacity={isOnline ? (isLight ? 0.85 : 0.65) : (isLight ? 0.4 : 0.25)}
2358
+ className="transition-[fill,opacity] duration-300 ease-out"
2359
+ />
1112
2360
  <circle
1113
2361
  cx={pos.x}
1114
2362
  cy={pos.y}
@@ -1118,6 +2366,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1118
2366
  strokeWidth={isOnline ? 3 : 1.5}
1119
2367
  strokeDasharray={isOnline ? 'none' : '5 5'}
1120
2368
  filter={isOnline && !isLight ? 'url(#topo-glow)' : undefined}
2369
+ className="transition-[fill,stroke] duration-300 ease-out"
1121
2370
  />
1122
2371
  {/* Issue #96: node "avatar" is now driven by the model
1123
2372
  vendor. Decision order:
@@ -1204,52 +2453,115 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1204
2453
  </g>
1205
2454
  );
1206
2455
  })()}
1207
- {session.status === 'working' && (
1208
- // working pulse small dot just inside the status ring,
1209
- // above the avatar text. Was a 18px horizontal bar at
1210
- // y+11 which now collides with the avatar; the dot reads
1211
- // as the same "active" cue without occluding the initial.
1212
- <circle cx={pos.x} cy={pos.y - (radius - 6)} r="2.5" fill={pal.flowParticle}>
1213
- <animate attributeName="opacity" values="1;0.25;1" dur="1.1s" repeatCount="indefinite" />
1214
- </circle>
1215
- )}
2456
+ {session.status === 'working' && (() => {
2457
+ // Round 24 / Loop: pulse rate traffic. The dot's dur
2458
+ // was a flat 1.1s every working node looked equally
2459
+ // busy. Mapping it to sse:N lets the eye land on what's
2460
+ // actually hot. 3 discrete tiers read cleaner than
2461
+ // continuous easing at a glance; thresholds picked so
2462
+ // typical 1-2-SSE Claude/Codex sessions stay in the
2463
+ // "active" tier and only multi-pane orchestrators pop
2464
+ // into the "busy" tier. Geometry unchanged (r=2.5).
2465
+ const sse = sseCountFor ?? 0;
2466
+ const dur = sse >= 4 ? '0.7s' : sse >= 2 ? '0.9s' : '1.2s';
2467
+ return (
2468
+ <circle cx={pos.x} cy={pos.y - (radius - 6)} r="2.5" fill={pal.flowParticle} data-pulse-dur={dur} opacity={reducedMotion ? 0.6 : undefined}>
2469
+ {!reducedMotion && (
2470
+ <animate attributeName="opacity" values="1;0.25;1" dur={dur} repeatCount="indefinite" />
2471
+ )}
2472
+ </circle>
2473
+ );
2474
+ })()}
1216
2475
 
1217
2476
  {/* Round 98 (issue #61): label rect 124px → 100px.
1218
2477
  Round 109/110 (Vincent P0): full opaque card below the
1219
2478
  density threshold / on hover / when zoomed; otherwise a
1220
2479
  lightweight plain-text alias that keeps every node
1221
- labelled without an opaque box covering its neighbours. */}
1222
- {showFullLabel ? (
1223
- <g transform={`translate(${pos.x}, ${pos.y + radius + 22})`} style={{ pointerEvents: 'none' }}>
1224
- <rect x="-50" y="-14" width="100" height="42" rx="6" fill={pal.labelBox.fill} stroke={pal.labelBox.stroke} opacity={isLight ? 1 : 0.94} />
1225
- <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize="12" fontFamily="monospace" fontWeight="700">
1226
- {truncate(session.alias, 12)}
1227
- </text>
1228
- <text x="0" y="17" textAnchor="middle" fill={status.primary} fontSize="9" fontFamily="monospace">
1229
- {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
2480
+ labelled without an opaque box covering its neighbours.
2481
+ Round 15 / Loop: when nodeScale=S the node shrinks 30%
2482
+ but the label card was staying full-size, so the label
2483
+ visually outweighed the small node. Tighten the card
2484
+ frame, alias / sub fontSize, drop-offset and truncate
2485
+ length specifically for S; M and L keep their existing
2486
+ sizes (M ≈ L for labels by design — the S user is the
2487
+ one who explicitly asked for a denser view). */}
2488
+ {(() => {
2489
+ const isSmall = nodeScale < 0.8;
2490
+ const cardW = isSmall ? 88 : 100;
2491
+ const cardH = isSmall ? 36 : 42;
2492
+ const cardTopY = isSmall ? -12 : -14;
2493
+ const aliasFs = isSmall ? 11 : 12;
2494
+ const subFs = isSmall ? 8 : 9;
2495
+ const subY = isSmall ? 15 : 17;
2496
+ const dropY = isSmall ? 18 : 22;
2497
+ const fullMax = isSmall ? 11 : 12;
2498
+ const denseFs = isSmall ? 9 : 10;
2499
+ const denseDrop = isSmall ? 12 : 14;
2500
+ // Round 26 / Loop: micro-lift the label group on hover —
2501
+ // 1.5 px upward, 200 ms ease. Pairs with the Round 18
2502
+ // group-box hover-accent treatment to give the same
2503
+ // "this is the focused element" feedback at the per-
2504
+ // node level. CSS transform stacks onto the SVG
2505
+ // positioning transform attribute (SVG 2 cascade);
2506
+ // bbox is unchanged at rest, so the overlap-test gate
2507
+ // continues to see the geometric layout it expects.
2508
+ return showFullLabel ? (
2509
+ <g transform={`translate(${pos.x}, ${pos.y + radius + dropY})`} style={{ pointerEvents: 'none' }}
2510
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]">
2511
+ <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} />
2512
+ <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize={aliasFs} fontFamily="monospace" fontWeight="700">
2513
+ {truncate(session.alias, fullMax)}
2514
+ </text>
2515
+ <text x="0" y={subY} textAnchor="middle" fill={status.primary} fontSize={subFs} fontFamily="monospace">
2516
+ {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
2517
+ </text>
2518
+ </g>
2519
+ ) : (
2520
+ <text
2521
+ x={pos.x}
2522
+ y={pos.y + radius + denseDrop}
2523
+ textAnchor="middle"
2524
+ fill={status.text}
2525
+ fontSize={denseFs}
2526
+ fontFamily="monospace"
2527
+ fontWeight="700"
2528
+ opacity={0.9}
2529
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]"
2530
+ style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
2531
+ stroke={pal.containerBg}
2532
+ strokeWidth="3"
2533
+ >
2534
+ {truncate(session.alias, isSmall ? 9 : 10)}
1230
2535
  </text>
1231
- </g>
1232
- ) : (
1233
- <text
1234
- x={pos.x}
1235
- y={pos.y + radius + 14}
1236
- textAnchor="middle"
1237
- fill={status.text}
1238
- fontSize="10"
1239
- fontFamily="monospace"
1240
- fontWeight="700"
1241
- opacity={0.9}
1242
- style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
1243
- stroke={pal.containerBg}
1244
- strokeWidth="3"
1245
- >
1246
- {truncate(session.alias, 10)}
1247
- </text>
1248
- )}
2536
+ );
2537
+ })()}
1249
2538
  </g>
1250
2539
  );
1251
2540
  })}
1252
2541
 
2542
+ {/* Round 14 / Loop: click ripple — one-shot expanding ring from
2543
+ the clicked node, ~500ms, status-coloured. Sits inside the
2544
+ zoom/pan <g> so it scales / pans with the topology. Keyed by
2545
+ ts so a re-click on any node (same or different) remounts the
2546
+ <circle> and the SMIL <animate> elements replay from t=0.
2547
+ strokeWidth=2 doesn't match the overlap-test selectors. */}
2548
+ {clickRipple && !reducedMotion && (
2549
+ <circle
2550
+ key={clickRipple.ts}
2551
+ cx={clickRipple.x}
2552
+ cy={clickRipple.y}
2553
+ r={clickRipple.r0 + 4}
2554
+ fill="none"
2555
+ stroke={clickRipple.color}
2556
+ strokeWidth="2"
2557
+ opacity="0"
2558
+ style={{ pointerEvents: 'none' }}
2559
+ >
2560
+ <animate attributeName="r" values={`${clickRipple.r0 + 4};${clickRipple.r0 + 30}`} dur="0.5s" fill="freeze" />
2561
+ <animate attributeName="opacity" values="0.7;0" dur="0.5s" fill="freeze" />
2562
+ </circle>
2563
+ )}
2564
+
1253
2565
  </g>
1254
2566
 
1255
2567
  {/* #112: overlay panels (recent-signal + legend) render OUTSIDE the
@@ -1260,30 +2572,195 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1260
2572
  corner triangles, so the panels never overlap a node, in ring
1261
2573
  OR grid layout (Vincent 4727 zero-overlap criterion). */}
1262
2574
  {/* latest flow labels */}
2575
+ {/* Round 57 / Loop: drop-shadow on the panel rects gives them
2576
+ card-like elevation, especially on light theme where the
2577
+ near-white fill on a near-white canvas read as pasted-on.
2578
+ data-topo-panel-elevation tag so the test can verify both
2579
+ panels carry the filter. The filter is on the rect, not
2580
+ the parent <g>, so it doesn't shadow the rows + text inside
2581
+ — only the panel chrome lifts. */}
1263
2582
  <g transform="translate(16, 16)">
1264
- <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} />
2583
+ <rect
2584
+ x="0" y="0" width="230" height="84" rx="10"
2585
+ fill={pal.legendBox.fill}
2586
+ stroke={pal.legendBox.stroke}
2587
+ opacity={isLight ? 0.97 : 0.92}
2588
+ style={{ filter: isLight ? 'drop-shadow(0 2px 6px rgba(15,23,42,0.08))' : 'drop-shadow(0 2px 6px rgba(0,0,0,0.45))' }}
2589
+ data-topo-panel-elevation="recent"
2590
+ />
1265
2591
  <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700">recent signal</text>
1266
2592
  <text x="150" y="21" fill={pal.legendAccent} fontSize="10" fontFamily="monospace">{messages.length} msgs</text>
1267
- {flowLinks.slice(0, 3).map((link, index) => (
1268
- <text key={link.key} x="13" y={38 + index * 16} fill={pal.legendText} fontSize="9" fontFamily="monospace">
1269
- {truncate(link.from, 6)} {'->'} {truncate(link.to, 6)} / {link.count} / {truncate(link.content, 12)}
2593
+ {/* Round 45 / Loop: empty state. The panel used to render
2594
+ "recent signal" + "0 msgs" with three blank slots below
2595
+ when no flow yet read as "broken" rather than "quiet".
2596
+ A muted centred placeholder makes the empty state
2597
+ deliberate. Messages count CAN diverge from flowLinks
2598
+ count (raw count vs. deduped pairs), so the placeholder
2599
+ fires on flowLinks.length=0 specifically. */}
2600
+ {flowLinks.length === 0 ? (
2601
+ <text x="115" y="60" textAnchor="middle" fill={pal.legendText} fontSize="10" fontFamily="monospace" fontStyle="italic" opacity={0.65} data-recent-signal-empty>
2602
+ no flow yet
1270
2603
  </text>
1271
- ))}
2604
+ ) : (
2605
+ // Round 56 / Loop: each row is a navigator into the canvas.
2606
+ // Hover a row → set hoveredEdgeKey, which the existing R50
2607
+ // edge-focus + R49 endpoint-highlight ladders consume. The
2608
+ // matching flow edge brightens to 2× + thickens, its two
2609
+ // endpoint nodes stay full opacity, and every other edge +
2610
+ // non-endpoint node dims. Released → all restore. Wrapping
2611
+ // <g> + a transparent 218×14 hitbox so the cursor doesn't
2612
+ // have to land precisely on the truncated text.
2613
+ flowLinks.slice(0, 3).map((link, index) => (
2614
+ <g
2615
+ key={link.key}
2616
+ data-recent-row={link.key}
2617
+ style={{ cursor: 'pointer' }}
2618
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
2619
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
2620
+ >
2621
+ <rect x="6" y={38 + index * 16 - 10} width="218" height="14" fill="transparent" />
2622
+ <text
2623
+ x="13" y={38 + index * 16}
2624
+ fill={hoveredEdgeKey === link.key ? pal.legendHeadline : pal.legendText}
2625
+ fontSize="9"
2626
+ fontFamily="monospace"
2627
+ style={{ transition: 'fill 150ms ease-out' }}
2628
+ >
2629
+ {truncate(link.from, 6)} {'->'} {truncate(link.to, 6)} / {link.count} / {truncate(link.content, 12)}
2630
+ </text>
2631
+ </g>
2632
+ ))
2633
+ )}
1272
2634
  </g>
1273
2635
 
1274
- {/* legend */}
2636
+ {/* legend — Round 55 / Loop: each status row is now a hover
2637
+ target. Pointer enter sets `hoveredStatus`; pointer leave
2638
+ clears it. Node opacity formula composes the match below.
2639
+ The row text brightens to legendHeadline while hovered as
2640
+ a small affordance hint. Geometry unchanged — the new
2641
+ <g> wrappers only carry pointer handlers. */}
1275
2642
  <g transform="translate(760, 16)">
1276
- <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} />
1277
- <circle cx="16" cy="24" r="5.5" fill={isLight ? '#059669' : '#22c55e'} />
1278
- <text x="30" y="28" fill={pal.legendText} fontSize="11" fontFamily="monospace">working node</text>
1279
- <circle cx="16" cy="48" r="5.5" fill={isLight ? '#0d9488' : '#2dd4bf'} />
1280
- <text x="30" y="52" fill={pal.legendText} fontSize="11" fontFamily="monospace">online idle</text>
1281
- <circle cx="16" cy="72" r="5.5" fill={isLight ? '#94a3b8' : '#6b7280'} />
1282
- <text x="30" y="76" fill={pal.legendText} fontSize="11" fontFamily="monospace">offline / no SSE</text>
1283
- <path d="M140,72 Q164,48 196,72" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" />
2643
+ {/* R57: matching drop-shadow elevation to the legend panel. */}
2644
+ <rect
2645
+ x="0" y="0" width="224" height="96" rx="10"
2646
+ fill={pal.legendBox.fill}
2647
+ stroke={pal.legendBox.stroke}
2648
+ opacity={isLight ? 0.97 : 0.92}
2649
+ style={{ filter: isLight ? 'drop-shadow(0 2px 6px rgba(15,23,42,0.08))' : 'drop-shadow(0 2px 6px rgba(0,0,0,0.45))' }}
2650
+ data-topo-panel-elevation="legend"
2651
+ />
2652
+ {([
2653
+ { key: 'working' as const, y0: 24, y1: 28, fill: isLight ? '#059669' : '#22c55e', label: 'working node' },
2654
+ { key: 'idle' as const, y0: 48, y1: 52, fill: isLight ? '#0d9488' : '#2dd4bf', label: 'online idle' },
2655
+ { key: 'offline' as const, y0: 72, y1: 76, fill: isLight ? '#94a3b8' : '#6b7280', label: 'offline / no SSE' },
2656
+ ]).map(row => {
2657
+ // Round 61 / Loop: legend rows pin too — symmetric with the
2658
+ // R60 pressure-bar segments. R55 hover stays transient; the
2659
+ // new onClick toggles pinnedStatus so users can lock a
2660
+ // filter without holding the cursor still. Pinned row gets
2661
+ // an inset ring on the swatch (same vocab as R60).
2662
+ const isPinned = pinnedStatus === row.key;
2663
+ return (
2664
+ <g
2665
+ key={row.key}
2666
+ data-legend-status={row.key}
2667
+ role="button"
2668
+ tabIndex={0}
2669
+ aria-pressed={isPinned}
2670
+ style={{ cursor: 'pointer' }}
2671
+ // R61: stopPropagation on pointerdown so the SVG-level
2672
+ // pan handler (R103) doesn't setPointerCapture and
2673
+ // redirect the follow-up click away from this <g>.
2674
+ // Same trick the node <g> uses (and R52 hub).
2675
+ onPointerDown={(e) => e.stopPropagation()}
2676
+ onMouseEnter={() => setHoveredStatus(row.key)}
2677
+ onMouseLeave={() => setHoveredStatus(prev => prev === row.key ? null : prev)}
2678
+ onClick={() => setPinnedStatus(prev => prev === row.key ? null : row.key)}
2679
+ >
2680
+ {/* invisible hitbox covers the row so cursor doesn't need
2681
+ to be exactly on the 5-px swatch */}
2682
+ <rect x="6" y={row.y0 - 12} width="170" height="22" fill="transparent" />
2683
+ <circle cx="16" cy={row.y0} r="5.5" fill={row.fill} />
2684
+ {/* R61 pinned-state ring — concentric stroke at r=8 in
2685
+ the row colour, draws OUTSIDE the swatch so it
2686
+ doesn't fight the fill colour the user is matching. */}
2687
+ {isPinned && (
2688
+ <circle cx="16" cy={row.y0} r="8" fill="none" stroke={row.fill} strokeWidth="1.5" />
2689
+ )}
2690
+ <text
2691
+ x="30" y={row.y1}
2692
+ fill={hoveredStatus === row.key || isPinned ? pal.legendHeadline : pal.legendText}
2693
+ fontSize="11"
2694
+ fontFamily="monospace"
2695
+ style={{ transition: 'fill 150ms ease-out' }}
2696
+ >{row.label}</text>
2697
+ </g>
2698
+ );
2699
+ })}
2700
+ {/* Flow-arrow swatch sits at y=72 — same row as the offline
2701
+ hitbox. Drop its pointerEvents so the offline legend row
2702
+ stays hoverable (R55). It's decoration, no need to
2703
+ receive events. */}
2704
+ <path d="M140,72 Q164,48 196,72" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" style={{ pointerEvents: 'none' }} />
1284
2705
  </g>
1285
2706
  </svg>
1286
2707
 
2708
+ {/* Round 30 / Loop: minimap. Big fleets in fullscreen mode at high
2709
+ zoom let users lose their position — the minimap shows the
2710
+ whole topology miniaturised plus a viewport rectangle so the
2711
+ user always knows where they are. Click anywhere to recenter
2712
+ the canvas there. Only mounted when the view is non-default
2713
+ (zoomed or panned) since at 1× centered the minimap and the
2714
+ canvas show the same thing. HTML overlay so it stays fixed
2715
+ while the SVG transforms. */}
2716
+ {(() => {
2717
+ const isDefaultView = Math.abs(view.zoom - 1) < 0.01 && Math.abs(view.x) < 1 && Math.abs(view.y) < 1;
2718
+ if (isDefaultView || (onlineNodes.length + offlineNodes.length) === 0) return null;
2719
+ const MW = 120, MH = 82;
2720
+ const sx = MW / VIEWBOX_W, sy = MH / VIEWBOX_H;
2721
+ const rectX = (-view.x / view.zoom) * sx;
2722
+ const rectY = (-view.y / view.zoom) * sy;
2723
+ const rectW = (VIEWBOX_W / view.zoom) * sx;
2724
+ const rectH = (VIEWBOX_H / view.zoom) * sy;
2725
+ return (
2726
+ <div
2727
+ className="absolute right-3 rounded-md border shadow-lg shadow-black/30 overflow-hidden anet-fade-in"
2728
+ style={{ bottom: 56, background: pal.legendBox.fill, borderColor: pal.containerBorder, cursor: 'crosshair' }}
2729
+ role="img"
2730
+ aria-label="Topology minimap — click to recenter"
2731
+ title="Minimap · click to recenter"
2732
+ onClick={(e) => {
2733
+ const r = e.currentTarget.getBoundingClientRect();
2734
+ const fx = (e.clientX - r.left) / r.width;
2735
+ const fy = (e.clientY - r.top) / r.height;
2736
+ setView(prev => ({
2737
+ ...prev,
2738
+ x: VIEWBOX_W / 2 - fx * VIEWBOX_W * prev.zoom,
2739
+ y: VIEWBOX_H / 2 - fy * VIEWBOX_H * prev.zoom,
2740
+ }));
2741
+ }}
2742
+ data-topo-minimap
2743
+ >
2744
+ <svg width={MW} height={MH} viewBox={`0 0 ${MW} ${MH}`} style={{ display: 'block' }}>
2745
+ {[...onlineNodes, ...offlineNodes].map(s => {
2746
+ const p = nodePositions[s.alias];
2747
+ if (!p) return null;
2748
+ const sseN = (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
2749
+ const isOn = s.status !== 'offline' || !!sseN;
2750
+ const st = nodeStatus(s, isOn, isLight);
2751
+ 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} />;
2752
+ })}
2753
+ {/* viewport rectangle */}
2754
+ <rect
2755
+ x={Math.max(0, rectX)} y={Math.max(0, rectY)}
2756
+ width={Math.max(0, Math.min(MW - Math.max(0, rectX), rectW))}
2757
+ height={Math.max(0, Math.min(MH - Math.max(0, rectY), rectH))}
2758
+ fill="none" stroke={pal.legendAccent} strokeWidth="1" opacity="0.9"
2759
+ />
2760
+ </svg>
2761
+ </div>
2762
+ );
2763
+ })()}
1287
2764
  {/* Round 103 (issue #81): zoom / pan / fullscreen controls — HTML
1288
2765
  overlay so they stay fixed while the SVG content transforms.
1289
2766
  Round 104: Vincent 实测 — the reset action used to be hidden
@@ -1320,6 +2797,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1320
2797
  className="px-2 py-1 hover:bg-white/5 transition-colors"
1321
2798
  style={{ color: pal.legendText }}
1322
2799
  aria-label="Zoom out"
2800
+ title="Zoom out (−)"
1323
2801
  >
1324
2802
  <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>
1325
2803
  </button>
@@ -1335,6 +2813,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1335
2813
  className="px-2 py-1 hover:bg-white/5 transition-colors"
1336
2814
  style={{ color: pal.legendText }}
1337
2815
  aria-label="Zoom in"
2816
+ title="Zoom in (+)"
1338
2817
  >
1339
2818
  <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>
1340
2819
  </button>
@@ -1344,7 +2823,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1344
2823
  className="p-1.5 rounded-md border hover:bg-white/5 transition-colors"
1345
2824
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
1346
2825
  aria-label="Reset view"
1347
- title="Reset zoom + pan (or double-click the canvas)"
2826
+ title="Reset zoom + pan (0, or double-click the canvas)"
1348
2827
  >
1349
2828
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1350
2829
  <path d="M3 12a9 9 0 1 0 9-9 9 9 0 0 0-6.4 2.6L3 8" />