@sleep2agi/agent-network-dashboard 0.4.6-preview.8 → 0.4.6-preview.80

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 (286) 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/04eilowxyfm3z.js +1 -0
  184. package/.next/static/chunks/05uk96gc~9mni.js +1 -0
  185. package/.next/static/chunks/08kf-.bgs10ze.js +1 -0
  186. package/.next/static/chunks/0glmrdj9112tc.js +7 -0
  187. package/.next/static/chunks/0rxrez_cjn.eh.js +1 -0
  188. package/.next/static/chunks/0s5km_6ow6jzv.js +7 -0
  189. package/.next/static/chunks/0tvn2l1pc.h65.js +1 -0
  190. package/.next/static/chunks/0w2-uz~4phw2f.css +2 -0
  191. package/.next/static/chunks/0x8x.29hkju5a.js +1 -0
  192. package/.next/static/chunks/0zyef0ov9aijd.js +1 -0
  193. package/.next/static/chunks/13_ce6isvlsqf.js +2 -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 +33 -0
  203. package/app/components/HelpOverlay.tsx +40 -0
  204. package/app/components/ServersDrawer.tsx +206 -0
  205. package/app/components/TopoGraph.tsx +1454 -166
  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-test.mjs +73 -0
  213. package/scripts/topo-arrow-tier-test.mjs +61 -0
  214. package/scripts/topo-auto-fit-test.mjs +73 -0
  215. package/scripts/topo-chat-focus-ring-test.mjs +77 -0
  216. package/scripts/topo-click-ripple-test.mjs +66 -0
  217. package/scripts/topo-cmdk-pin-test.mjs +123 -0
  218. package/scripts/topo-cwd-tooltip-test.mjs +64 -0
  219. package/scripts/topo-dblclick-reset-test.mjs +74 -0
  220. package/scripts/topo-edge-endpoint-highlight-test.mjs +111 -0
  221. package/scripts/topo-edge-focus-test.mjs +113 -0
  222. package/scripts/topo-edge-hover-test.mjs +93 -0
  223. package/scripts/topo-edge-tooltip-test.mjs +70 -0
  224. package/scripts/topo-esc-clears-pin-test.mjs +98 -0
  225. package/scripts/topo-filter-pill-body-click-test.mjs +112 -0
  226. package/scripts/topo-filter-pill-count-test.mjs +110 -0
  227. package/scripts/topo-filter-pill-mobile-test.mjs +90 -0
  228. package/scripts/topo-fit-key-test.mjs +106 -0
  229. package/scripts/topo-freshness-chip-test.mjs +74 -0
  230. package/scripts/topo-group-box-pinned-state-test.mjs +95 -0
  231. package/scripts/topo-group-count-test.mjs +68 -0
  232. package/scripts/topo-group-hover-test.mjs +82 -0
  233. package/scripts/topo-group-pin-test.mjs +119 -0
  234. package/scripts/topo-group-status-pips-test.mjs +103 -0
  235. package/scripts/topo-groupbox-test.mjs +4 -2
  236. package/scripts/topo-help-overlay-r59-test.mjs +67 -0
  237. package/scripts/topo-help-overlay-r65-test.mjs +64 -0
  238. package/scripts/topo-help-overlay-r70-test.mjs +63 -0
  239. package/scripts/topo-hub-click-fit-test.mjs +137 -0
  240. package/scripts/topo-hub-title-test.mjs +93 -0
  241. package/scripts/topo-ingroup-edge-test.mjs +103 -0
  242. package/scripts/topo-label-lift-test.mjs +119 -0
  243. package/scripts/topo-label-scale-test.mjs +78 -0
  244. package/scripts/topo-lastseen-tooltip-test.mjs +59 -0
  245. package/scripts/topo-layout-key-test.mjs +91 -0
  246. package/scripts/topo-legend-hover-test.mjs +113 -0
  247. package/scripts/topo-legend-pin-test.mjs +128 -0
  248. package/scripts/topo-minimap-test.mjs +68 -0
  249. package/scripts/topo-mobile-chips-test.mjs +103 -0
  250. package/scripts/topo-node-hover-lift-test.mjs +85 -0
  251. package/scripts/topo-offline-dim-test.mjs +70 -0
  252. package/scripts/topo-overlap-test.mjs +164 -0
  253. package/scripts/topo-p0round-test.mjs +2 -2
  254. package/scripts/topo-pan-cursor-test.mjs +62 -0
  255. package/scripts/topo-panel-elevation-test.mjs +76 -0
  256. package/scripts/topo-pin-persistence-test.mjs +105 -0
  257. package/scripts/topo-popover-meta-test.mjs +78 -0
  258. package/scripts/topo-pressure-bar-test.mjs +69 -0
  259. package/scripts/topo-pressure-pin-test.mjs +112 -0
  260. package/scripts/topo-pulse-rate-test.mjs +71 -0
  261. package/scripts/topo-recent-row-hover-test.mjs +122 -0
  262. package/scripts/topo-recent-signal-empty-test.mjs +63 -0
  263. package/scripts/topo-reduced-motion-test.mjs +77 -0
  264. package/scripts/topo-tier-ring-test.mjs +69 -0
  265. package/scripts/topo-tier-stagger-test.mjs +89 -0
  266. package/scripts/topo-tz-ghost-test.mjs +69 -0
  267. package/scripts/topo-wheel-zoom-test.mjs +79 -0
  268. package/scripts/topo-zoom-keys-test.mjs +89 -0
  269. package/.next/static/chunks/0-7iowzu4.798.js +0 -1
  270. package/.next/static/chunks/02t5jr4shh.hy.js +0 -1
  271. package/.next/static/chunks/0_qfg5s5uzs7..js +0 -7
  272. package/.next/static/chunks/0_rmxom9~1lmk.css +0 -2
  273. package/.next/static/chunks/0cbezu61m-e~z.js +0 -1
  274. package/.next/static/chunks/0cj5qr_a8dzky.js +0 -7
  275. package/.next/static/chunks/0elalb9n_.~di.js +0 -1
  276. package/.next/static/chunks/0j_q__o8p6g83.js +0 -1
  277. package/.next/static/chunks/0jl6_w50zh4l5.js +0 -1
  278. package/.next/static/chunks/0mn0ruf0g4op5.js +0 -1
  279. package/.next/static/chunks/0r4q73.-1bkyl.js +0 -1
  280. package/.next/static/chunks/0r_c8eccg3z2o.js +0 -1
  281. package/.next/static/chunks/0y9subfnntv1s.js +0 -1
  282. package/.next/static/chunks/10oi7l-tycgl9.js +0 -1
  283. package/.next/static/chunks/11uk1izuv3~0t.js +0 -1
  284. /package/.next/static/{7xem03-473G3qw-fC160p → b35iGYnW-KxCMXl7VYDhg}/_buildManifest.js +0 -0
  285. /package/.next/static/{7xem03-473G3qw-fC160p → b35iGYnW-KxCMXl7VYDhg}/_clientMiddlewareManifest.js +0 -0
  286. /package/.next/static/{7xem03-473G3qw-fC160p → b35iGYnW-KxCMXl7VYDhg}/_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);
@@ -422,10 +526,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
422
526
  const all = [...online, ...offline];
423
527
  const groupKeys = computeGroups(all);
424
528
  const cols = Math.max(1, Math.ceil(Math.sqrt(all.length)));
425
- // #112: gy0 starts below the recent-signal / legend overlay panels so
426
- // grid row 0 is never occluded by them (Vincent 4727).
427
- const gx0 = 150, gx1 = 850, gy0 = 184, gy1 = 588;
529
+ // #112: gy0 starts below the (now compact) recent-signal / legend
530
+ // overlay panels their max bottom edge is y≈112 so grid row 0 is
531
+ // never occluded; gy1 extends near the canvas bottom for breathing room.
532
+ const gx0 = 150, gx1 = 850, gy0 = 126, gy1 = 652;
428
533
  const cellW = (gx1 - gx0) / cols;
534
+ // largest node radius at the current size scale — drives the group
535
+ // label band + the row-height floor so nothing ever overlaps.
536
+ const nodeR = Math.round(26 * nodeScale);
429
537
 
430
538
  // ordered runs of consecutive same-group-key nodes (≥2 = real group)
431
539
  const runs: { key: string; members: Session[] }[] = [];
@@ -459,9 +567,32 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
459
567
  }
460
568
  }
461
569
  const totalRows = Math.max(1, row);
462
- // Adaptive row height so every band fits the canvas many small groups
463
- // would overflow a fixed height; zoom/pan still works past the floor.
464
- const cellH = Math.min(100, (gy1 - gy0) / totalRows);
570
+ // #112: the group label sits in a band ABOVE the topmost node, so the
571
+ // band must clear the node radius GROUP_TOP is node-relative, never
572
+ // cellH-derived (cellH-derived was the label↔node overlap bug Vincent
573
+ // hit). cellH then floors at GROUP_TOP + 30 so the label band + bottom
574
+ // padding always fit and stacked group boxes never touch; past the
575
+ // floor the grid overflows and zoom/pan handles it.
576
+ const GROUP_TOP = nodeR + 20;
577
+ // Round 27 / P0 (Vincent screenshot preview.29): dense plain-text
578
+ // node labels (`pos.y + radius + denseDrop`, with a 3 px containerBg
579
+ // stroke halo for readability) at the BOTTOM row of band N would
580
+ // paint OVER the start of band N+1's group label, creating the
581
+ // "blueleap → eleap", "agent-network-dashboard → t-network / board"
582
+ // visual chopping. Geometry of the collision:
583
+ // dense label visual bottom = node_N.y + radius + denseDrop + halo
584
+ // group label glyph top = boxY + 4 (text y=boxY+14, ~10px ascent)
585
+ // = node_N+1.y - GROUP_TOP + 4
586
+ // node_N+1.y - node_N.y = cellH for consecutive rows, so no-collide:
587
+ // cellH ≥ radius + denseDrop + halo + GROUP_TOP - 4 + buffer
588
+ // With halo=3 buffer=4: cellH ≥ nodeR + denseDrop + GROUP_TOP + 3.
589
+ const denseDrop = nodeScale < 0.8 ? 12 : 14;
590
+ const cellH = Math.max(
591
+ 2 * nodeR + 22, // node + dense label within band
592
+ GROUP_TOP + 12, // group-label band + box padding
593
+ nodeR + denseDrop + GROUP_TOP + 3, // round-27 dense↔group label clearance
594
+ Math.min(100, (gy1 - gy0) / totalRows),
595
+ );
465
596
 
466
597
  // Pass 2 — place each band's members.
467
598
  for (const band of bands) {
@@ -481,11 +612,10 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
481
612
  const active = new Set<string>();
482
613
  links.forEach(link => { active.add(link.from); active.add(link.to); });
483
614
  // #111: one bounding box per multi-member group (Vincent 4722). Each
484
- // group owns its rows, so the box is a clean rect with no overlap.
485
- // Padding scales with the (adaptive) row height so stacked group boxes
486
- // stay clear of each other even when many bands compress the grid.
487
- const GROUP_TOP = Math.max(22, Math.min(44, cellH * 0.4)); // label band
488
- const GROUP_PAD = Math.max(16, Math.min(32, cellH * 0.3)); // side/bottom
615
+ // group owns its rows; GROUP_PAD fills the row space left below the
616
+ // nodes after the label band, so stacked group boxes always have a
617
+ // gap between them (GROUP_TOP is defined above, with cellH).
618
+ const GROUP_PAD = Math.max(8, Math.min(26, cellH - GROUP_TOP - 8)); // side/bottom
489
619
  const groupBoxes = bands
490
620
  .filter(b => b.isGroup)
491
621
  .map(band => {
@@ -493,14 +623,36 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
493
623
  const xs = pts.map(p => p.x);
494
624
  const ys = pts.map(p => p.y);
495
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
+ }
496
640
  return {
497
641
  key: band.members.length ? groupKeys[band.members[0].alias] : '',
642
+ count: band.members.length,
643
+ statuses: { working: w, idle: i, offline: o },
498
644
  x: minX - GROUP_PAD,
499
645
  y: minY - GROUP_TOP,
500
646
  w: Math.max(...xs) - minX + GROUP_PAD * 2,
501
647
  h: Math.max(...ys) - minY + GROUP_TOP + GROUP_PAD,
502
648
  };
503
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;
504
656
  return {
505
657
  onlineNodes: online,
506
658
  offlineNodes: offline,
@@ -509,6 +661,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
509
661
  activeAliases: active,
510
662
  groupKeys,
511
663
  groupBoxes,
664
+ gridContentBottom,
512
665
  };
513
666
  }
514
667
 
@@ -590,16 +743,133 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
590
743
  groupKeys,
591
744
  // #111: group boxes are a grid-layout feature only — radially scattered
592
745
  // ring nodes can't be cleanly boxed. Ring keeps the #83 prefix hue.
593
- 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,
594
749
  };
595
- }, [messages, sessions, sseSessions, layout]);
750
+ }, [messages, sessions, sseSessions, layout, nodeScale]);
596
751
 
597
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]);
598
767
  // Round 109 (Vincent 4582 P0): hover-gated labels above this node count
599
768
  // so dense fleets show clean avatars instead of a wall of overlapping
600
769
  // label cards. 16 ≈ where the triple-tier rings start to crowd.
601
770
  const denseLayout = onlineNodes.length + offlineNodes.length > 16;
602
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 55 / Loop: hovering a legend status row dims nodes whose status
815
+ // doesn't match. The legend was passive — "what does this colour mean".
816
+ // Now it answers "show me all of these" the same way R8 group-focus
817
+ // answers "show me this team". Three values match the legend rows.
818
+ const [hoveredStatus, setHoveredStatus] = useState<'working' | 'idle' | 'offline' | null>(null);
819
+ // Round 60 / Loop: sticky variant of `hoveredStatus`. R55 only filters
820
+ // while the user is actively hovering the legend; for sweeping a fleet
821
+ // you want to LOCK the filter. Each segment of the R31 pressure bar
822
+ // toggles a pin — click again on the same segment to release. The node
823
+ // opacity formula reads `activeStatus = hoveredStatus ?? pinnedStatus`
824
+ // so hover transiently overrides a pin (handy for spot-comparison)
825
+ // without nuking it.
826
+ // Round 66 / Loop: pin survives a page reload via sessionStorage —
827
+ // per-tab not per-browser (a new tab starts clean, intentionally).
828
+ // The init reader validates against the known status set so a stale
829
+ // / corrupt value can't paint the canvas with an impossible filter.
830
+ const [pinnedStatus, setPinnedStatus] = useState<'working' | 'idle' | 'offline' | null>(() => {
831
+ if (typeof window === 'undefined') return null;
832
+ try {
833
+ const v = sessionStorage.getItem('anet-topo-pinned-status');
834
+ return (v === 'working' || v === 'idle' || v === 'offline') ? v : null;
835
+ } catch { return null; }
836
+ });
837
+ const activeStatus = hoveredStatus ?? pinnedStatus;
838
+ // R66: sync pinnedStatus into sessionStorage when it changes. Paired
839
+ // with the matching effect for pinnedGroup higher up.
840
+ useEffect(() => {
841
+ try {
842
+ if (pinnedStatus) sessionStorage.setItem('anet-topo-pinned-status', pinnedStatus);
843
+ else sessionStorage.removeItem('anet-topo-pinned-status');
844
+ } catch {}
845
+ }, [pinnedStatus]);
846
+ // R69: listen for Cmd+K palette pin actions. The palette can't reach
847
+ // this component's state directly so it dispatches a CustomEvent;
848
+ // we react by setting pinnedStatus / pinnedGroup. The palette also
849
+ // writes to sessionStorage in lockstep, so the R66 init readers
850
+ // would pick it up on reload — the event handler is what keeps the
851
+ // currently-mounted canvas in sync without one.
852
+ useEffect(() => {
853
+ const onPin = (e: Event) => {
854
+ const detail = (e as CustomEvent).detail || {};
855
+ if (detail.kind === 'clear') {
856
+ setPinnedStatus(null);
857
+ setPinnedGroup(null);
858
+ } else if (detail.kind === 'status') {
859
+ const v = detail.value;
860
+ if (v === 'working' || v === 'idle' || v === 'offline') setPinnedStatus(v);
861
+ } else if (detail.kind === 'group' && typeof detail.value === 'string') {
862
+ setPinnedGroup(detail.value);
863
+ }
864
+ };
865
+ window.addEventListener('anet:topo-pin', onPin);
866
+ return () => window.removeEventListener('anet:topo-pin', onPin);
867
+ }, []);
868
+ const hoveredEdgeEndpoints = useMemo<Set<string> | null>(() => {
869
+ if (!hoveredEdgeKey) return null;
870
+ const link = flowLinks.find(l => l.key === hoveredEdgeKey);
871
+ return link ? new Set([link.from, link.to]) : null;
872
+ }, [hoveredEdgeKey, flowLinks]);
603
873
 
604
874
  // --- Round 103 (issue #81): fullscreen + zoom + pan interaction layer ---
605
875
  // DIY native (no d3 / svg-pan-zoom): wrap the topology content in a single
@@ -619,6 +889,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
619
889
  // Issue #100: singleton chat popover. One alias at a time — clicking
620
890
  // another node swaps the target and the conversation switches in place.
621
891
  const [chatAlias, setChatAlias] = useState<string | null>(null);
892
+ // Round 14 / Loop: one-shot click ripple — fades outward from the clicked
893
+ // node so the click registers physically before the popover snaps in.
894
+ // Pairs with the Round 11 chat-focus ring: ripple expands, ring locks on.
895
+ // Keyed by ts so re-clicking the same node remounts the <circle> and the
896
+ // SMIL <animate> replays. Cleared 600ms after click (longer than the
897
+ // 500ms animation so a final frame at opacity 0 is still in the tree).
898
+ const [clickRipple, setClickRipple] = useState<{
899
+ ts: number; x: number; y: number; r0: number; color: string;
900
+ } | null>(null);
622
901
  // #84: when a node is renamed while its chat popover is open, follow the
623
902
  // rename so the conversation keeps targeting a live alias.
624
903
  useEffect(() => {
@@ -649,6 +928,42 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
649
928
  } catch {}
650
929
  }, []);
651
930
 
931
+ // Round 28 / Loop: first-paint auto-fit. Round 27's cellH bump means
932
+ // dense grids (≥6-7 rows) overflow the 680-px viewBox at the natural
933
+ // 100% zoom — a new user sees the topology with its bottom rows
934
+ // clipped and no hint that there's more below. Auto-fit on first
935
+ // paint (and only if the user has no persisted view) sets the
936
+ // initial zoom so all content fits. Subsequent zoom changes persist
937
+ // and override the auto-fit on reload; explicit reset (0 key) still
938
+ // goes back to 100% so the gesture's "reset to natural size" semantic
939
+ // is preserved.
940
+ //
941
+ // Capture pre-mount persistence in a useState initializer — the
942
+ // existing `persist` effect (declared below) runs on first render
943
+ // with the default {1,0,0} view and writes it to localStorage before
944
+ // the auto-fit effect (deps on async-arriving sessions) gets a turn.
945
+ // Reading localStorage from the effect would see that write and
946
+ // skip the fit. The useState snapshot fires once, before any effects.
947
+ const [hadPersistedViewOnMount] = useState<boolean>(
948
+ () => typeof window !== 'undefined' && !!localStorage.getItem('anet-topo-view'),
949
+ );
950
+ const autoFitDoneRef = useRef(false);
951
+ useEffect(() => {
952
+ if (autoFitDoneRef.current) return;
953
+ if (hadPersistedViewOnMount) {
954
+ autoFitDoneRef.current = true;
955
+ return;
956
+ }
957
+ if (layout !== 'grid' || sessions.length === 0 || !gridContentBottom) return;
958
+ if (gridContentBottom <= VIEWBOX_H) {
959
+ autoFitDoneRef.current = true; // no overflow → no fit needed
960
+ return;
961
+ }
962
+ const fitZoom = Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
963
+ setView({ zoom: fitZoom, x: 0, y: 0 });
964
+ autoFitDoneRef.current = true;
965
+ }, [layout, sessions.length, gridContentBottom, hadPersistedViewOnMount]);
966
+
652
967
  // persist view
653
968
  useEffect(() => {
654
969
  try { localStorage.setItem('anet-topo-view', JSON.stringify(view)); } catch {}
@@ -668,6 +983,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
668
983
  const svg = svgRef.current;
669
984
  if (!svg) return;
670
985
  const onWheel = (e: WheelEvent) => {
986
+ // Round 23 / Loop: only zoom on Ctrl/Meta+wheel when inline; plain
987
+ // wheel over the canvas keeps scrolling the page (the topo sits at
988
+ // the top of /; trapping page scroll mid-page is the classic
989
+ // "scroll-jail" anti-pattern). In fullscreen the canvas owns the
990
+ // viewport so any wheel zooms — no page scroll to preserve. Pinch-
991
+ // zoom on a trackpad surfaces as ctrlKey=true natively, which is
992
+ // also what we want (zoom the canvas, not the browser).
993
+ if (!isFullscreen && !e.ctrlKey && !e.metaKey) return;
671
994
  e.preventDefault();
672
995
  const rect = svg.getBoundingClientRect();
673
996
  const mx = ((e.clientX - rect.left) / rect.width) * VIEWBOX_W;
@@ -688,8 +1011,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
688
1011
  };
689
1012
  svg.addEventListener('wheel', onWheel, { passive: false });
690
1013
  return () => svg.removeEventListener('wheel', onWheel);
691
- }, []);
692
-
1014
+ // Re-attach when the fullscreen flag toggles so the handler's
1015
+ // closure picks up the new value (capture-by-closure means we'd
1016
+ // otherwise read the stale boolean forever).
1017
+ }, [isFullscreen]);
1018
+
1019
+ // Round 21 / Loop: pan-aware cursor. Static `grab` gave no tactile cue
1020
+ // that the canvas was actually being dragged — "grabbing" while
1021
+ // dragRef.current.active = true tells the hand it's moving the
1022
+ // viewport. Mirroring dragRef.active into state is the only way to
1023
+ // re-render the SVG style; the ref itself doesn't trigger renders.
1024
+ const [isPanning, setIsPanning] = useState(false);
693
1025
  const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
694
1026
  if (e.button !== 0) return;
695
1027
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
@@ -700,6 +1032,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
700
1032
  baseX: viewRef.current.x,
701
1033
  baseY: viewRef.current.y,
702
1034
  };
1035
+ setIsPanning(true);
703
1036
  };
704
1037
  const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
705
1038
  const d = dragRef.current;
@@ -714,6 +1047,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
714
1047
  const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
715
1048
  if (!dragRef.current.active) return;
716
1049
  dragRef.current.active = false;
1050
+ setIsPanning(false);
717
1051
  try {
718
1052
  (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
719
1053
  } catch {}
@@ -731,6 +1065,64 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
731
1065
  };
732
1066
  const resetView = () => setView({ zoom: 1, x: 0, y: 0 });
733
1067
 
1068
+ // Round 29 / Loop: `f` = fit-to-content. Shared by the Round 28
1069
+ // first-paint auto-fit effect and the keyboard handler so the math is
1070
+ // in one place. When content already fits at natural zoom, this is
1071
+ // effectively a "recenter" — `f` always lands on a known good view.
1072
+ const fitView = useCallback(() => {
1073
+ const zoom = !gridContentBottom || gridContentBottom <= VIEWBOX_H
1074
+ ? 1
1075
+ : Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
1076
+ setView({ zoom, x: 0, y: 0 });
1077
+ }, [gridContentBottom]);
1078
+
1079
+ // Round 22 / Loop: keyboard zoom — +/= zoom in, - zoom out, 0 reset.
1080
+ // Round 29 / Loop: +f to fit content.
1081
+ // Listen on window so the user doesn't need to focus the SVG first,
1082
+ // but only act when no text input has focus (otherwise typing "-" in
1083
+ // a chat would zoom the topology — surprising and bad). Modifier keys
1084
+ // pass through so Cmd/Ctrl combos still hit their owners. The button
1085
+ // titles below document these shortcuts so users discover them.
1086
+ useEffect(() => {
1087
+ const onKey = (e: KeyboardEvent) => {
1088
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
1089
+ const ae = document.activeElement as HTMLElement | null;
1090
+ if (ae) {
1091
+ const tag = ae.tagName;
1092
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || ae.isContentEditable) return;
1093
+ }
1094
+ if (e.key === '+' || e.key === '=') { zoomBy(1.2); e.preventDefault(); }
1095
+ else if (e.key === '-' || e.key === '_') { zoomBy(1 / 1.2); e.preventDefault(); }
1096
+ else if (e.key === '0') { resetView(); e.preventDefault(); }
1097
+ else if (e.key === 'f' || e.key === 'F') { fitView(); e.preventDefault(); }
1098
+ // Round 32 / Loop: `l` toggles ring|grid. The vim-style `g l` route
1099
+ // (Audit Log) requires a preceding `g` within 1500ms; a bare `l`
1100
+ // outside that window is free for topology use.
1101
+ else if (e.key === 'l' || e.key === 'L') { toggleLayout(); e.preventDefault(); }
1102
+ // Round 62 / Loop: Esc clears the R60/R61 pinned status filter so
1103
+ // users have a universal-cancel keyboard out. Esc on an open chat
1104
+ // is owned by ChatPopover (which only mounts when chatAlias is
1105
+ // set), so this handler is effectively scoped to "no chat open".
1106
+ // We additionally guard on chatAlias to be explicit — if the chat
1107
+ // closed mid-cycle, the pin can still be cleared on the next Esc.
1108
+ // R63: extends to pinnedGroup too. Clears WHATEVER pin is active
1109
+ // (one Esc collapses all topology pins) so the keyboard escape
1110
+ // route stays a single key, not "Esc maybe Esc again".
1111
+ else if (e.key === 'Escape' && !chatAlias && (pinnedStatus || pinnedGroup)) {
1112
+ if (pinnedStatus) setPinnedStatus(null);
1113
+ if (pinnedGroup) setPinnedGroup(null);
1114
+ e.preventDefault();
1115
+ }
1116
+ };
1117
+ window.addEventListener('keydown', onKey);
1118
+ return () => window.removeEventListener('keydown', onKey);
1119
+ // zoomBy / resetView are stable wrt setView callback; fitView changes
1120
+ // with gridContentBottom so deps list catches it. R62 adds chatAlias
1121
+ // and pinnedStatus so the Escape branch reads fresh state (re-binding
1122
+ // the listener on these state changes is sub-ms — cheaper than refs).
1123
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1124
+ }, [fitView, chatAlias, pinnedStatus, pinnedGroup]);
1125
+
734
1126
  const toggleFullscreen = () => {
735
1127
  const el = containerRef.current;
736
1128
  if (!el) return;
@@ -758,6 +1150,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
758
1150
  <button
759
1151
  onClick={() => { if (layout !== 'ring') toggleLayout(); }}
760
1152
  aria-pressed={layout === 'ring'}
1153
+ title="Ring layout (l to toggle)"
761
1154
  className={`px-2.5 py-1 transition-colors ${layout === 'ring' ? 'bg-cyan-500/15 text-cyan-300' : 'text-gray-500 hover:text-gray-400'}`}
762
1155
  >
763
1156
  Ring
@@ -765,6 +1158,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
765
1158
  <button
766
1159
  onClick={() => { if (layout !== 'grid') toggleLayout(); }}
767
1160
  aria-pressed={layout === 'grid'}
1161
+ title="Grid layout (l to toggle)"
768
1162
  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'}`}
769
1163
  >
770
1164
  Grid
@@ -776,9 +1170,196 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
776
1170
  <span className="px-2.5 py-1 rounded-md bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
777
1171
  {onlineNodes.length} online
778
1172
  </span>
779
- <span className="px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20">
780
- {flowLinks.length} active links
781
- </span>
1173
+ {/* Round 31 / Loop: fleet-health pressure bar. The "X working /
1174
+ Y online" chips already carry the raw counts; the bar lets
1175
+ the eye get the working/idle/offline RATIO in one glance
1176
+ without mental math. Stacked 3-segment chip, ~64 px wide. */}
1177
+ {(() => {
1178
+ const w = workingCount;
1179
+ const i = onlineNodes.length - workingCount; // idle = online - working
1180
+ const o = offlineNodes.length;
1181
+ const total = w + i + o;
1182
+ if (total === 0) return null;
1183
+ // Round 60 / Loop: each segment toggles a sticky filter via
1184
+ // `pinnedStatus`. Click the working segment → all non-working
1185
+ // nodes dim; click again → release. Segments share width with
1186
+ // their proportion of `total`, so a 1-node working share is
1187
+ // ~3 px wide on a 64-px bar. We pad the click target with a
1188
+ // negative-margin overlay wrapper to give thin slices a
1189
+ // 14-px minimum hit area without disturbing the rendered
1190
+ // chip width. cursor:pointer + a one-line title hint at the
1191
+ // affordance.
1192
+ const seg = (n: number, color: string, key: 'working' | 'idle' | 'offline', label: string) => {
1193
+ if (n === 0) return null;
1194
+ const isPinned = pinnedStatus === key;
1195
+ return (
1196
+ <span
1197
+ key={key}
1198
+ data-pressure-seg={key}
1199
+ role="button"
1200
+ tabIndex={0}
1201
+ aria-pressed={isPinned}
1202
+ title={`${n} ${label} — click to ${isPinned ? 'release filter' : 'highlight'}`}
1203
+ style={{
1204
+ width: `${(n / total) * 100}%`,
1205
+ background: color,
1206
+ height: '100%',
1207
+ cursor: 'pointer',
1208
+ boxShadow: isPinned ? `inset 0 0 0 1px ${color}, inset 0 0 0 2px rgba(255,255,255,0.6)` : undefined,
1209
+ }}
1210
+ onClick={(e) => {
1211
+ e.stopPropagation();
1212
+ setPinnedStatus(prev => prev === key ? null : key);
1213
+ }}
1214
+ onKeyDown={(e) => {
1215
+ if (e.key === 'Enter' || e.key === ' ') {
1216
+ e.preventDefault();
1217
+ setPinnedStatus(prev => prev === key ? null : key);
1218
+ }
1219
+ }}
1220
+ />
1221
+ );
1222
+ };
1223
+ // Round 47 / Loop: hidden on mobile — at <640px the chip row
1224
+ // wraps to multiple lines and crowds the topology header;
1225
+ // pressure ratio is best read with the working+online raw
1226
+ // counts (kept visible) anyway.
1227
+ return (
1228
+ <span
1229
+ 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"
1230
+ title={`${w} working · ${i} idle · ${o} offline`}
1231
+ data-fleet-pressure
1232
+ >
1233
+ <span className="text-[10px] tracking-wide">pressure</span>
1234
+ <span className="inline-flex h-1.5 w-16 rounded-full overflow-hidden" style={{ background: 'rgb(75 85 99 / 0.25)' }}>
1235
+ {seg(w, isLight ? '#059669' : '#22c55e', 'working', 'working')}
1236
+ {seg(i, isLight ? '#0d9488' : '#2dd4bf', 'idle', 'idle')}
1237
+ {seg(o, isLight ? '#94a3b8' : '#6b7280', 'offline', 'offline')}
1238
+ </span>
1239
+ </span>
1240
+ );
1241
+ })()}
1242
+ {/* Round 64 / Loop: active-filter pills. When pinnedStatus or
1243
+ pinnedGroup is set, show a small "filter: <key> ×" pill so
1244
+ the user can see the pin from the chip row even if they
1245
+ scrolled the canvas off-screen. × button clears the
1246
+ specific pin (Esc still clears all — both paths are
1247
+ valid). pinnedStatus and pinnedGroup pin independently so
1248
+ both pills may render simultaneously. */}
1249
+ {/* R67: pills enter via anet-fade-in so they appear softly
1250
+ instead of popping. The "filter: " prefix collapses below
1251
+ sm — at narrow viewports the chip row is precious real
1252
+ estate (R47 already hides pressure / vendor / freshness),
1253
+ so dropping the redundant label keeps the working/idle/
1254
+ alpha keys readable without overflow. */}
1255
+ {/* R71: each pill picks up a "· N" match count tail. Tells the
1256
+ user at a glance how many sessions the active filter
1257
+ matches without scanning the canvas. Counts come from the
1258
+ already-computed workingCount + onlineNodes + offlineNodes
1259
+ for status, and groupKeys for group. */}
1260
+ {/* R73: entire pill body is a click-to-clear target — matches
1261
+ the Notion / Linear tag UX (the whole chip releases). The
1262
+ × keeps its dedicated <button> + aria-label for screen
1263
+ readers; the outer span just adds an extra mouse-friendly
1264
+ hit area with a title hint. ×'s onClick stopPropagation
1265
+ so the redundant outer onClick doesn't double-fire (no
1266
+ functional difference since both clear, but cleaner
1267
+ event flow). */}
1268
+ {pinnedStatus && (() => {
1269
+ const matchCount = pinnedStatus === 'working' ? workingCount
1270
+ : pinnedStatus === 'idle' ? (onlineNodes.length - workingCount)
1271
+ : offlineNodes.length;
1272
+ return (
1273
+ <span
1274
+ data-active-filter="status"
1275
+ data-filter-match-count={matchCount}
1276
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in"
1277
+ title="Click to clear filter"
1278
+ onClick={() => setPinnedStatus(null)}
1279
+ style={{
1280
+ background: pinnedStatus === 'working' ? (isLight ? '#05966914' : '#22c55e1f')
1281
+ : pinnedStatus === 'idle' ? (isLight ? '#0d948814' : '#2dd4bf1f')
1282
+ : (isLight ? '#94a3b814' : '#6b72801f'),
1283
+ color: pinnedStatus === 'working' ? (isLight ? '#047857' : '#86efac')
1284
+ : pinnedStatus === 'idle' ? (isLight ? '#0f766e' : '#5eead4')
1285
+ : (isLight ? '#475569' : '#9ca3af'),
1286
+ borderColor: 'currentColor',
1287
+ cursor: 'pointer',
1288
+ }}
1289
+ >
1290
+ <span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{pinnedStatus}<span className="opacity-70"> · {matchCount}</span></span>
1291
+ <button
1292
+ type="button"
1293
+ aria-label={`Clear ${pinnedStatus} filter`}
1294
+ onClick={(e) => { e.stopPropagation(); setPinnedStatus(null); }}
1295
+ className="ml-0.5 leading-none hover:opacity-70"
1296
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1297
+ >×</button>
1298
+ </span>
1299
+ );
1300
+ })()}
1301
+ {pinnedGroup && (() => {
1302
+ const matchCount = Object.values(groupKeys).filter(k => k === pinnedGroup).length;
1303
+ return (
1304
+ <span
1305
+ data-active-filter="group"
1306
+ data-filter-match-count={matchCount}
1307
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in"
1308
+ title="Click to clear filter"
1309
+ onClick={() => setPinnedGroup(null)}
1310
+ style={{
1311
+ background: isLight ? '#67e8f914' : '#67e8f91f',
1312
+ color: pal.legendAccent,
1313
+ borderColor: 'currentColor',
1314
+ cursor: 'pointer',
1315
+ }}
1316
+ >
1317
+ <span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{pinnedGroup}<span className="opacity-70"> · {matchCount}</span></span>
1318
+ <button
1319
+ type="button"
1320
+ aria-label={`Clear group filter ${pinnedGroup}`}
1321
+ onClick={(e) => { e.stopPropagation(); setPinnedGroup(null); }}
1322
+ className="ml-0.5 leading-none hover:opacity-70"
1323
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1324
+ >×</button>
1325
+ </span>
1326
+ );
1327
+ })()}
1328
+ {vendorDist.length > 1 && (
1329
+ <span
1330
+ 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"
1331
+ title="Fleet by model vendor"
1332
+ >
1333
+ {vendorDist.map(v => (
1334
+ <span key={v.initial} className="inline-flex items-baseline gap-0.5">
1335
+ <span style={{ color: v.color }}>{v.initial}</span>
1336
+ <span className="text-gray-500">:{v.count}</span>
1337
+ </span>
1338
+ ))}
1339
+ </span>
1340
+ )}
1341
+ {/* Round 42 / Loop: extend active-links chip with the timestamp
1342
+ of the most-recent flow event. Tells the operator at a glance
1343
+ whether the topology is currently humming (last 30s) or has
1344
+ been quiet for a while — the visual flow particles and
1345
+ edge brightness only show that there IS traffic, not when
1346
+ the last one was. Reuses Round 38's relativeAgo. */}
1347
+ {(() => {
1348
+ const recent = flowLinks.reduce<number | null>((acc, l) => {
1349
+ if (!l.last_at) return acc;
1350
+ const t = parseHubTime(l.last_at);
1351
+ if (t === null) return acc;
1352
+ return acc === null || t > acc ? t : acc;
1353
+ }, null);
1354
+ const rel = recent !== null ? relativeAgo(new Date(recent).toISOString()) : null;
1355
+ return (
1356
+ <span className="hidden sm:inline px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20">
1357
+ {flowLinks.length} active link{flowLinks.length === 1 ? '' : 's'}
1358
+ {rel ? <span className="text-gray-500"> · last {rel}</span> : null}
1359
+ </span>
1360
+ );
1361
+ })()}
1362
+ <FreshnessChip sessions={sessions} />
782
1363
  </div>
783
1364
  </div>
784
1365
 
@@ -798,7 +1379,18 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
798
1379
  onPointerMove={onPointerMove}
799
1380
  onPointerUp={onPointerUp}
800
1381
  onPointerLeave={onPointerUp}
801
- style={{ cursor: 'grab', touchAction: 'none' }}
1382
+ // Round 41 / Loop: the reset-button title (R22) and the Help
1383
+ // overlay (R25) both advertise "double-click the canvas to
1384
+ // reset", but the handler was never actually wired. The text
1385
+ // was lying. Wire it here, guarded so dbl-clicking a node
1386
+ // (which would also trigger the SVG-level handler via event
1387
+ // bubbling) doesn't unexpectedly reset the view on the user.
1388
+ onDoubleClick={(e) => {
1389
+ const t = e.target as Element | null;
1390
+ if (t?.closest('g[data-node]')) return;
1391
+ resetView();
1392
+ }}
1393
+ style={{ cursor: isPanning ? 'grabbing' : 'grab', touchAction: 'none' }}
802
1394
  >
803
1395
  <defs>
804
1396
  <linearGradient id="topo-panel" x1="0" x2="1" y1="0" y2="1">
@@ -820,9 +1412,37 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
820
1412
  </feMerge>
821
1413
  </filter>
822
1414
  )}
823
- <marker id="topo-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
824
- <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
825
- </marker>
1415
+ {/* Round 16 / Loop: 3-tier flow-link arrow markers.
1416
+ The single marker had `markerUnits` defaulting to
1417
+ `strokeWidth`, so heavy edges (stroke=7) rendered 35-user-
1418
+ unit arrowheads — visually dominant, the head outweighed
1419
+ the line. Switching to `userSpaceOnUse` decouples arrow
1420
+ size from stroke; binning by count gives a clearer
1421
+ hierarchy than continuous linear scaling:
1422
+ s (count 1-2) → 12 user units
1423
+ m (count 3-4) → 16 user units (alias for `topo-arrow`)
1424
+ l (count 5+) → 22 user units
1425
+ `topo-arrow` stays bound to the medium tier so the legend
1426
+ swatch (line ~1500) renders without change. */}
1427
+ {[
1428
+ { id: 'topo-arrow-s', size: 12 },
1429
+ { id: 'topo-arrow', size: 16 },
1430
+ { id: 'topo-arrow-l', size: 22 },
1431
+ ].map(m => (
1432
+ <marker
1433
+ key={m.id}
1434
+ id={m.id}
1435
+ viewBox="0 0 10 10"
1436
+ refX="8"
1437
+ refY="5"
1438
+ markerWidth={m.size}
1439
+ markerHeight={m.size}
1440
+ markerUnits="userSpaceOnUse"
1441
+ orient="auto-start-reverse"
1442
+ >
1443
+ <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
1444
+ </marker>
1445
+ ))}
826
1446
  {/* Round 45: radial radar sweep gradient — bright at center
827
1447
  (where it meets the hub) fading to leading edge. */}
828
1448
  <radialGradient id="topo-sweep" cx="0%" cy="50%" r="100%">
@@ -842,13 +1462,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
842
1462
  grid mode drops it so the concentric rings don't sit behind a
843
1463
  rectangular grid. */}
844
1464
  {layout === 'ring' && (<>
845
- <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" />
1465
+ {/* R52: radar bg is pure decoration — drop its pointer events so
1466
+ the hub <g> under it (and any future inner-disk affordances)
1467
+ can receive clicks. Previously the r=330 disc intercepted
1468
+ the hub click outright. */}
1469
+ <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" style={{ pointerEvents: 'none' }} />
846
1470
 
847
1471
  {/* Round 45: subtle star field — 24 deterministic dots scattered
848
1472
  across the canvas give the radar bg some depth. Skipped on
849
1473
  light theme so the white surface stays clean. */}
850
1474
  {!isLight && (
851
- <g opacity="0.5">
1475
+ <g opacity="0.5" style={{ pointerEvents: 'none' }}>
852
1476
  {Array.from({ length: 28 }).map((_, i) => {
853
1477
  // Deterministic pseudo-random scatter so positions are
854
1478
  // stable between renders (no JS hydration mismatch).
@@ -870,6 +1494,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
870
1494
  style={{
871
1495
  transformOrigin: `${cx}px ${cy}px`,
872
1496
  transformBox: 'view-box',
1497
+ pointerEvents: 'none',
873
1498
  }}
874
1499
  className="anet-topo-sweep"
875
1500
  opacity={isLight ? 0.7 : 1}
@@ -880,11 +1505,44 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
880
1505
  />
881
1506
  </g>
882
1507
 
883
- {/* radar rings */}
1508
+ {/* radar rings — pure decoration at fixed radii, independent of
1509
+ node positions so the radar aesthetic is preserved across tier
1510
+ changes. */}
884
1511
  {[90, 170, 250, 330].map(radius => (
885
1512
  <circle key={radius} cx={cx} cy={cy} r={radius} fill="none" stroke={pal.ringStroke} strokeWidth="1" opacity={isLight ? 0.6 : 0.35} />
886
1513
  ))}
887
1514
 
1515
+ {/* Round 54 / Loop: tier-radius guide rings. The radar rings above
1516
+ are decorative and don't match the actual tier radii nodes sit
1517
+ on (single 220 / dual 175,260 / triple 145,215,285). Drawing
1518
+ a faint dashed ring at each ACTIVE tier radius lets the eye
1519
+ anchor "this is the inner / outer ring" without inferring from
1520
+ node spacing. Picked based on online node count so only the
1521
+ tiers currently in use draw — empty tiers stay quiet. pointer-
1522
+ events:none so they never intercept hub or node clicks. The
1523
+ 0.7 stroke + dashed pattern reads as guide, not feature. */}
1524
+ {(
1525
+ onlineNodes.length > onlineTripleThreshold
1526
+ ? [onlineTripleInnerR, onlineTripleMidR, onlineTripleOuterR]
1527
+ : onlineNodes.length > onlineTierThreshold
1528
+ ? [onlineInnerRadius, onlineOuterRadius]
1529
+ : onlineNodes.length > 0
1530
+ ? [onlineRadius]
1531
+ : []
1532
+ ).map(r => (
1533
+ <circle
1534
+ key={`tier-${r}`}
1535
+ cx={cx} cy={cy} r={r}
1536
+ fill="none"
1537
+ stroke={pal.ringStroke}
1538
+ strokeWidth="0.7"
1539
+ strokeDasharray="2 8"
1540
+ opacity={isLight ? 0.32 : 0.42}
1541
+ style={{ pointerEvents: 'none' }}
1542
+ data-tier-ring={r}
1543
+ />
1544
+ ))}
1545
+
888
1546
  {/* Round 50: 4 small particles slowly orbiting the outer ring
889
1547
  (r=330). Each starts at a different angle (offset 0/0.25/0.5/0.75
890
1548
  of the cycle) so they're evenly spaced. 16s per revolution is
@@ -954,32 +1612,119 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
954
1612
  only — groupBoxes is empty in ring mode. Rendered behind the
955
1613
  flow links + nodes; pointer-events off so they never intercept
956
1614
  a node click. Restrained dashed container + group-name label. */}
957
- {groupBoxes.map(box => (
958
- <g key={`grp-${box.key}`} data-group={box.key} style={{ pointerEvents: 'none' }}>
959
- <rect
960
- x={box.x}
961
- y={box.y}
962
- width={box.w}
963
- height={box.h}
964
- rx="14"
965
- fill={isLight ? '#0f172a' : '#a5b4fc'}
966
- fillOpacity={isLight ? 0.025 : 0.045}
967
- stroke={pal.ringStroke}
968
- strokeWidth="1.5"
969
- strokeDasharray="6 6"
970
- />
971
- <text
972
- x={box.x + 12}
973
- y={box.y + 21}
974
- fill={pal.legendText}
975
- fontSize="13"
976
- fontFamily="monospace"
977
- fontWeight="700"
1615
+ {groupBoxes.map(box => {
1616
+ const isHovered = activeGroup === box.key;
1617
+ // R68: distinguish "locked by click" from "currently hovered".
1618
+ // R63 made pinned and hovered identical (both hit isHovered
1619
+ // via activeGroup). A user with one team pinned should see at
1620
+ // a glance which is the locked one even while their cursor
1621
+ // sweeps elsewhere. isPinned reads pinnedGroup directly
1622
+ // (NOT activeGroup) so the visual is specific to the sticky
1623
+ // state transient hover keeps the R63 isHovered styling.
1624
+ const isPinned = pinnedGroup === box.key;
1625
+ // Round 18 / Loop: group-box hover linkage. The Round 8 fade
1626
+ // already dropped OUT-of-focus groups to 0.28, but the IN-focus
1627
+ // group sat at its baseline appearance — no positive emphasis.
1628
+ // Hovering now upgrades the box to an "accent" treatment:
1629
+ // solid stroke (not dashed), thicker, accent-coloured; brighter
1630
+ // text and slightly stronger fill. Label and box read as one
1631
+ // selected unit. Geometry unchanged → overlap test untouched.
1632
+ return (
1633
+ <g
1634
+ key={`grp-${box.key}`}
1635
+ data-group={box.key}
1636
+ className="transition-opacity"
1637
+ // R63: drop the blanket pointerEvents:'none' that
1638
+ // previously sat here. Chrome's SVG impl doesn't let a
1639
+ // child override a parent's `none` even though the spec
1640
+ // says it should — moving the property onto just the
1641
+ // rect (where it's needed so nodes underneath stay
1642
+ // clickable) lets the label text receive its own click.
1643
+ style={{ opacity: !activeGroup || isHovered ? 1 : 0.28 }}
978
1644
  >
979
- {box.key}
980
- </text>
981
- </g>
982
- ))}
1645
+ <rect
1646
+ x={box.x}
1647
+ y={box.y}
1648
+ width={box.w}
1649
+ height={box.h}
1650
+ rx="14"
1651
+ fill={isLight ? '#0f172a' : '#a5b4fc'}
1652
+ // R68: 3-tier opacity + stroke ladder.
1653
+ // pinned → fill 0.08 / 0.13, stroke 3 px (locked)
1654
+ // hovered → fill 0.05 / 0.09, stroke 2 px (inspecting)
1655
+ // idle → fill 0.025 / 0.045, stroke 1.5 px dashed
1656
+ fillOpacity={isPinned ? (isLight ? 0.08 : 0.13)
1657
+ : isHovered ? (isLight ? 0.05 : 0.09)
1658
+ : (isLight ? 0.025 : 0.045)}
1659
+ stroke={(isPinned || isHovered) ? pal.legendAccent : pal.ringStroke}
1660
+ strokeWidth={isPinned ? 3 : isHovered ? 2 : 1.5}
1661
+ strokeDasharray={(isPinned || isHovered) ? 'none' : '6 6'}
1662
+ data-group-box-pinned={isPinned ? 'true' : 'false'}
1663
+ style={{ transition: 'stroke 200ms ease-out, stroke-width 200ms ease-out, fill-opacity 200ms ease-out', pointerEvents: 'none' }}
1664
+ />
1665
+ {/* R63: wrap label in a clickable <g> with an invisible
1666
+ rect hitbox. The text alone wasn't getting hit-tested
1667
+ reliably — the SVG-wide topo-panel <rect> intercepts
1668
+ at the label's screen position when the label sits at
1669
+ a high viewBox-y (it lands below where the compositor
1670
+ expects the text to paint on top, same gotcha as the
1671
+ recent-signal panel rows in R56). Hitbox rect + the
1672
+ parent <g> taking the click + onPointerDown stop-
1673
+ propagation match the R55/R56/R61 pattern. */}
1674
+ <g
1675
+ role="button"
1676
+ tabIndex={0}
1677
+ aria-pressed={pinnedGroup === box.key}
1678
+ data-group-label-hit={box.key}
1679
+ style={{ pointerEvents: 'all', cursor: 'pointer' }}
1680
+ onPointerDown={(e) => e.stopPropagation()}
1681
+ onClick={() => setPinnedGroup(prev => prev === box.key ? null : box.key)}
1682
+ >
1683
+ <rect
1684
+ x={box.x + 6}
1685
+ y={box.y + 2}
1686
+ width={Math.min(box.w - 12, 240)}
1687
+ height={20}
1688
+ fill="transparent"
1689
+ />
1690
+ <text
1691
+ x={box.x + 12}
1692
+ y={box.y + 14}
1693
+ fill={isHovered ? pal.legendHeadline : pal.legendText}
1694
+ fontSize="13"
1695
+ fontFamily="monospace"
1696
+ fontWeight="700"
1697
+ style={{ transition: 'fill 200ms ease-out' }}
1698
+ data-group-label={box.key}
1699
+ >
1700
+ {box.key}
1701
+ {/* Round 19 / Loop: member-count chip. Inline tspan stays
1702
+ in the single <text> bbox the overlap test reads, so
1703
+ the node↔label guard still catches if the chip ever
1704
+ pushes the label far enough right to clip a node.
1705
+ Smaller + lighter weight reads as metadata, not name. */}
1706
+ <tspan dx="6" fill={pal.legendText} fontSize="11" fontWeight="400">· {box.count}</tspan>
1707
+ {/* Round 58 / Loop: status mix pip strip. Compact text-
1708
+ based chips (e.g. "2w 1i") so the strip stays inside
1709
+ the same <text> bbox the overlap-test reads — keeps
1710
+ the R27 label↔label and R19 node↔label guards intact.
1711
+ Each tier is colour-coded against the legend swatches
1712
+ and only renders when count > 0, so a healthy all-
1713
+ working group reads simply " · 2w". */}
1714
+ {box.statuses.working > 0 && (
1715
+ <tspan dx="8" fill={isLight ? '#059669' : '#22c55e'} fontSize="11" fontWeight="600">{box.statuses.working}w</tspan>
1716
+ )}
1717
+ {box.statuses.idle > 0 && (
1718
+ <tspan dx="4" fill={isLight ? '#0d9488' : '#2dd4bf'} fontSize="11" fontWeight="600">{box.statuses.idle}i</tspan>
1719
+ )}
1720
+ {box.statuses.offline > 0 && (
1721
+ <tspan dx="4" fill={isLight ? '#94a3b8' : '#6b7280'} fontSize="11" fontWeight="600">{box.statuses.offline}o</tspan>
1722
+ )}
1723
+ </text>
1724
+ </g>
1725
+ </g>
1726
+ );
1727
+ })}
983
1728
 
984
1729
  {/* directed message flows */}
985
1730
  {flowLinks.map((link, index) => {
@@ -987,21 +1732,116 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
987
1732
  const to = nodePositions[link.to];
988
1733
  if (!from || !to) return null;
989
1734
 
990
- const lift = index % 2 === 0 ? 36 : -36;
1735
+ // Round 7 / Loop: lift now scales with distance so short links
1736
+ // aren't over-bent (long links keep the ~36px hump), and the
1737
+ // particle period shortens with link.count so busier edges
1738
+ // visibly pulse faster — instant info density on top of the
1739
+ // existing stroke-width-by-count chip.
1740
+ const dist = Math.hypot(to.x - from.x, to.y - from.y);
1741
+ const lift = (index % 2 === 0 ? 1 : -1) * Math.min(36, dist * 0.18);
991
1742
  const path = curvePath(from, to, lift);
992
1743
  const width = Math.min(2 + link.count, 7);
993
- const duration = 2.2 + (index % 5) * 0.32;
994
-
1744
+ const duration = Math.max(0.9, 2.6 / Math.sqrt(link.count));
1745
+ // Round 10 / Loop: freshness fade. An edge that fired ≤30s ago
1746
+ // stays at full intensity; over 5 minutes it decays to ~35%.
1747
+ // Surfaces "what's happening now" vs background chatter without
1748
+ // hiding old flow entirely (some context still useful). `now`
1749
+ // captured at useMemo-recompute time (every 5s message refresh)
1750
+ // — accuracy is within the poll interval, plenty.
1751
+ const ageMs = link.last_at ? Math.max(0, Date.now() - Date.parse(link.last_at)) : 0;
1752
+ const fresh = Math.max(0.35, 1 - ageMs / (5 * 60 * 1000));
1753
+ // Round 16 arrow-tier binning — keep `topo-arrow` as the
1754
+ // medium tier id so the legend swatch picks it up unchanged.
1755
+ const arrowId = link.count <= 2 ? 'topo-arrow-s'
1756
+ : link.count <= 4 ? 'topo-arrow'
1757
+ : 'topo-arrow-l';
1758
+
1759
+ // Round 39 / Loop: edge hover tooltip — surface the same
1760
+ // last_at + count info the freshness fade and arrow tier
1761
+ // already encode visually, in plain text. The stroke is the
1762
+ // hover target; SVG `<title>` honours newlines on every
1763
+ // browser the dashboard targets.
1764
+ const lastAt = relativeAgo(link.last_at);
1765
+ const tooltip = `${link.from} → ${link.to}\n${link.count} message${link.count === 1 ? '' : 's'}${lastAt ? ` · last ${lastAt}` : ''}`;
1766
+ // Round 40 / Loop: edges follow node hover — when an alias is
1767
+ // hovered, every edge touching it brightens, the rest fade.
1768
+ // Pairs with the Round 8 group-focus fade on nodes: hover a
1769
+ // node to find "who is this agent talking to" at a glance.
1770
+ // No hover → multiplier is 1.0 (current behaviour preserved).
1771
+ // Round 50 / Loop: edge-on-self priority. When the user hovers
1772
+ // a flow edge directly (R48 widened the hitbox so this is now
1773
+ // a precise gesture), THAT edge gets the strongest boost (2.0)
1774
+ // and every other edge dims to 0.35. Node-hover (R40) keeps
1775
+ // its own ladder. Edge-hover and node-hover are mutually
1776
+ // exclusive in practice — the cursor is over one or the
1777
+ // other — but the order below makes edge-hover win if both
1778
+ // ever read truthy at the same React tick.
1779
+ // Round 53 / Loop: in-group edges follow team focus. R40
1780
+ // brightened only edges touching the exact hovered alias —
1781
+ // but with R8/R18 prefix-clustering, a user hovering one
1782
+ // member is asking about the team. So when BOTH endpoints of
1783
+ // an edge share the hoveredGroup (and neither is the exact
1784
+ // hovered alias — that gets the stronger 1.7×), the edge
1785
+ // boosts to 1.3×. Edges leaving the team (one endpoint in,
1786
+ // one out) still dim to 0.35× per R40 since they read as
1787
+ // background to the focus. Singletons fall through unchanged
1788
+ // (their group key is the alias itself, so bothInHoveredGroup
1789
+ // is impossible for a non-self-edge).
1790
+ const isHoveredEdge = hoveredEdgeKey === link.key;
1791
+ const fromGroup = groupKeys[link.from] ?? link.from;
1792
+ const toGroup = groupKeys[link.to] ?? link.to;
1793
+ const bothInHoveredGroup = !!activeGroup && fromGroup === activeGroup && toGroup === activeGroup;
1794
+ // R63: also gate "no filter" on activeGroup so pinnedGroup
1795
+ // alone activates the in-group 1.3× boost + non-group dim.
1796
+ // When neither a node nor a group is in focus, mul is 1.
1797
+ const edgeOpacityMul = isHoveredEdge
1798
+ ? 2.0
1799
+ : hoveredEdgeKey
1800
+ ? 0.35
1801
+ : !hoveredAlias && !activeGroup
1802
+ ? 1
1803
+ : (link.from === hoveredAlias || link.to === hoveredAlias)
1804
+ ? 1.7
1805
+ : bothInHoveredGroup
1806
+ ? 1.3
1807
+ : 0.35;
1808
+ // Round 50: the hovered edge also visibly thickens so the eye
1809
+ // tracks it even at low message counts (width was 3 → 4.5 on
1810
+ // hover). 1.4× is enough to read as "lifted" without
1811
+ // breaching the 16-px hitbox bound.
1812
+ const renderWidth = isHoveredEdge ? Math.min(width * 1.4, 10) : width;
995
1813
  return (
996
1814
  <g key={link.key}>
1815
+ {/* Round 48 / Loop: invisible hover hitbox — visible flow
1816
+ path is 3-7 px wide and damn hard to hover precisely.
1817
+ Stack a transparent 16-px-wide stroke behind it so the
1818
+ cursor only needs to be ~8 px from the line for the
1819
+ tooltip to fire. Native <title> moves here; the
1820
+ visible path no longer needs it. pointer-events on
1821
+ the visible path drop to "none" since the hitbox
1822
+ owns the hover surface. */}
1823
+ <path
1824
+ d={path}
1825
+ fill="none"
1826
+ stroke="transparent"
1827
+ strokeWidth={Math.max(width + 10, 16)}
1828
+ style={{ pointerEvents: 'stroke' }}
1829
+ data-edge-hitbox
1830
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
1831
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
1832
+ >
1833
+ <title>{tooltip}</title>
1834
+ </path>
997
1835
  <path
998
1836
  d={path}
999
1837
  fill="none"
1000
1838
  stroke={pal.flowEdge}
1001
- strokeWidth={width}
1002
- opacity={isLight ? 0.22 : 0.28}
1839
+ strokeWidth={renderWidth}
1840
+ opacity={Math.min(1, (isLight ? 0.22 : 0.28) * fresh * edgeOpacityMul)}
1003
1841
  filter={isLight ? undefined : 'url(#topo-glow)'}
1004
- markerEnd="url(#topo-arrow)"
1842
+ markerEnd={`url(#${arrowId})`}
1843
+ className="transition-opacity duration-300"
1844
+ style={{ pointerEvents: 'none' }}
1005
1845
  />
1006
1846
  <path
1007
1847
  id={`flow-path-${index}`}
@@ -1010,37 +1850,75 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1010
1850
  stroke={pal.flowPath}
1011
1851
  strokeWidth="1"
1012
1852
  strokeDasharray="2 12"
1013
- opacity={isLight ? 0.4 : 0.75}
1853
+ opacity={Math.min(1, (isLight ? 0.4 : 0.75) * fresh * edgeOpacityMul)}
1854
+ className="transition-opacity duration-300"
1014
1855
  />
1015
- <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'}>
1016
- <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
1017
- </circle>
1856
+ {!reducedMotion && (
1857
+ <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'} opacity={Math.min(1, fresh * edgeOpacityMul)}>
1858
+ <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
1859
+ </circle>
1860
+ )}
1018
1861
  </g>
1019
1862
  );
1020
1863
  })}
1021
1864
 
1022
- {/* center hub — round 39: enlarged from r=6 (12px) to r=10 (20px)
1023
- core so the "control plane" reads as the network's anchor,
1024
- not a stray particle. Static halo ring at r=18 grounds it
1025
- visually; two outward pulses keep the "signal source" idea
1026
- from r17. Light theme gets a paler core to avoid hot-spotting
1027
- the bg. */}
1028
- {layout === 'ring' && (<g>
1029
- {/* static grounding halo sits underneath, low opacity */}
1865
+ {/* center hub — round 39 sized + round 13 restraint.
1866
+ The hub is the control-plane anchor. r39 gave it two outward
1867
+ pulses (r 10→38, 2.4s, double-phase) which read as "loudest
1868
+ node in the room" wrong semantics: anchors don't emit,
1869
+ they hold. r13 swaps the kinetic pulse for a slow opacity
1870
+ breath on the static halo (4s, ±15% from base), so the hub
1871
+ stays alive without throwing kinetic energy outward. The
1872
+ dual-circle "lit lamp" core is unchanged. */}
1873
+ {layout === 'ring' && (<g
1874
+ data-topo-hub
1875
+ style={{ cursor: 'pointer' }}
1876
+ // Stop pointerdown from reaching the SVG pan handler — same
1877
+ // reason as the node <g>: a captured pointer makes Chromium
1878
+ // fire the follow-up click on the SVG, not this group.
1879
+ onPointerDown={(e) => e.stopPropagation()}
1880
+ // Round 52 / Loop: hub is the visual anchor but had no click
1881
+ // action — users discovered fit-to-content only via the `f`
1882
+ // key or the chrome button. Wire the hub to fitView() so the
1883
+ // most prominent element in the canvas is also the "re-
1884
+ // center" affordance. A click-ripple keyed by ts confirms
1885
+ // the gesture; the hub <title> now ends with a hint.
1886
+ onClick={() => {
1887
+ fitView();
1888
+ setClickRipple({ ts: Date.now(), x: cx, y: cy, r0: 18, color: isLight ? '#059669' : '#10b981' });
1889
+ setTimeout(() => setClickRipple(prev => prev && prev.x === cx && prev.y === cy ? null : prev), 600);
1890
+ }}
1891
+ >
1892
+ {/* Round 43 / Loop: hub `<title>` summary — hovering the
1893
+ central glow now answers "what is this?" with a one-line
1894
+ fleet snapshot. Duplicates the header chips by design;
1895
+ hovering the most visually prominent element is the
1896
+ most natural impulse, so satisfy it where the cursor
1897
+ already is. Falls clean: omits sub-clauses when their
1898
+ counts are zero. R52 appends a "click to fit view" hint.
1899
+ */}
1900
+ <title>{(() => {
1901
+ const total = sessions.length;
1902
+ const parts = [`Network hub`, `${total} session${total === 1 ? '' : 's'}`];
1903
+ if (onlineNodes.length > 0) parts.push(`${onlineNodes.length} online`);
1904
+ if (workingCount > 0) parts.push(`${workingCount} working`);
1905
+ if (flowLinks.length > 0) parts.push(`${flowLinks.length} active link${flowLinks.length === 1 ? '' : 's'}`);
1906
+ return parts.join(' · ') + '\nclick to fit view';
1907
+ })()}</title>
1908
+ {/* grounding halo — now breathes in opacity, no expansion */}
1030
1909
  <circle
1031
1910
  cx={cx} cy={cy} r="18"
1032
1911
  fill={isLight ? '#d1fae5' : '#10b981'}
1033
- opacity={isLight ? 0.35 : 0.10}
1034
- />
1035
- {/* outer pulse 1 */}
1036
- <circle cx={cx} cy={cy} r="10" fill="none" stroke="#10b981" strokeWidth="1.5">
1037
- <animate attributeName="r" values="10;38;10" dur="2.4s" repeatCount="indefinite" />
1038
- <animate attributeName="opacity" values="0.55;0;0.55" dur="2.4s" repeatCount="indefinite" />
1039
- </circle>
1040
- {/* outer pulse 2 (delayed 1.2s) */}
1041
- <circle cx={cx} cy={cy} r="10" fill="none" stroke="#10b981" strokeWidth="1.5" opacity="0">
1042
- <animate attributeName="r" values="10;38;10" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
1043
- <animate attributeName="opacity" values="0.45;0;0.45" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
1912
+ opacity={isLight ? 0.42 : 0.12}
1913
+ >
1914
+ {!reducedMotion && (
1915
+ <animate
1916
+ attributeName="opacity"
1917
+ values={isLight ? '0.32;0.52;0.32' : '0.08;0.16;0.08'}
1918
+ dur="4s"
1919
+ repeatCount="indefinite"
1920
+ />
1921
+ )}
1044
1922
  </circle>
1045
1923
  {/* core — 20px diameter, larger inner highlight reads as a "lit lamp" */}
1046
1924
  <circle cx={cx} cy={cy} r="10" fill={isLight ? '#059669' : '#10b981'} />
@@ -1048,7 +1926,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1048
1926
  </g>)}
1049
1927
 
1050
1928
  {/* agent nodes */}
1051
- {[...onlineNodes, ...offlineNodes].map(session => {
1929
+ {[...onlineNodes, ...offlineNodes].map((session, nodeIdx) => {
1052
1930
  const pos = nodePositions[session.alias];
1053
1931
  if (!pos) return null;
1054
1932
 
@@ -1068,13 +1946,105 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1068
1946
  // occlude an avatar), and the full card appears only for the
1069
1947
  // hovered node or once zoomed in past 1.4×.
1070
1948
  const showFullLabel = !denseLayout || hoveredAlias === session.alias || view.zoom >= 1.4;
1949
+ // Round 8: when a node in another group is hovered, fade this
1950
+ // one. Same-group nodes (incl. singletons matching the hovered
1951
+ // alias) stay full. Pure visual focus, geometry unchanged.
1952
+ const inFocus = !activeGroup || (groupKeys[session.alias] ?? session.alias) === activeGroup;
1953
+ // R72: in ring layout, classify the node into a tier by its
1954
+ // distance from hub centre so the first-paint stagger can
1955
+ // emanate inner → outer instead of running clockwise. Grid
1956
+ // layout doesn't have a radial structure; it keeps R9's pure
1957
+ // nodeIdx stagger. Thresholds bracket the actual tier radii
1958
+ // (single 220, dual 175/260, triple 145/215/285, offline 325+).
1959
+ let tierIdx = 0;
1960
+ if (layout === 'ring') {
1961
+ const p = nodePositions[session.alias];
1962
+ if (p) {
1963
+ const d = Math.hypot(p.x - cx, p.y - cy);
1964
+ tierIdx = d < 195 ? 0 : d < 270 ? 1 : d < 310 ? 2 : 3;
1965
+ }
1966
+ }
1071
1967
 
1072
1968
  return (
1073
1969
  <g
1074
1970
  key={session.alias}
1075
1971
  data-node={session.alias}
1076
- className="transition-opacity"
1077
- style={{ cursor: 'pointer' }}
1972
+ data-tier-idx={layout === 'ring' ? tierIdx : -1}
1973
+ // Round 3 / Loop: `anet-fade-in` runs once when the <g>
1974
+ // mounts — a new session entering the fleet (or the topology
1975
+ // first rendering) eases in instead of popping. Re-renders of
1976
+ // an existing node don't re-trigger (React preserves the <g>
1977
+ // via the alias key), so status changes don't flicker. The
1978
+ // global prefers-reduced-motion sweep already neutralises it.
1979
+ className="group transition-opacity anet-fade-in"
1980
+ style={{
1981
+ cursor: 'pointer',
1982
+ // Round 17 / Loop: offline nodes drop to 0.6 at rest so
1983
+ // online nodes pop without losing the offline-as-ghost
1984
+ // information. The dashed stroke + smaller radius already
1985
+ // say "offline"; dimming the whole group strengthens the
1986
+ // online-vs-offline hierarchy at first glance. Exempt the
1987
+ // chat-focused node — if the user explicitly opened a
1988
+ // popover targeting an offline alias, that node stays
1989
+ // full-brightness so the focus ring + popover read as one
1990
+ // selected thing rather than a dimmed selection. Group-
1991
+ // hover fade (Round 8) still wins when inFocus is false.
1992
+ // Round 49 / Loop: edge-hover endpoint highlight composes
1993
+ // OVER the inFocus/online formula — a non-endpoint node
1994
+ // dims to 0.28 (just below the inFocus 0.32 to read as a
1995
+ // stronger "not relevant" signal), endpoints keep their
1996
+ // base opacity. chatAlias still wins to keep the focus
1997
+ // ring legible if the user clicked through.
1998
+ // Round 55 / Loop: legend-status hover composes on the
1999
+ // SAME level as edge-hover-endpoint. Non-matching nodes
2000
+ // dim to 0.28; matching nodes stay at their base.
2001
+ // activeStatus matches: working = status==='working',
2002
+ // idle = online but not working, offline = !isOnline.
2003
+ // Round 60 / Loop: activeStatus = hoveredStatus ?? pinnedStatus
2004
+ // so the pressure-bar segment pins (R60) and the legend
2005
+ // row hover (R55) feed the same branch.
2006
+ opacity: hoveredEdgeEndpoints && !hoveredEdgeEndpoints.has(session.alias) && chatAlias !== session.alias
2007
+ ? 0.28
2008
+ : activeStatus && chatAlias !== session.alias && !(
2009
+ activeStatus === 'working' ? session.status === 'working'
2010
+ : activeStatus === 'idle' ? (isOnline && session.status !== 'working')
2011
+ : /* offline */ !isOnline
2012
+ )
2013
+ ? 0.28
2014
+ : !inFocus
2015
+ ? 0.32
2016
+ : chatAlias === session.alias
2017
+ ? 1
2018
+ : isOnline ? 1 : 0.6,
2019
+ // Round 9 / Loop: stagger the anet-fade-in so the topology
2020
+ // reveals as a wave on first paint instead of one big pop.
2021
+ // Cap at 24 indices (≈600ms tail) so 50-node fleets still
2022
+ // finish revealing within a beat. CSS animation-delay only
2023
+ // applies during the keyframe — re-renders without a new
2024
+ // mount (same alias key) don't replay, so status changes
2025
+ // never trigger the stagger again.
2026
+ // Round 72 / Loop: in ring layout, stagger by tier radius
2027
+ // so the topology emanates from the hub outward — inner
2028
+ // tier at 0ms, middle at ~180ms, outer at ~360ms, offline
2029
+ // at ~540ms. A small within-tier offset (nodeIdx % 6 * 25)
2030
+ // adds variety so each ring rotates in instead of all-at-
2031
+ // once popping. Grid keeps R9's pure nodeIdx stagger.
2032
+ animationDelay: layout === 'ring'
2033
+ ? `${tierIdx * 180 + (nodeIdx % 6) * 25}ms`
2034
+ : `${Math.min(nodeIdx, 24) * 25}ms`,
2035
+ // Round 51 / Loop: hover micro-lift. The label already
2036
+ // lifts on group-hover (R26). The node body — circle,
2037
+ // avatar, status ring — stays planted, so the gesture
2038
+ // reads "label moves, body doesn't". Now the whole <g>
2039
+ // translates -2px when hovered: avatar + ring + label
2040
+ // move as one unit. 2 px is well inside the inter-row
2041
+ // gap (cellH headroom ≥22), and CSS transform on SVG
2042
+ // <g> never affects the overlap-test geometry (the
2043
+ // test never simulates hover). useReducedMotion drops
2044
+ // the lift to 0 for prefers-reduced-motion users.
2045
+ transform: !reducedMotion && hoveredAlias === session.alias ? 'translateY(-2px)' : undefined,
2046
+ transition: 'transform 180ms cubic-bezier(0.4,0,0.2,1)',
2047
+ }}
1078
2048
  // Stop the pointerdown from reaching the SVG pan handler: the
1079
2049
  // SVG calls setPointerCapture, and a captured pointer makes
1080
2050
  // Chromium fire the follow-up `click` on the SVG instead of
@@ -1082,21 +2052,106 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1082
2052
  // Side effect (intended): you pan from empty canvas, not by
1083
2053
  // grabbing a node.
1084
2054
  onPointerDown={(e) => e.stopPropagation()}
1085
- onPointerEnter={() => denseLayout && setHoveredAlias(session.alias)}
1086
- onPointerLeave={() => denseLayout && setHoveredAlias(prev => (prev === session.alias ? null : prev))}
1087
- onClick={() => setChatAlias(session.alias)}
2055
+ // Track hover globally (not just under denseLayout) so the
2056
+ // Round 8 group-focus fade works at any fleet size.
2057
+ onPointerEnter={() => setHoveredAlias(session.alias)}
2058
+ onPointerLeave={() => setHoveredAlias(prev => (prev === session.alias ? null : prev))}
2059
+ onClick={() => {
2060
+ setChatAlias(session.alias);
2061
+ // Round 14 ripple — capture position, radius and status
2062
+ // colour at click time so the one-shot circle is self-
2063
+ // contained (no re-render-time recomputation needed).
2064
+ setClickRipple({
2065
+ ts: Date.now(),
2066
+ x: pos.x, y: pos.y, r0: radius,
2067
+ color: status.primary,
2068
+ });
2069
+ setTimeout(() => setClickRipple(prev =>
2070
+ prev && Date.now() - prev.ts >= 590 ? null : prev), 600);
2071
+ }}
1088
2072
  >
1089
2073
  {/* Issue #96: native hover tooltip — "Vendor · model · Runtime".
1090
2074
  Falls back to just the alias when the node reports no
1091
- model/runtime. */}
1092
- <title>{identityLine(session.model, session.runtime) || session.alias}</title>
1093
- {isActive && (
2075
+ model/runtime.
2076
+ Round 33 / Loop: cwd line answers "what is this agent on?".
2077
+ Round 34 / Loop: for offline nodes, append "last seen: 6m ago"
2078
+ so the operator knows whether to wait or chase. Online nodes
2079
+ skip the line (Round 27's 1 h ghost age-out means anything
2080
+ still online has heartbeated recently — the line would just
2081
+ be noise). Accepts both ISO ("…T06:00:28Z") and SQL-style
2082
+ ("… 06:00:28" assumed UTC) formats. */}
2083
+ {(() => {
2084
+ // Round 35 / Loop: TZ-safe parsing via the shared lib helper
2085
+ // — same parseHubTime is used by isGhost above (Round 38
2086
+ // factored it to app/lib/time.ts so both paths interpret SQL
2087
+ // bare timestamps as UTC on every browser).
2088
+ const lastSeen = !isOnline && session.last_seen_at ? relativeAgo(session.last_seen_at) : null;
2089
+ return (
2090
+ <title>{[
2091
+ identityLine(session.model, session.runtime) || session.alias,
2092
+ session.project_dir ? `cwd: ${session.project_dir}` : null,
2093
+ lastSeen ? `last seen: ${lastSeen}` : null,
2094
+ ].filter(Boolean).join('\n')}</title>
2095
+ );
2096
+ })()}
2097
+ {/* Round 2 / Loop: hover ring — a thin outer stroke that fades
2098
+ in when the cursor enters the node, signalling clickability
2099
+ (real-user feedback for the chat-popover open). Pure CSS via
2100
+ Tailwind group-hover, so it costs nothing per frame and
2101
+ respects prefers-reduced-motion via the global media query. */}
2102
+ <circle
2103
+ cx={pos.x}
2104
+ cy={pos.y}
2105
+ r={radius + 12}
2106
+ fill="none"
2107
+ stroke={status.primary}
2108
+ // strokeWidth must NOT be 1.5 (offline status ring) or 3
2109
+ // (online status ring) — the overlap test selects by those
2110
+ // exact widths and would mis-count this invisible hover
2111
+ // ring as a node footprint.
2112
+ strokeWidth="2"
2113
+ className="opacity-0 group-hover:opacity-70 transition-opacity duration-150"
2114
+ style={{ pointerEvents: 'none' }}
2115
+ />
2116
+ {/* Round 11 / Loop: chat-focus ring — when the ChatPopover is
2117
+ open targeting this node, anchor a persistent ring around
2118
+ it so the floating popover visibly links back to its source
2119
+ node. Static (not pulsing) so it reads as "selected state"
2120
+ rather than "this node is active". strokeWidth=2.5 stays
2121
+ clear of the overlap-test selectors (1.5 / 3). Sits just
2122
+ outside the halo radius+8 so it never overlaps a neighbour
2123
+ (halos already pack flush in dense grids). */}
2124
+ {chatAlias === session.alias && (
2125
+ <circle
2126
+ cx={pos.x}
2127
+ cy={pos.y}
2128
+ r={radius + 14}
2129
+ fill="none"
2130
+ stroke={status.primary}
2131
+ strokeWidth="2.5"
2132
+ opacity={isLight ? 0.85 : 0.95}
2133
+ filter={!isLight ? 'url(#topo-glow)' : undefined}
2134
+ className="transition-opacity duration-200"
2135
+ style={{ pointerEvents: 'none' }}
2136
+ />
2137
+ )}
2138
+ {isActive && !reducedMotion && (
1094
2139
  <circle cx={pos.x} cy={pos.y} r={radius + 14} fill={status.primary} opacity={isLight ? 0.08 : 0.12}>
1095
2140
  <animate attributeName="r" values={`${radius + 8};${radius + 22};${radius + 8}`} dur="2.4s" repeatCount="indefinite" />
1096
2141
  <animate attributeName="opacity" values={isLight ? '0.12;0.02;0.12' : '0.18;0.04;0.18'} dur="2.4s" repeatCount="indefinite" />
1097
2142
  </circle>
1098
2143
  )}
1099
- <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)} />
2144
+ {/* Round 4 / Loop: transition-[fill,stroke,opacity] smooths
2145
+ status colour changes so idle↔working↔offline doesn't snap
2146
+ — task replies / node-rename / SSE updates ease in. */}
2147
+ <circle
2148
+ cx={pos.x}
2149
+ cy={pos.y}
2150
+ r={radius + 8}
2151
+ fill={status.halo}
2152
+ opacity={isOnline ? (isLight ? 0.85 : 0.65) : (isLight ? 0.4 : 0.25)}
2153
+ className="transition-[fill,opacity] duration-300 ease-out"
2154
+ />
1100
2155
  <circle
1101
2156
  cx={pos.x}
1102
2157
  cy={pos.y}
@@ -1106,6 +2161,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1106
2161
  strokeWidth={isOnline ? 3 : 1.5}
1107
2162
  strokeDasharray={isOnline ? 'none' : '5 5'}
1108
2163
  filter={isOnline && !isLight ? 'url(#topo-glow)' : undefined}
2164
+ className="transition-[fill,stroke] duration-300 ease-out"
1109
2165
  />
1110
2166
  {/* Issue #96: node "avatar" is now driven by the model
1111
2167
  vendor. Decision order:
@@ -1192,84 +2248,314 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1192
2248
  </g>
1193
2249
  );
1194
2250
  })()}
1195
- {session.status === 'working' && (
1196
- // working pulse small dot just inside the status ring,
1197
- // above the avatar text. Was a 18px horizontal bar at
1198
- // y+11 which now collides with the avatar; the dot reads
1199
- // as the same "active" cue without occluding the initial.
1200
- <circle cx={pos.x} cy={pos.y - (radius - 6)} r="2.5" fill={pal.flowParticle}>
1201
- <animate attributeName="opacity" values="1;0.25;1" dur="1.1s" repeatCount="indefinite" />
1202
- </circle>
1203
- )}
2251
+ {session.status === 'working' && (() => {
2252
+ // Round 24 / Loop: pulse rate traffic. The dot's dur
2253
+ // was a flat 1.1s every working node looked equally
2254
+ // busy. Mapping it to sse:N lets the eye land on what's
2255
+ // actually hot. 3 discrete tiers read cleaner than
2256
+ // continuous easing at a glance; thresholds picked so
2257
+ // typical 1-2-SSE Claude/Codex sessions stay in the
2258
+ // "active" tier and only multi-pane orchestrators pop
2259
+ // into the "busy" tier. Geometry unchanged (r=2.5).
2260
+ const sse = sseCountFor ?? 0;
2261
+ const dur = sse >= 4 ? '0.7s' : sse >= 2 ? '0.9s' : '1.2s';
2262
+ return (
2263
+ <circle cx={pos.x} cy={pos.y - (radius - 6)} r="2.5" fill={pal.flowParticle} data-pulse-dur={dur} opacity={reducedMotion ? 0.6 : undefined}>
2264
+ {!reducedMotion && (
2265
+ <animate attributeName="opacity" values="1;0.25;1" dur={dur} repeatCount="indefinite" />
2266
+ )}
2267
+ </circle>
2268
+ );
2269
+ })()}
1204
2270
 
1205
2271
  {/* Round 98 (issue #61): label rect 124px → 100px.
1206
2272
  Round 109/110 (Vincent P0): full opaque card below the
1207
2273
  density threshold / on hover / when zoomed; otherwise a
1208
2274
  lightweight plain-text alias that keeps every node
1209
- labelled without an opaque box covering its neighbours. */}
1210
- {showFullLabel ? (
1211
- <g transform={`translate(${pos.x}, ${pos.y + radius + 22})`} style={{ pointerEvents: 'none' }}>
1212
- <rect x="-50" y="-14" width="100" height="42" rx="6" fill={pal.labelBox.fill} stroke={pal.labelBox.stroke} opacity={isLight ? 1 : 0.94} />
1213
- <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize="12" fontFamily="monospace" fontWeight="700">
1214
- {truncate(session.alias, 12)}
1215
- </text>
1216
- <text x="0" y="17" textAnchor="middle" fill={status.primary} fontSize="9" fontFamily="monospace">
1217
- {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
2275
+ labelled without an opaque box covering its neighbours.
2276
+ Round 15 / Loop: when nodeScale=S the node shrinks 30%
2277
+ but the label card was staying full-size, so the label
2278
+ visually outweighed the small node. Tighten the card
2279
+ frame, alias / sub fontSize, drop-offset and truncate
2280
+ length specifically for S; M and L keep their existing
2281
+ sizes (M ≈ L for labels by design — the S user is the
2282
+ one who explicitly asked for a denser view). */}
2283
+ {(() => {
2284
+ const isSmall = nodeScale < 0.8;
2285
+ const cardW = isSmall ? 88 : 100;
2286
+ const cardH = isSmall ? 36 : 42;
2287
+ const cardTopY = isSmall ? -12 : -14;
2288
+ const aliasFs = isSmall ? 11 : 12;
2289
+ const subFs = isSmall ? 8 : 9;
2290
+ const subY = isSmall ? 15 : 17;
2291
+ const dropY = isSmall ? 18 : 22;
2292
+ const fullMax = isSmall ? 11 : 12;
2293
+ const denseFs = isSmall ? 9 : 10;
2294
+ const denseDrop = isSmall ? 12 : 14;
2295
+ // Round 26 / Loop: micro-lift the label group on hover —
2296
+ // 1.5 px upward, 200 ms ease. Pairs with the Round 18
2297
+ // group-box hover-accent treatment to give the same
2298
+ // "this is the focused element" feedback at the per-
2299
+ // node level. CSS transform stacks onto the SVG
2300
+ // positioning transform attribute (SVG 2 cascade);
2301
+ // bbox is unchanged at rest, so the overlap-test gate
2302
+ // continues to see the geometric layout it expects.
2303
+ return showFullLabel ? (
2304
+ <g transform={`translate(${pos.x}, ${pos.y + radius + dropY})`} style={{ pointerEvents: 'none' }}
2305
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]">
2306
+ <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} />
2307
+ <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize={aliasFs} fontFamily="monospace" fontWeight="700">
2308
+ {truncate(session.alias, fullMax)}
2309
+ </text>
2310
+ <text x="0" y={subY} textAnchor="middle" fill={status.primary} fontSize={subFs} fontFamily="monospace">
2311
+ {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
2312
+ </text>
2313
+ </g>
2314
+ ) : (
2315
+ <text
2316
+ x={pos.x}
2317
+ y={pos.y + radius + denseDrop}
2318
+ textAnchor="middle"
2319
+ fill={status.text}
2320
+ fontSize={denseFs}
2321
+ fontFamily="monospace"
2322
+ fontWeight="700"
2323
+ opacity={0.9}
2324
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]"
2325
+ style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
2326
+ stroke={pal.containerBg}
2327
+ strokeWidth="3"
2328
+ >
2329
+ {truncate(session.alias, isSmall ? 9 : 10)}
1218
2330
  </text>
1219
- </g>
1220
- ) : (
1221
- <text
1222
- x={pos.x}
1223
- y={pos.y + radius + 14}
1224
- textAnchor="middle"
1225
- fill={status.text}
1226
- fontSize="10"
1227
- fontFamily="monospace"
1228
- fontWeight="700"
1229
- opacity={0.9}
1230
- style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
1231
- stroke={pal.containerBg}
1232
- strokeWidth="3"
1233
- >
1234
- {truncate(session.alias, 10)}
1235
- </text>
1236
- )}
2331
+ );
2332
+ })()}
1237
2333
  </g>
1238
2334
  );
1239
2335
  })}
1240
2336
 
2337
+ {/* Round 14 / Loop: click ripple — one-shot expanding ring from
2338
+ the clicked node, ~500ms, status-coloured. Sits inside the
2339
+ zoom/pan <g> so it scales / pans with the topology. Keyed by
2340
+ ts so a re-click on any node (same or different) remounts the
2341
+ <circle> and the SMIL <animate> elements replay from t=0.
2342
+ strokeWidth=2 doesn't match the overlap-test selectors. */}
2343
+ {clickRipple && !reducedMotion && (
2344
+ <circle
2345
+ key={clickRipple.ts}
2346
+ cx={clickRipple.x}
2347
+ cy={clickRipple.y}
2348
+ r={clickRipple.r0 + 4}
2349
+ fill="none"
2350
+ stroke={clickRipple.color}
2351
+ strokeWidth="2"
2352
+ opacity="0"
2353
+ style={{ pointerEvents: 'none' }}
2354
+ >
2355
+ <animate attributeName="r" values={`${clickRipple.r0 + 4};${clickRipple.r0 + 30}`} dur="0.5s" fill="freeze" />
2356
+ <animate attributeName="opacity" values="0.7;0" dur="0.5s" fill="freeze" />
2357
+ </circle>
2358
+ )}
2359
+
1241
2360
  </g>
1242
2361
 
1243
2362
  {/* #112: overlay panels (recent-signal + legend) render OUTSIDE the
1244
- zoom/pan <g> they stay fixed in the viewBox corners while the
1245
- topology pans/zooms (previously they drifted with the content).
1246
- Grid layout insets its rows below them (gy0) so nodes are never
1247
- occluded. */}
2363
+ zoom/pan <g> so they stay fixed while the topology pans/zooms.
2364
+ They're sized + tucked into the top corners so every corner of
2365
+ each panel is >325px from the canvas centre i.e. fully outside
2366
+ the outermost (offline) ring. No node on any ring can reach the
2367
+ corner triangles, so the panels never overlap a node, in ring
2368
+ OR grid layout (Vincent 4727 zero-overlap criterion). */}
1248
2369
  {/* latest flow labels */}
1249
- <g transform="translate(28, 34)">
1250
- <rect x="0" y="0" width="285" height={60 + Math.min(flowLinks.length, 4) * 20} rx="10" fill={pal.legendBox.fill} stroke={pal.legendBox.stroke} opacity={isLight ? 0.97 : 0.92} />
1251
- <text x="16" y="26" fill={pal.legendHeadline} fontSize="13" fontFamily="monospace" fontWeight="700">recent signal</text>
1252
- <text x="178" y="26" fill={pal.legendAccent} fontSize="11" fontFamily="monospace">{messages.length} messages</text>
1253
- {flowLinks.slice(0, 4).map((link, index) => (
1254
- <text key={link.key} x="16" y={54 + index * 20} fill={pal.legendText} fontSize="10" fontFamily="monospace">
1255
- {truncate(link.from, 8)} {'->'} {truncate(link.to, 8)} / {link.count} / {truncate(link.content, 18)}
2370
+ {/* Round 57 / Loop: drop-shadow on the panel rects gives them
2371
+ card-like elevation, especially on light theme where the
2372
+ near-white fill on a near-white canvas read as pasted-on.
2373
+ data-topo-panel-elevation tag so the test can verify both
2374
+ panels carry the filter. The filter is on the rect, not
2375
+ the parent <g>, so it doesn't shadow the rows + text inside
2376
+ only the panel chrome lifts. */}
2377
+ <g transform="translate(16, 16)">
2378
+ <rect
2379
+ x="0" y="0" width="230" height="84" rx="10"
2380
+ fill={pal.legendBox.fill}
2381
+ stroke={pal.legendBox.stroke}
2382
+ opacity={isLight ? 0.97 : 0.92}
2383
+ 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))' }}
2384
+ data-topo-panel-elevation="recent"
2385
+ />
2386
+ <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700">recent signal</text>
2387
+ <text x="150" y="21" fill={pal.legendAccent} fontSize="10" fontFamily="monospace">{messages.length} msgs</text>
2388
+ {/* Round 45 / Loop: empty state. The panel used to render
2389
+ "recent signal" + "0 msgs" with three blank slots below
2390
+ when no flow yet — read as "broken" rather than "quiet".
2391
+ A muted centred placeholder makes the empty state
2392
+ deliberate. Messages count CAN diverge from flowLinks
2393
+ count (raw count vs. deduped pairs), so the placeholder
2394
+ fires on flowLinks.length=0 specifically. */}
2395
+ {flowLinks.length === 0 ? (
2396
+ <text x="115" y="60" textAnchor="middle" fill={pal.legendText} fontSize="10" fontFamily="monospace" fontStyle="italic" opacity={0.65} data-recent-signal-empty>
2397
+ no flow yet
1256
2398
  </text>
1257
- ))}
2399
+ ) : (
2400
+ // Round 56 / Loop: each row is a navigator into the canvas.
2401
+ // Hover a row → set hoveredEdgeKey, which the existing R50
2402
+ // edge-focus + R49 endpoint-highlight ladders consume. The
2403
+ // matching flow edge brightens to 2× + thickens, its two
2404
+ // endpoint nodes stay full opacity, and every other edge +
2405
+ // non-endpoint node dims. Released → all restore. Wrapping
2406
+ // <g> + a transparent 218×14 hitbox so the cursor doesn't
2407
+ // have to land precisely on the truncated text.
2408
+ flowLinks.slice(0, 3).map((link, index) => (
2409
+ <g
2410
+ key={link.key}
2411
+ data-recent-row={link.key}
2412
+ style={{ cursor: 'pointer' }}
2413
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
2414
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
2415
+ >
2416
+ <rect x="6" y={38 + index * 16 - 10} width="218" height="14" fill="transparent" />
2417
+ <text
2418
+ x="13" y={38 + index * 16}
2419
+ fill={hoveredEdgeKey === link.key ? pal.legendHeadline : pal.legendText}
2420
+ fontSize="9"
2421
+ fontFamily="monospace"
2422
+ style={{ transition: 'fill 150ms ease-out' }}
2423
+ >
2424
+ {truncate(link.from, 6)} {'->'} {truncate(link.to, 6)} / {link.count} / {truncate(link.content, 12)}
2425
+ </text>
2426
+ </g>
2427
+ ))
2428
+ )}
1258
2429
  </g>
1259
2430
 
1260
- {/* legend */}
1261
- <g transform="translate(720, 34)">
1262
- <rect x="0" y="0" width="250" height="112" rx="10" fill={pal.legendBox.fill} stroke={pal.legendBox.stroke} opacity={isLight ? 0.97 : 0.92} />
1263
- <circle cx="18" cy="26" r="6" fill={isLight ? '#059669' : '#22c55e'} />
1264
- <text x="34" y="30" fill={pal.legendText} fontSize="11" fontFamily="monospace">working node</text>
1265
- <circle cx="18" cy="52" r="6" fill={isLight ? '#0d9488' : '#2dd4bf'} />
1266
- <text x="34" y="56" fill={pal.legendText} fontSize="11" fontFamily="monospace">online idle</text>
1267
- <circle cx="18" cy="78" r="6" fill={isLight ? '#94a3b8' : '#6b7280'} />
1268
- <text x="34" y="82" fill={pal.legendText} fontSize="11" fontFamily="monospace">offline / no SSE</text>
1269
- <path d="M150,78 Q176,52 210,78" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" />
2431
+ {/* legend — Round 55 / Loop: each status row is now a hover
2432
+ target. Pointer enter sets `hoveredStatus`; pointer leave
2433
+ clears it. Node opacity formula composes the match below.
2434
+ The row text brightens to legendHeadline while hovered as
2435
+ a small affordance hint. Geometry unchanged — the new
2436
+ <g> wrappers only carry pointer handlers. */}
2437
+ <g transform="translate(760, 16)">
2438
+ {/* R57: matching drop-shadow elevation to the legend panel. */}
2439
+ <rect
2440
+ x="0" y="0" width="224" height="96" rx="10"
2441
+ fill={pal.legendBox.fill}
2442
+ stroke={pal.legendBox.stroke}
2443
+ opacity={isLight ? 0.97 : 0.92}
2444
+ 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))' }}
2445
+ data-topo-panel-elevation="legend"
2446
+ />
2447
+ {([
2448
+ { key: 'working' as const, y0: 24, y1: 28, fill: isLight ? '#059669' : '#22c55e', label: 'working node' },
2449
+ { key: 'idle' as const, y0: 48, y1: 52, fill: isLight ? '#0d9488' : '#2dd4bf', label: 'online idle' },
2450
+ { key: 'offline' as const, y0: 72, y1: 76, fill: isLight ? '#94a3b8' : '#6b7280', label: 'offline / no SSE' },
2451
+ ]).map(row => {
2452
+ // Round 61 / Loop: legend rows pin too — symmetric with the
2453
+ // R60 pressure-bar segments. R55 hover stays transient; the
2454
+ // new onClick toggles pinnedStatus so users can lock a
2455
+ // filter without holding the cursor still. Pinned row gets
2456
+ // an inset ring on the swatch (same vocab as R60).
2457
+ const isPinned = pinnedStatus === row.key;
2458
+ return (
2459
+ <g
2460
+ key={row.key}
2461
+ data-legend-status={row.key}
2462
+ role="button"
2463
+ tabIndex={0}
2464
+ aria-pressed={isPinned}
2465
+ style={{ cursor: 'pointer' }}
2466
+ // R61: stopPropagation on pointerdown so the SVG-level
2467
+ // pan handler (R103) doesn't setPointerCapture and
2468
+ // redirect the follow-up click away from this <g>.
2469
+ // Same trick the node <g> uses (and R52 hub).
2470
+ onPointerDown={(e) => e.stopPropagation()}
2471
+ onMouseEnter={() => setHoveredStatus(row.key)}
2472
+ onMouseLeave={() => setHoveredStatus(prev => prev === row.key ? null : prev)}
2473
+ onClick={() => setPinnedStatus(prev => prev === row.key ? null : row.key)}
2474
+ >
2475
+ {/* invisible hitbox covers the row so cursor doesn't need
2476
+ to be exactly on the 5-px swatch */}
2477
+ <rect x="6" y={row.y0 - 12} width="170" height="22" fill="transparent" />
2478
+ <circle cx="16" cy={row.y0} r="5.5" fill={row.fill} />
2479
+ {/* R61 pinned-state ring — concentric stroke at r=8 in
2480
+ the row colour, draws OUTSIDE the swatch so it
2481
+ doesn't fight the fill colour the user is matching. */}
2482
+ {isPinned && (
2483
+ <circle cx="16" cy={row.y0} r="8" fill="none" stroke={row.fill} strokeWidth="1.5" />
2484
+ )}
2485
+ <text
2486
+ x="30" y={row.y1}
2487
+ fill={hoveredStatus === row.key || isPinned ? pal.legendHeadline : pal.legendText}
2488
+ fontSize="11"
2489
+ fontFamily="monospace"
2490
+ style={{ transition: 'fill 150ms ease-out' }}
2491
+ >{row.label}</text>
2492
+ </g>
2493
+ );
2494
+ })}
2495
+ {/* Flow-arrow swatch sits at y=72 — same row as the offline
2496
+ hitbox. Drop its pointerEvents so the offline legend row
2497
+ stays hoverable (R55). It's decoration, no need to
2498
+ receive events. */}
2499
+ <path d="M140,72 Q164,48 196,72" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" style={{ pointerEvents: 'none' }} />
1270
2500
  </g>
1271
2501
  </svg>
1272
2502
 
2503
+ {/* Round 30 / Loop: minimap. Big fleets in fullscreen mode at high
2504
+ zoom let users lose their position — the minimap shows the
2505
+ whole topology miniaturised plus a viewport rectangle so the
2506
+ user always knows where they are. Click anywhere to recenter
2507
+ the canvas there. Only mounted when the view is non-default
2508
+ (zoomed or panned) since at 1× centered the minimap and the
2509
+ canvas show the same thing. HTML overlay so it stays fixed
2510
+ while the SVG transforms. */}
2511
+ {(() => {
2512
+ const isDefaultView = Math.abs(view.zoom - 1) < 0.01 && Math.abs(view.x) < 1 && Math.abs(view.y) < 1;
2513
+ if (isDefaultView || (onlineNodes.length + offlineNodes.length) === 0) return null;
2514
+ const MW = 120, MH = 82;
2515
+ const sx = MW / VIEWBOX_W, sy = MH / VIEWBOX_H;
2516
+ const rectX = (-view.x / view.zoom) * sx;
2517
+ const rectY = (-view.y / view.zoom) * sy;
2518
+ const rectW = (VIEWBOX_W / view.zoom) * sx;
2519
+ const rectH = (VIEWBOX_H / view.zoom) * sy;
2520
+ return (
2521
+ <div
2522
+ className="absolute right-3 rounded-md border shadow-lg shadow-black/30 overflow-hidden anet-fade-in"
2523
+ style={{ bottom: 56, background: pal.legendBox.fill, borderColor: pal.containerBorder, cursor: 'crosshair' }}
2524
+ role="img"
2525
+ aria-label="Topology minimap — click to recenter"
2526
+ title="Minimap · click to recenter"
2527
+ onClick={(e) => {
2528
+ const r = e.currentTarget.getBoundingClientRect();
2529
+ const fx = (e.clientX - r.left) / r.width;
2530
+ const fy = (e.clientY - r.top) / r.height;
2531
+ setView(prev => ({
2532
+ ...prev,
2533
+ x: VIEWBOX_W / 2 - fx * VIEWBOX_W * prev.zoom,
2534
+ y: VIEWBOX_H / 2 - fy * VIEWBOX_H * prev.zoom,
2535
+ }));
2536
+ }}
2537
+ data-topo-minimap
2538
+ >
2539
+ <svg width={MW} height={MH} viewBox={`0 0 ${MW} ${MH}`} style={{ display: 'block' }}>
2540
+ {[...onlineNodes, ...offlineNodes].map(s => {
2541
+ const p = nodePositions[s.alias];
2542
+ if (!p) return null;
2543
+ const sseN = (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
2544
+ const isOn = s.status !== 'offline' || !!sseN;
2545
+ const st = nodeStatus(s, isOn, isLight);
2546
+ 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} />;
2547
+ })}
2548
+ {/* viewport rectangle */}
2549
+ <rect
2550
+ x={Math.max(0, rectX)} y={Math.max(0, rectY)}
2551
+ width={Math.max(0, Math.min(MW - Math.max(0, rectX), rectW))}
2552
+ height={Math.max(0, Math.min(MH - Math.max(0, rectY), rectH))}
2553
+ fill="none" stroke={pal.legendAccent} strokeWidth="1" opacity="0.9"
2554
+ />
2555
+ </svg>
2556
+ </div>
2557
+ );
2558
+ })()}
1273
2559
  {/* Round 103 (issue #81): zoom / pan / fullscreen controls — HTML
1274
2560
  overlay so they stay fixed while the SVG content transforms.
1275
2561
  Round 104: Vincent 实测 — the reset action used to be hidden
@@ -1306,6 +2592,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1306
2592
  className="px-2 py-1 hover:bg-white/5 transition-colors"
1307
2593
  style={{ color: pal.legendText }}
1308
2594
  aria-label="Zoom out"
2595
+ title="Zoom out (−)"
1309
2596
  >
1310
2597
  <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>
1311
2598
  </button>
@@ -1321,6 +2608,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1321
2608
  className="px-2 py-1 hover:bg-white/5 transition-colors"
1322
2609
  style={{ color: pal.legendText }}
1323
2610
  aria-label="Zoom in"
2611
+ title="Zoom in (+)"
1324
2612
  >
1325
2613
  <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>
1326
2614
  </button>
@@ -1330,7 +2618,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1330
2618
  className="p-1.5 rounded-md border hover:bg-white/5 transition-colors"
1331
2619
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
1332
2620
  aria-label="Reset view"
1333
- title="Reset zoom + pan (or double-click the canvas)"
2621
+ title="Reset zoom + pan (0, or double-click the canvas)"
1334
2622
  >
1335
2623
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1336
2624
  <path d="M3 12a9 9 0 1 0 9-9 9 9 0 0 0-6.4 2.6L3 8" />