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

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 (259) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +1 -0
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/diagnostics/route-bundle-stats.json +69 -55
  5. package/.next/fallback-build-manifest.json +3 -3
  6. package/.next/prerender-manifest.json +3 -3
  7. package/.next/routes-manifest.json +6 -0
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app/_not-found.html +2 -2
  17. package/.next/server/app/_not-found.rsc +12 -12
  18. package/.next/server/app/_not-found.segments/_full.segment.rsc +12 -12
  19. package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  20. package/.next/server/app/_not-found.segments/_index.segment.rsc +7 -7
  21. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  22. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  23. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  24. package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
  25. package/.next/server/app/admin.html +2 -2
  26. package/.next/server/app/admin.rsc +14 -14
  27. package/.next/server/app/admin.segments/_full.segment.rsc +14 -14
  28. package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
  29. package/.next/server/app/admin.segments/_index.segment.rsc +7 -7
  30. package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
  31. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +4 -4
  32. package/.next/server/app/admin.segments/admin.segment.rsc +3 -3
  33. package/.next/server/app/api/hub/servers/route/app-paths-manifest.json +3 -0
  34. package/.next/server/app/api/hub/servers/route/build-manifest.json +9 -0
  35. package/.next/server/app/api/hub/servers/route/server-reference-manifest.json +4 -0
  36. package/.next/server/app/api/hub/servers/route.js +7 -0
  37. package/.next/server/app/api/hub/servers/route.js.map +5 -0
  38. package/.next/server/app/api/hub/servers/route.js.nft.json +1 -0
  39. package/.next/server/app/api/hub/servers/route_client-reference-manifest.js +3 -0
  40. package/.next/server/app/index.html +2 -2
  41. package/.next/server/app/index.rsc +14 -14
  42. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  43. package/.next/server/app/index.segments/_full.segment.rsc +14 -14
  44. package/.next/server/app/index.segments/_head.segment.rsc +4 -4
  45. package/.next/server/app/index.segments/_index.segment.rsc +7 -7
  46. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  47. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  48. package/.next/server/app/login.html +2 -2
  49. package/.next/server/app/login.rsc +14 -14
  50. package/.next/server/app/login.segments/_full.segment.rsc +14 -14
  51. package/.next/server/app/login.segments/_head.segment.rsc +4 -4
  52. package/.next/server/app/login.segments/_index.segment.rsc +7 -7
  53. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  54. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
  55. package/.next/server/app/login.segments/login.segment.rsc +3 -3
  56. package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
  57. package/.next/server/app/logs.html +2 -2
  58. package/.next/server/app/logs.rsc +14 -14
  59. package/.next/server/app/logs.segments/_full.segment.rsc +14 -14
  60. package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
  61. package/.next/server/app/logs.segments/_index.segment.rsc +7 -7
  62. package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
  63. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
  64. package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
  65. package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
  66. package/.next/server/app/messages.html +2 -2
  67. package/.next/server/app/messages.rsc +14 -14
  68. package/.next/server/app/messages.segments/_full.segment.rsc +14 -14
  69. package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
  70. package/.next/server/app/messages.segments/_index.segment.rsc +7 -7
  71. package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
  72. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
  73. package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
  74. package/.next/server/app/node/page_client-reference-manifest.js +1 -1
  75. package/.next/server/app/node.html +2 -2
  76. package/.next/server/app/node.rsc +14 -14
  77. package/.next/server/app/node.segments/_full.segment.rsc +14 -14
  78. package/.next/server/app/node.segments/_head.segment.rsc +4 -4
  79. package/.next/server/app/node.segments/_index.segment.rsc +7 -7
  80. package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
  81. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +4 -4
  82. package/.next/server/app/node.segments/node.segment.rsc +3 -3
  83. package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
  84. package/.next/server/app/nodes.html +2 -2
  85. package/.next/server/app/nodes.rsc +14 -14
  86. package/.next/server/app/nodes.segments/_full.segment.rsc +14 -14
  87. package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
  88. package/.next/server/app/nodes.segments/_index.segment.rsc +7 -7
  89. package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
  90. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
  91. package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
  92. package/.next/server/app/page_client-reference-manifest.js +1 -1
  93. package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
  94. package/.next/server/app/server-logs.html +2 -2
  95. package/.next/server/app/server-logs.rsc +14 -14
  96. package/.next/server/app/server-logs.segments/_full.segment.rsc +14 -14
  97. package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
  98. package/.next/server/app/server-logs.segments/_index.segment.rsc +7 -7
  99. package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
  100. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +4 -4
  101. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
  102. package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
  103. package/.next/server/app/settings/networks.html +2 -2
  104. package/.next/server/app/settings/networks.rsc +14 -14
  105. package/.next/server/app/settings/networks.segments/_full.segment.rsc +14 -14
  106. package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
  107. package/.next/server/app/settings/networks.segments/_index.segment.rsc +7 -7
  108. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
  109. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +4 -4
  110. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
  111. package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
  112. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  113. package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
  114. package/.next/server/app/settings/tokens.html +2 -2
  115. package/.next/server/app/settings/tokens.rsc +14 -14
  116. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +14 -14
  117. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
  118. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +7 -7
  119. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
  120. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +4 -4
  121. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
  122. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
  123. package/.next/server/app/settings.html +2 -2
  124. package/.next/server/app/settings.rsc +14 -14
  125. package/.next/server/app/settings.segments/_full.segment.rsc +14 -14
  126. package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
  127. package/.next/server/app/settings.segments/_index.segment.rsc +7 -7
  128. package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  129. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
  130. package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
  131. package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  132. package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
  133. package/.next/server/app/tasks.html +2 -2
  134. package/.next/server/app/tasks.rsc +14 -14
  135. package/.next/server/app/tasks.segments/_full.segment.rsc +14 -14
  136. package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
  137. package/.next/server/app/tasks.segments/_index.segment.rsc +7 -7
  138. package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
  139. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
  140. package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
  141. package/.next/server/app-paths-manifest.json +1 -0
  142. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_servers_route_actions_08-m-cs.js +3 -0
  143. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_servers_route_actions_08-m-cs.js.map +1 -0
  144. package/.next/server/chunks/[root-of-the-server]__0tx8s8i._.js +3 -0
  145. package/.next/server/chunks/[root-of-the-server]__0tx8s8i._.js.map +1 -0
  146. package/.next/server/chunks/ssr/[root-of-the-server]__03b.f76._.js +1 -1
  147. package/.next/server/chunks/ssr/[root-of-the-server]__03b.f76._.js.map +1 -1
  148. package/.next/server/chunks/ssr/[root-of-the-server]__0fhoq8i._.js +1 -1
  149. package/.next/server/chunks/ssr/[root-of-the-server]__0fhoq8i._.js.map +1 -1
  150. package/.next/server/chunks/ssr/[root-of-the-server]__0nw~zhp._.js +1 -1
  151. package/.next/server/chunks/ssr/[root-of-the-server]__0nw~zhp._.js.map +1 -1
  152. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js +2 -1
  153. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js.map +1 -1
  154. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js +1 -1
  155. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js.map +1 -1
  156. package/.next/server/chunks/ssr/agent-network-dashboard_app_057q.ne._.js +1 -1
  157. package/.next/server/chunks/ssr/agent-network-dashboard_app_057q.ne._.js.map +1 -1
  158. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js +1 -1
  159. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js.map +1 -1
  160. package/.next/server/chunks/ssr/agent-network-dashboard_app_0_d45-d._.js +1 -1
  161. package/.next/server/chunks/ssr/agent-network-dashboard_app_0_d45-d._.js.map +1 -1
  162. package/.next/server/chunks/ssr/agent-network-dashboard_app_0i3759l._.js +1 -1
  163. package/.next/server/chunks/ssr/agent-network-dashboard_app_0i3759l._.js.map +1 -1
  164. package/.next/server/chunks/ssr/agent-network-dashboard_app_0xgney8._.js +1 -1
  165. package/.next/server/chunks/ssr/agent-network-dashboard_app_0xgney8._.js.map +1 -1
  166. package/.next/server/chunks/ssr/agent-network-dashboard_app_10hjgv4._.js +1 -1
  167. package/.next/server/chunks/ssr/agent-network-dashboard_app_10hjgv4._.js.map +1 -1
  168. package/.next/server/chunks/ssr/agent-network-dashboard_app_1153xeb._.js +1 -1
  169. package/.next/server/chunks/ssr/agent-network-dashboard_app_1153xeb._.js.map +1 -1
  170. package/.next/server/chunks/ssr/agent-network-dashboard_app_12l4oto._.js +1 -1
  171. package/.next/server/chunks/ssr/agent-network-dashboard_app_12l4oto._.js.map +1 -1
  172. package/.next/server/chunks/ssr/agent-network-dashboard_app_components_0mvyi-4._.js +1 -1
  173. package/.next/server/chunks/ssr/agent-network-dashboard_app_components_0mvyi-4._.js.map +1 -1
  174. package/.next/server/middleware-build-manifest.js +3 -3
  175. package/.next/server/pages/404.html +2 -2
  176. package/.next/server/pages/500.html +1 -1
  177. package/.next/server/server-reference-manifest.js +1 -1
  178. package/.next/server/server-reference-manifest.json +1 -1
  179. package/.next/static/chunks/0-mpa_947ipeq.js +1 -0
  180. package/.next/static/chunks/01lmbsd37fybu.js +1 -0
  181. package/.next/static/chunks/03o.h6kvmw4l_.js +1 -0
  182. package/.next/static/chunks/03~~oirxz7~vc.js +1 -0
  183. package/.next/static/chunks/05uk96gc~9mni.js +1 -0
  184. package/.next/static/chunks/088ouqy7tekty.js +2 -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/0hyl7mdapsz38.js +1 -0
  188. package/.next/static/chunks/0mqwn-l7wtcdo.js +7 -0
  189. package/.next/static/chunks/0rxrez_cjn.eh.js +1 -0
  190. package/.next/static/chunks/0tvn2l1pc.h65.js +1 -0
  191. package/.next/static/chunks/0x8x.29hkju5a.js +1 -0
  192. package/.next/static/chunks/127kw7~wsnyu7.css +2 -0
  193. package/.next/static/chunks/16yfxp9ak2gdm.js +1 -0
  194. package/.next/static/chunks/174re5ar~kxne.js +1 -0
  195. package/.next/trace +2 -2
  196. package/.next/trace-build +1 -1
  197. package/.next/types/routes.d.ts +2 -1
  198. package/.next/types/validator.ts +9 -0
  199. package/app/api/hub/servers/route.ts +39 -0
  200. package/app/components/AppShell.tsx +2 -0
  201. package/app/components/ChatPopover.tsx +38 -2
  202. package/app/components/HelpOverlay.tsx +18 -0
  203. package/app/components/ServersDrawer.tsx +206 -0
  204. package/app/components/TopoGraph.tsx +871 -162
  205. package/app/components/utils.ts +8 -7
  206. package/app/lib/time.ts +36 -0
  207. package/package.json +1 -1
  208. package/scripts/help-overlay-topo-test.mjs +44 -0
  209. package/scripts/servers-drawer-live-test.mjs +96 -0
  210. package/scripts/topo-active-links-chip-test.mjs +73 -0
  211. package/scripts/topo-arrow-tier-test.mjs +61 -0
  212. package/scripts/topo-auto-fit-test.mjs +73 -0
  213. package/scripts/topo-chat-focus-ring-test.mjs +77 -0
  214. package/scripts/topo-click-ripple-test.mjs +66 -0
  215. package/scripts/topo-cwd-tooltip-test.mjs +64 -0
  216. package/scripts/topo-dblclick-reset-test.mjs +74 -0
  217. package/scripts/topo-edge-hover-test.mjs +85 -0
  218. package/scripts/topo-edge-tooltip-test.mjs +70 -0
  219. package/scripts/topo-fit-key-test.mjs +106 -0
  220. package/scripts/topo-freshness-chip-test.mjs +74 -0
  221. package/scripts/topo-group-count-test.mjs +63 -0
  222. package/scripts/topo-group-hover-test.mjs +82 -0
  223. package/scripts/topo-hub-title-test.mjs +88 -0
  224. package/scripts/topo-label-lift-test.mjs +115 -0
  225. package/scripts/topo-label-scale-test.mjs +78 -0
  226. package/scripts/topo-lastseen-tooltip-test.mjs +59 -0
  227. package/scripts/topo-layout-key-test.mjs +91 -0
  228. package/scripts/topo-minimap-test.mjs +68 -0
  229. package/scripts/topo-mobile-chips-test.mjs +103 -0
  230. package/scripts/topo-offline-dim-test.mjs +70 -0
  231. package/scripts/topo-overlap-test.mjs +164 -0
  232. package/scripts/topo-p0round-test.mjs +2 -2
  233. package/scripts/topo-pan-cursor-test.mjs +62 -0
  234. package/scripts/topo-popover-meta-test.mjs +78 -0
  235. package/scripts/topo-pressure-bar-test.mjs +69 -0
  236. package/scripts/topo-pulse-rate-test.mjs +71 -0
  237. package/scripts/topo-recent-signal-empty-test.mjs +63 -0
  238. package/scripts/topo-reduced-motion-test.mjs +77 -0
  239. package/scripts/topo-tz-ghost-test.mjs +69 -0
  240. package/scripts/topo-wheel-zoom-test.mjs +79 -0
  241. package/scripts/topo-zoom-keys-test.mjs +89 -0
  242. package/.next/static/chunks/0-7iowzu4.798.js +0 -1
  243. package/.next/static/chunks/02t5jr4shh.hy.js +0 -1
  244. package/.next/static/chunks/0_qfg5s5uzs7..js +0 -7
  245. package/.next/static/chunks/0_rmxom9~1lmk.css +0 -2
  246. package/.next/static/chunks/0cbezu61m-e~z.js +0 -1
  247. package/.next/static/chunks/0cj5qr_a8dzky.js +0 -7
  248. package/.next/static/chunks/0elalb9n_.~di.js +0 -1
  249. package/.next/static/chunks/0j_q__o8p6g83.js +0 -1
  250. package/.next/static/chunks/0jl6_w50zh4l5.js +0 -1
  251. package/.next/static/chunks/0mn0ruf0g4op5.js +0 -1
  252. package/.next/static/chunks/0r4q73.-1bkyl.js +0 -1
  253. package/.next/static/chunks/0r_c8eccg3z2o.js +0 -1
  254. package/.next/static/chunks/0y9subfnntv1s.js +0 -1
  255. package/.next/static/chunks/10oi7l-tycgl9.js +0 -1
  256. package/.next/static/chunks/11uk1izuv3~0t.js +0 -1
  257. /package/.next/static/{7xem03-473G3qw-fC160p → 23MxP0RBVA4BaOJ_HMYmM}/_buildManifest.js +0 -0
  258. /package/.next/static/{7xem03-473G3qw-fC160p → 23MxP0RBVA4BaOJ_HMYmM}/_clientMiddlewareManifest.js +0 -0
  259. /package/.next/static/{7xem03-473G3qw-fC160p → 23MxP0RBVA4BaOJ_HMYmM}/_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 => {
@@ -495,12 +625,19 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
495
625
  const minX = Math.min(...xs), minY = Math.min(...ys);
496
626
  return {
497
627
  key: band.members.length ? groupKeys[band.members[0].alias] : '',
628
+ count: band.members.length,
498
629
  x: minX - GROUP_PAD,
499
630
  y: minY - GROUP_TOP,
500
631
  w: Math.max(...xs) - minX + GROUP_PAD * 2,
501
632
  h: Math.max(...ys) - minY + GROUP_TOP + GROUP_PAD,
502
633
  };
503
634
  });
635
+ // Round 28 / Loop: surface the grid's natural content bottom so the
636
+ // mount effect can auto-fit zoom when the layout would overflow the
637
+ // viewBox. = bottom of the last node row + its label drop + a small
638
+ // breathing buffer. Round 27's cellH bump makes this overflow more
639
+ // common (30-node fleets reach ~774 px, viewBox is 680).
640
+ const gridContentBottom = gy0 + totalRows * cellH + 8;
504
641
  return {
505
642
  onlineNodes: online,
506
643
  offlineNodes: offline,
@@ -509,6 +646,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
509
646
  activeAliases: active,
510
647
  groupKeys,
511
648
  groupBoxes,
649
+ gridContentBottom,
512
650
  };
513
651
  }
514
652
 
@@ -590,16 +728,36 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
590
728
  groupKeys,
591
729
  // #111: group boxes are a grid-layout feature only — radially scattered
592
730
  // 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 }[],
731
+ groupBoxes: [] as { key: string; count: number; x: number; y: number; w: number; h: number }[],
732
+ // ring fits within VIEWBOX_H by construction (offlineRadius=325 + centre at y=330)
733
+ gridContentBottom: 0,
594
734
  };
595
- }, [messages, sessions, sseSessions, layout]);
735
+ }, [messages, sessions, sseSessions, layout, nodeScale]);
596
736
 
597
737
  const workingCount = onlineNodes.filter(s => s.status === 'working').length;
738
+ // Round 6 / Loop: vendor distribution for the header chip — at a glance
739
+ // "what's in the fleet" (A:5 M:2 O:8 书:12 …) without opening a node.
740
+ // Sorted by count desc; "unknown" vendors collapse into a "?" bucket.
741
+ const vendorDist = useMemo(() => {
742
+ const tally = new Map<string, { initial: string; count: number; color: string }>();
743
+ for (const s of [...onlineNodes, ...offlineNodes]) {
744
+ const v = vendorForModel(s.model);
745
+ const key = v.id === 'unknown' ? '?' : v.initial;
746
+ const cur = tally.get(key);
747
+ if (cur) cur.count++;
748
+ else tally.set(key, { initial: key, count: 1, color: v.mono.text });
749
+ }
750
+ return [...tally.values()].sort((a, b) => b.count - a.count);
751
+ }, [onlineNodes, offlineNodes]);
598
752
  // Round 109 (Vincent 4582 P0): hover-gated labels above this node count
599
753
  // so dense fleets show clean avatars instead of a wall of overlapping
600
754
  // label cards. 16 ≈ where the triple-tier rings start to crowd.
601
755
  const denseLayout = onlineNodes.length + offlineNodes.length > 16;
602
756
  const [hoveredAlias, setHoveredAlias] = useState<string | null>(null);
757
+ // Round 8 / Loop: which group is currently focused. Nodes outside this
758
+ // group + other group boxes fade so the eye locks onto the team you're
759
+ // pointing at. Singletons use their own alias as the group key.
760
+ const hoveredGroup = hoveredAlias ? (groupKeys[hoveredAlias] ?? hoveredAlias) : null;
603
761
 
604
762
  // --- Round 103 (issue #81): fullscreen + zoom + pan interaction layer ---
605
763
  // DIY native (no d3 / svg-pan-zoom): wrap the topology content in a single
@@ -619,6 +777,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
619
777
  // Issue #100: singleton chat popover. One alias at a time — clicking
620
778
  // another node swaps the target and the conversation switches in place.
621
779
  const [chatAlias, setChatAlias] = useState<string | null>(null);
780
+ // Round 14 / Loop: one-shot click ripple — fades outward from the clicked
781
+ // node so the click registers physically before the popover snaps in.
782
+ // Pairs with the Round 11 chat-focus ring: ripple expands, ring locks on.
783
+ // Keyed by ts so re-clicking the same node remounts the <circle> and the
784
+ // SMIL <animate> replays. Cleared 600ms after click (longer than the
785
+ // 500ms animation so a final frame at opacity 0 is still in the tree).
786
+ const [clickRipple, setClickRipple] = useState<{
787
+ ts: number; x: number; y: number; r0: number; color: string;
788
+ } | null>(null);
622
789
  // #84: when a node is renamed while its chat popover is open, follow the
623
790
  // rename so the conversation keeps targeting a live alias.
624
791
  useEffect(() => {
@@ -649,6 +816,42 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
649
816
  } catch {}
650
817
  }, []);
651
818
 
819
+ // Round 28 / Loop: first-paint auto-fit. Round 27's cellH bump means
820
+ // dense grids (≥6-7 rows) overflow the 680-px viewBox at the natural
821
+ // 100% zoom — a new user sees the topology with its bottom rows
822
+ // clipped and no hint that there's more below. Auto-fit on first
823
+ // paint (and only if the user has no persisted view) sets the
824
+ // initial zoom so all content fits. Subsequent zoom changes persist
825
+ // and override the auto-fit on reload; explicit reset (0 key) still
826
+ // goes back to 100% so the gesture's "reset to natural size" semantic
827
+ // is preserved.
828
+ //
829
+ // Capture pre-mount persistence in a useState initializer — the
830
+ // existing `persist` effect (declared below) runs on first render
831
+ // with the default {1,0,0} view and writes it to localStorage before
832
+ // the auto-fit effect (deps on async-arriving sessions) gets a turn.
833
+ // Reading localStorage from the effect would see that write and
834
+ // skip the fit. The useState snapshot fires once, before any effects.
835
+ const [hadPersistedViewOnMount] = useState<boolean>(
836
+ () => typeof window !== 'undefined' && !!localStorage.getItem('anet-topo-view'),
837
+ );
838
+ const autoFitDoneRef = useRef(false);
839
+ useEffect(() => {
840
+ if (autoFitDoneRef.current) return;
841
+ if (hadPersistedViewOnMount) {
842
+ autoFitDoneRef.current = true;
843
+ return;
844
+ }
845
+ if (layout !== 'grid' || sessions.length === 0 || !gridContentBottom) return;
846
+ if (gridContentBottom <= VIEWBOX_H) {
847
+ autoFitDoneRef.current = true; // no overflow → no fit needed
848
+ return;
849
+ }
850
+ const fitZoom = Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
851
+ setView({ zoom: fitZoom, x: 0, y: 0 });
852
+ autoFitDoneRef.current = true;
853
+ }, [layout, sessions.length, gridContentBottom, hadPersistedViewOnMount]);
854
+
652
855
  // persist view
653
856
  useEffect(() => {
654
857
  try { localStorage.setItem('anet-topo-view', JSON.stringify(view)); } catch {}
@@ -668,6 +871,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
668
871
  const svg = svgRef.current;
669
872
  if (!svg) return;
670
873
  const onWheel = (e: WheelEvent) => {
874
+ // Round 23 / Loop: only zoom on Ctrl/Meta+wheel when inline; plain
875
+ // wheel over the canvas keeps scrolling the page (the topo sits at
876
+ // the top of /; trapping page scroll mid-page is the classic
877
+ // "scroll-jail" anti-pattern). In fullscreen the canvas owns the
878
+ // viewport so any wheel zooms — no page scroll to preserve. Pinch-
879
+ // zoom on a trackpad surfaces as ctrlKey=true natively, which is
880
+ // also what we want (zoom the canvas, not the browser).
881
+ if (!isFullscreen && !e.ctrlKey && !e.metaKey) return;
671
882
  e.preventDefault();
672
883
  const rect = svg.getBoundingClientRect();
673
884
  const mx = ((e.clientX - rect.left) / rect.width) * VIEWBOX_W;
@@ -688,8 +899,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
688
899
  };
689
900
  svg.addEventListener('wheel', onWheel, { passive: false });
690
901
  return () => svg.removeEventListener('wheel', onWheel);
691
- }, []);
692
-
902
+ // Re-attach when the fullscreen flag toggles so the handler's
903
+ // closure picks up the new value (capture-by-closure means we'd
904
+ // otherwise read the stale boolean forever).
905
+ }, [isFullscreen]);
906
+
907
+ // Round 21 / Loop: pan-aware cursor. Static `grab` gave no tactile cue
908
+ // that the canvas was actually being dragged — "grabbing" while
909
+ // dragRef.current.active = true tells the hand it's moving the
910
+ // viewport. Mirroring dragRef.active into state is the only way to
911
+ // re-render the SVG style; the ref itself doesn't trigger renders.
912
+ const [isPanning, setIsPanning] = useState(false);
693
913
  const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
694
914
  if (e.button !== 0) return;
695
915
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
@@ -700,6 +920,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
700
920
  baseX: viewRef.current.x,
701
921
  baseY: viewRef.current.y,
702
922
  };
923
+ setIsPanning(true);
703
924
  };
704
925
  const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
705
926
  const d = dragRef.current;
@@ -714,6 +935,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
714
935
  const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
715
936
  if (!dragRef.current.active) return;
716
937
  dragRef.current.active = false;
938
+ setIsPanning(false);
717
939
  try {
718
940
  (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
719
941
  } catch {}
@@ -731,6 +953,48 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
731
953
  };
732
954
  const resetView = () => setView({ zoom: 1, x: 0, y: 0 });
733
955
 
956
+ // Round 29 / Loop: `f` = fit-to-content. Shared by the Round 28
957
+ // first-paint auto-fit effect and the keyboard handler so the math is
958
+ // in one place. When content already fits at natural zoom, this is
959
+ // effectively a "recenter" — `f` always lands on a known good view.
960
+ const fitView = useCallback(() => {
961
+ const zoom = !gridContentBottom || gridContentBottom <= VIEWBOX_H
962
+ ? 1
963
+ : Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
964
+ setView({ zoom, x: 0, y: 0 });
965
+ }, [gridContentBottom]);
966
+
967
+ // Round 22 / Loop: keyboard zoom — +/= zoom in, - zoom out, 0 reset.
968
+ // Round 29 / Loop: +f to fit content.
969
+ // Listen on window so the user doesn't need to focus the SVG first,
970
+ // but only act when no text input has focus (otherwise typing "-" in
971
+ // a chat would zoom the topology — surprising and bad). Modifier keys
972
+ // pass through so Cmd/Ctrl combos still hit their owners. The button
973
+ // titles below document these shortcuts so users discover them.
974
+ useEffect(() => {
975
+ const onKey = (e: KeyboardEvent) => {
976
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
977
+ const ae = document.activeElement as HTMLElement | null;
978
+ if (ae) {
979
+ const tag = ae.tagName;
980
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || ae.isContentEditable) return;
981
+ }
982
+ if (e.key === '+' || e.key === '=') { zoomBy(1.2); e.preventDefault(); }
983
+ else if (e.key === '-' || e.key === '_') { zoomBy(1 / 1.2); e.preventDefault(); }
984
+ else if (e.key === '0') { resetView(); e.preventDefault(); }
985
+ else if (e.key === 'f' || e.key === 'F') { fitView(); e.preventDefault(); }
986
+ // Round 32 / Loop: `l` toggles ring|grid. The vim-style `g l` route
987
+ // (Audit Log) requires a preceding `g` within 1500ms; a bare `l`
988
+ // outside that window is free for topology use.
989
+ else if (e.key === 'l' || e.key === 'L') { toggleLayout(); e.preventDefault(); }
990
+ };
991
+ window.addEventListener('keydown', onKey);
992
+ return () => window.removeEventListener('keydown', onKey);
993
+ // zoomBy / resetView are stable wrt setView callback; fitView changes
994
+ // with gridContentBottom so deps list catches it.
995
+ // eslint-disable-next-line react-hooks/exhaustive-deps
996
+ }, [fitView]);
997
+
734
998
  const toggleFullscreen = () => {
735
999
  const el = containerRef.current;
736
1000
  if (!el) return;
@@ -758,6 +1022,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
758
1022
  <button
759
1023
  onClick={() => { if (layout !== 'ring') toggleLayout(); }}
760
1024
  aria-pressed={layout === 'ring'}
1025
+ title="Ring layout (l to toggle)"
761
1026
  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
1027
  >
763
1028
  Ring
@@ -765,6 +1030,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
765
1030
  <button
766
1031
  onClick={() => { if (layout !== 'grid') toggleLayout(); }}
767
1032
  aria-pressed={layout === 'grid'}
1033
+ title="Grid layout (l to toggle)"
768
1034
  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
1035
  >
770
1036
  Grid
@@ -776,9 +1042,74 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
776
1042
  <span className="px-2.5 py-1 rounded-md bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
777
1043
  {onlineNodes.length} online
778
1044
  </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>
1045
+ {/* Round 31 / Loop: fleet-health pressure bar. The "X working /
1046
+ Y online" chips already carry the raw counts; the bar lets
1047
+ the eye get the working/idle/offline RATIO in one glance
1048
+ without mental math. Stacked 3-segment chip, ~64 px wide. */}
1049
+ {(() => {
1050
+ const w = workingCount;
1051
+ const i = onlineNodes.length - workingCount; // idle = online - working
1052
+ const o = offlineNodes.length;
1053
+ const total = w + i + o;
1054
+ if (total === 0) return null;
1055
+ const seg = (n: number, color: string, key: string) => {
1056
+ if (n === 0) return null;
1057
+ return <span key={key} style={{ width: `${(n / total) * 100}%`, background: color, height: '100%' }} />;
1058
+ };
1059
+ // Round 47 / Loop: hidden on mobile — at <640px the chip row
1060
+ // wraps to multiple lines and crowds the topology header;
1061
+ // pressure ratio is best read with the working+online raw
1062
+ // counts (kept visible) anyway.
1063
+ return (
1064
+ <span
1065
+ 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"
1066
+ title={`${w} working · ${i} idle · ${o} offline`}
1067
+ data-fleet-pressure
1068
+ >
1069
+ <span className="text-[10px] tracking-wide">pressure</span>
1070
+ <span className="inline-flex h-1.5 w-16 rounded-full overflow-hidden" style={{ background: 'rgb(75 85 99 / 0.25)' }}>
1071
+ {seg(w, isLight ? '#059669' : '#22c55e', 'w')}
1072
+ {seg(i, isLight ? '#0d9488' : '#2dd4bf', 'i')}
1073
+ {seg(o, isLight ? '#94a3b8' : '#6b7280', 'o')}
1074
+ </span>
1075
+ </span>
1076
+ );
1077
+ })()}
1078
+ {vendorDist.length > 1 && (
1079
+ <span
1080
+ 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"
1081
+ title="Fleet by model vendor"
1082
+ >
1083
+ {vendorDist.map(v => (
1084
+ <span key={v.initial} className="inline-flex items-baseline gap-0.5">
1085
+ <span style={{ color: v.color }}>{v.initial}</span>
1086
+ <span className="text-gray-500">:{v.count}</span>
1087
+ </span>
1088
+ ))}
1089
+ </span>
1090
+ )}
1091
+ {/* Round 42 / Loop: extend active-links chip with the timestamp
1092
+ of the most-recent flow event. Tells the operator at a glance
1093
+ whether the topology is currently humming (last 30s) or has
1094
+ been quiet for a while — the visual flow particles and
1095
+ edge brightness only show that there IS traffic, not when
1096
+ the last one was. Reuses Round 38's relativeAgo. */}
1097
+ {(() => {
1098
+ const recent = flowLinks.reduce<number | null>((acc, l) => {
1099
+ if (!l.last_at) return acc;
1100
+ const t = parseHubTime(l.last_at);
1101
+ if (t === null) return acc;
1102
+ return acc === null || t > acc ? t : acc;
1103
+ }, null);
1104
+ const rel = recent !== null ? relativeAgo(new Date(recent).toISOString()) : null;
1105
+ return (
1106
+ <span className="hidden sm:inline px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20">
1107
+ {flowLinks.length} active link{flowLinks.length === 1 ? '' : 's'}
1108
+ {rel ? <span className="text-gray-500"> · last {rel}</span> : null}
1109
+ </span>
1110
+ );
1111
+ })()}
1112
+ <FreshnessChip sessions={sessions} />
782
1113
  </div>
783
1114
  </div>
784
1115
 
@@ -798,7 +1129,18 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
798
1129
  onPointerMove={onPointerMove}
799
1130
  onPointerUp={onPointerUp}
800
1131
  onPointerLeave={onPointerUp}
801
- style={{ cursor: 'grab', touchAction: 'none' }}
1132
+ // Round 41 / Loop: the reset-button title (R22) and the Help
1133
+ // overlay (R25) both advertise "double-click the canvas to
1134
+ // reset", but the handler was never actually wired. The text
1135
+ // was lying. Wire it here, guarded so dbl-clicking a node
1136
+ // (which would also trigger the SVG-level handler via event
1137
+ // bubbling) doesn't unexpectedly reset the view on the user.
1138
+ onDoubleClick={(e) => {
1139
+ const t = e.target as Element | null;
1140
+ if (t?.closest('g[data-node]')) return;
1141
+ resetView();
1142
+ }}
1143
+ style={{ cursor: isPanning ? 'grabbing' : 'grab', touchAction: 'none' }}
802
1144
  >
803
1145
  <defs>
804
1146
  <linearGradient id="topo-panel" x1="0" x2="1" y1="0" y2="1">
@@ -820,9 +1162,37 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
820
1162
  </feMerge>
821
1163
  </filter>
822
1164
  )}
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>
1165
+ {/* Round 16 / Loop: 3-tier flow-link arrow markers.
1166
+ The single marker had `markerUnits` defaulting to
1167
+ `strokeWidth`, so heavy edges (stroke=7) rendered 35-user-
1168
+ unit arrowheads — visually dominant, the head outweighed
1169
+ the line. Switching to `userSpaceOnUse` decouples arrow
1170
+ size from stroke; binning by count gives a clearer
1171
+ hierarchy than continuous linear scaling:
1172
+ s (count 1-2) → 12 user units
1173
+ m (count 3-4) → 16 user units (alias for `topo-arrow`)
1174
+ l (count 5+) → 22 user units
1175
+ `topo-arrow` stays bound to the medium tier so the legend
1176
+ swatch (line ~1500) renders without change. */}
1177
+ {[
1178
+ { id: 'topo-arrow-s', size: 12 },
1179
+ { id: 'topo-arrow', size: 16 },
1180
+ { id: 'topo-arrow-l', size: 22 },
1181
+ ].map(m => (
1182
+ <marker
1183
+ key={m.id}
1184
+ id={m.id}
1185
+ viewBox="0 0 10 10"
1186
+ refX="8"
1187
+ refY="5"
1188
+ markerWidth={m.size}
1189
+ markerHeight={m.size}
1190
+ markerUnits="userSpaceOnUse"
1191
+ orient="auto-start-reverse"
1192
+ >
1193
+ <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
1194
+ </marker>
1195
+ ))}
826
1196
  {/* Round 45: radial radar sweep gradient — bright at center
827
1197
  (where it meets the hub) fading to leading edge. */}
828
1198
  <radialGradient id="topo-sweep" cx="0%" cy="50%" r="100%">
@@ -954,32 +1324,55 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
954
1324
  only — groupBoxes is empty in ring mode. Rendered behind the
955
1325
  flow links + nodes; pointer-events off so they never intercept
956
1326
  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"
1327
+ {groupBoxes.map(box => {
1328
+ const isHovered = hoveredGroup === box.key;
1329
+ // Round 18 / Loop: group-box hover linkage. The Round 8 fade
1330
+ // already dropped OUT-of-focus groups to 0.28, but the IN-focus
1331
+ // group sat at its baseline appearance — no positive emphasis.
1332
+ // Hovering now upgrades the box to an "accent" treatment:
1333
+ // solid stroke (not dashed), thicker, accent-coloured; brighter
1334
+ // text and slightly stronger fill. Label and box read as one
1335
+ // selected unit. Geometry unchanged → overlap test untouched.
1336
+ return (
1337
+ <g
1338
+ key={`grp-${box.key}`}
1339
+ data-group={box.key}
1340
+ className="transition-opacity"
1341
+ style={{ pointerEvents: 'none', opacity: !hoveredGroup || isHovered ? 1 : 0.28 }}
978
1342
  >
979
- {box.key}
980
- </text>
981
- </g>
982
- ))}
1343
+ <rect
1344
+ x={box.x}
1345
+ y={box.y}
1346
+ width={box.w}
1347
+ height={box.h}
1348
+ rx="14"
1349
+ fill={isLight ? '#0f172a' : '#a5b4fc'}
1350
+ fillOpacity={isHovered ? (isLight ? 0.05 : 0.09) : (isLight ? 0.025 : 0.045)}
1351
+ stroke={isHovered ? pal.legendAccent : pal.ringStroke}
1352
+ strokeWidth={isHovered ? 2 : 1.5}
1353
+ strokeDasharray={isHovered ? 'none' : '6 6'}
1354
+ style={{ transition: 'stroke 200ms ease-out, stroke-width 200ms ease-out, fill-opacity 200ms ease-out' }}
1355
+ />
1356
+ <text
1357
+ x={box.x + 12}
1358
+ y={box.y + 14}
1359
+ fill={isHovered ? pal.legendHeadline : pal.legendText}
1360
+ fontSize="13"
1361
+ fontFamily="monospace"
1362
+ fontWeight="700"
1363
+ style={{ transition: 'fill 200ms ease-out' }}
1364
+ >
1365
+ {box.key}
1366
+ {/* Round 19 / Loop: member-count chip. Inline tspan stays
1367
+ in the single <text> bbox the overlap test reads, so
1368
+ the node↔label guard still catches if the chip ever
1369
+ pushes the label far enough right to clip a node.
1370
+ Smaller + lighter weight reads as metadata, not name. */}
1371
+ <tspan dx="6" fill={pal.legendText} fontSize="11" fontWeight="400">· {box.count}</tspan>
1372
+ </text>
1373
+ </g>
1374
+ );
1375
+ })}
983
1376
 
984
1377
  {/* directed message flows */}
985
1378
  {flowLinks.map((link, index) => {
@@ -987,11 +1380,47 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
987
1380
  const to = nodePositions[link.to];
988
1381
  if (!from || !to) return null;
989
1382
 
990
- const lift = index % 2 === 0 ? 36 : -36;
1383
+ // Round 7 / Loop: lift now scales with distance so short links
1384
+ // aren't over-bent (long links keep the ~36px hump), and the
1385
+ // particle period shortens with link.count so busier edges
1386
+ // visibly pulse faster — instant info density on top of the
1387
+ // existing stroke-width-by-count chip.
1388
+ const dist = Math.hypot(to.x - from.x, to.y - from.y);
1389
+ const lift = (index % 2 === 0 ? 1 : -1) * Math.min(36, dist * 0.18);
991
1390
  const path = curvePath(from, to, lift);
992
1391
  const width = Math.min(2 + link.count, 7);
993
- const duration = 2.2 + (index % 5) * 0.32;
994
-
1392
+ const duration = Math.max(0.9, 2.6 / Math.sqrt(link.count));
1393
+ // Round 10 / Loop: freshness fade. An edge that fired ≤30s ago
1394
+ // stays at full intensity; over 5 minutes it decays to ~35%.
1395
+ // Surfaces "what's happening now" vs background chatter without
1396
+ // hiding old flow entirely (some context still useful). `now`
1397
+ // captured at useMemo-recompute time (every 5s message refresh)
1398
+ // — accuracy is within the poll interval, plenty.
1399
+ const ageMs = link.last_at ? Math.max(0, Date.now() - Date.parse(link.last_at)) : 0;
1400
+ const fresh = Math.max(0.35, 1 - ageMs / (5 * 60 * 1000));
1401
+ // Round 16 arrow-tier binning — keep `topo-arrow` as the
1402
+ // medium tier id so the legend swatch picks it up unchanged.
1403
+ const arrowId = link.count <= 2 ? 'topo-arrow-s'
1404
+ : link.count <= 4 ? 'topo-arrow'
1405
+ : 'topo-arrow-l';
1406
+
1407
+ // Round 39 / Loop: edge hover tooltip — surface the same
1408
+ // last_at + count info the freshness fade and arrow tier
1409
+ // already encode visually, in plain text. The stroke is the
1410
+ // hover target; SVG `<title>` honours newlines on every
1411
+ // browser the dashboard targets.
1412
+ const lastAt = relativeAgo(link.last_at);
1413
+ const tooltip = `${link.from} → ${link.to}\n${link.count} message${link.count === 1 ? '' : 's'}${lastAt ? ` · last ${lastAt}` : ''}`;
1414
+ // Round 40 / Loop: edges follow node hover — when an alias is
1415
+ // hovered, every edge touching it brightens, the rest fade.
1416
+ // Pairs with the Round 8 group-focus fade on nodes: hover a
1417
+ // node to find "who is this agent talking to" at a glance.
1418
+ // No hover → multiplier is 1.0 (current behaviour preserved).
1419
+ const edgeOpacityMul = !hoveredAlias
1420
+ ? 1
1421
+ : (link.from === hoveredAlias || link.to === hoveredAlias)
1422
+ ? 1.7 // boost the touching edge
1423
+ : 0.35; // dim the rest
995
1424
  return (
996
1425
  <g key={link.key}>
997
1426
  <path
@@ -999,10 +1428,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
999
1428
  fill="none"
1000
1429
  stroke={pal.flowEdge}
1001
1430
  strokeWidth={width}
1002
- opacity={isLight ? 0.22 : 0.28}
1431
+ opacity={Math.min(1, (isLight ? 0.22 : 0.28) * fresh * edgeOpacityMul)}
1003
1432
  filter={isLight ? undefined : 'url(#topo-glow)'}
1004
- markerEnd="url(#topo-arrow)"
1005
- />
1433
+ markerEnd={`url(#${arrowId})`}
1434
+ className="transition-opacity duration-300"
1435
+ style={{ pointerEvents: 'stroke' }}
1436
+ >
1437
+ <title>{tooltip}</title>
1438
+ </path>
1006
1439
  <path
1007
1440
  id={`flow-path-${index}`}
1008
1441
  d={path}
@@ -1010,37 +1443,56 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1010
1443
  stroke={pal.flowPath}
1011
1444
  strokeWidth="1"
1012
1445
  strokeDasharray="2 12"
1013
- opacity={isLight ? 0.4 : 0.75}
1446
+ opacity={Math.min(1, (isLight ? 0.4 : 0.75) * fresh * edgeOpacityMul)}
1447
+ className="transition-opacity duration-300"
1014
1448
  />
1015
- <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'}>
1016
- <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
1017
- </circle>
1449
+ {!reducedMotion && (
1450
+ <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'} opacity={Math.min(1, fresh * edgeOpacityMul)}>
1451
+ <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
1452
+ </circle>
1453
+ )}
1018
1454
  </g>
1019
1455
  );
1020
1456
  })}
1021
1457
 
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 */}
1458
+ {/* center hub — round 39 sized + round 13 restraint.
1459
+ The hub is the control-plane anchor. r39 gave it two outward
1460
+ pulses (r 10→38, 2.4s, double-phase) which read as "loudest
1461
+ node in the room" wrong semantics: anchors don't emit,
1462
+ they hold. r13 swaps the kinetic pulse for a slow opacity
1463
+ breath on the static halo (4s, ±15% from base), so the hub
1464
+ stays alive without throwing kinetic energy outward. The
1465
+ dual-circle "lit lamp" core is unchanged. */}
1466
+ {layout === 'ring' && (<g data-topo-hub>
1467
+ {/* Round 43 / Loop: hub `<title>` summary — hovering the
1468
+ central glow now answers "what is this?" with a one-line
1469
+ fleet snapshot. Duplicates the header chips by design;
1470
+ hovering the most visually prominent element is the
1471
+ most natural impulse, so satisfy it where the cursor
1472
+ already is. Falls clean: omits sub-clauses when their
1473
+ counts are zero. */}
1474
+ <title>{(() => {
1475
+ const total = sessions.length;
1476
+ const parts = [`Network hub`, `${total} session${total === 1 ? '' : 's'}`];
1477
+ if (onlineNodes.length > 0) parts.push(`${onlineNodes.length} online`);
1478
+ if (workingCount > 0) parts.push(`${workingCount} working`);
1479
+ if (flowLinks.length > 0) parts.push(`${flowLinks.length} active link${flowLinks.length === 1 ? '' : 's'}`);
1480
+ return parts.join(' · ');
1481
+ })()}</title>
1482
+ {/* grounding halo — now breathes in opacity, no expansion */}
1030
1483
  <circle
1031
1484
  cx={cx} cy={cy} r="18"
1032
1485
  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" />
1486
+ opacity={isLight ? 0.42 : 0.12}
1487
+ >
1488
+ {!reducedMotion && (
1489
+ <animate
1490
+ attributeName="opacity"
1491
+ values={isLight ? '0.32;0.52;0.32' : '0.08;0.16;0.08'}
1492
+ dur="4s"
1493
+ repeatCount="indefinite"
1494
+ />
1495
+ )}
1044
1496
  </circle>
1045
1497
  {/* core — 20px diameter, larger inner highlight reads as a "lit lamp" */}
1046
1498
  <circle cx={cx} cy={cy} r="10" fill={isLight ? '#059669' : '#10b981'} />
@@ -1048,7 +1500,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1048
1500
  </g>)}
1049
1501
 
1050
1502
  {/* agent nodes */}
1051
- {[...onlineNodes, ...offlineNodes].map(session => {
1503
+ {[...onlineNodes, ...offlineNodes].map((session, nodeIdx) => {
1052
1504
  const pos = nodePositions[session.alias];
1053
1505
  if (!pos) return null;
1054
1506
 
@@ -1068,13 +1520,48 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1068
1520
  // occlude an avatar), and the full card appears only for the
1069
1521
  // hovered node or once zoomed in past 1.4×.
1070
1522
  const showFullLabel = !denseLayout || hoveredAlias === session.alias || view.zoom >= 1.4;
1523
+ // Round 8: when a node in another group is hovered, fade this
1524
+ // one. Same-group nodes (incl. singletons matching the hovered
1525
+ // alias) stay full. Pure visual focus, geometry unchanged.
1526
+ const inFocus = !hoveredGroup || (groupKeys[session.alias] ?? session.alias) === hoveredGroup;
1071
1527
 
1072
1528
  return (
1073
1529
  <g
1074
1530
  key={session.alias}
1075
1531
  data-node={session.alias}
1076
- className="transition-opacity"
1077
- style={{ cursor: 'pointer' }}
1532
+ // Round 3 / Loop: `anet-fade-in` runs once when the <g>
1533
+ // mounts a new session entering the fleet (or the topology
1534
+ // first rendering) eases in instead of popping. Re-renders of
1535
+ // an existing node don't re-trigger (React preserves the <g>
1536
+ // via the alias key), so status changes don't flicker. The
1537
+ // global prefers-reduced-motion sweep already neutralises it.
1538
+ className="group transition-opacity anet-fade-in"
1539
+ style={{
1540
+ cursor: 'pointer',
1541
+ // Round 17 / Loop: offline nodes drop to 0.6 at rest so
1542
+ // online nodes pop without losing the offline-as-ghost
1543
+ // information. The dashed stroke + smaller radius already
1544
+ // say "offline"; dimming the whole group strengthens the
1545
+ // online-vs-offline hierarchy at first glance. Exempt the
1546
+ // chat-focused node — if the user explicitly opened a
1547
+ // popover targeting an offline alias, that node stays
1548
+ // full-brightness so the focus ring + popover read as one
1549
+ // selected thing rather than a dimmed selection. Group-
1550
+ // hover fade (Round 8) still wins when inFocus is false.
1551
+ opacity: !inFocus
1552
+ ? 0.32
1553
+ : chatAlias === session.alias
1554
+ ? 1
1555
+ : isOnline ? 1 : 0.6,
1556
+ // Round 9 / Loop: stagger the anet-fade-in so the topology
1557
+ // reveals as a wave on first paint instead of one big pop.
1558
+ // Cap at 24 indices (≈600ms tail) so 50-node fleets still
1559
+ // finish revealing within a beat. CSS animation-delay only
1560
+ // applies during the keyframe — re-renders without a new
1561
+ // mount (same alias key) don't replay, so status changes
1562
+ // never trigger the stagger again.
1563
+ animationDelay: `${Math.min(nodeIdx, 24) * 25}ms`,
1564
+ }}
1078
1565
  // Stop the pointerdown from reaching the SVG pan handler: the
1079
1566
  // SVG calls setPointerCapture, and a captured pointer makes
1080
1567
  // Chromium fire the follow-up `click` on the SVG instead of
@@ -1082,21 +1569,106 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1082
1569
  // Side effect (intended): you pan from empty canvas, not by
1083
1570
  // grabbing a node.
1084
1571
  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)}
1572
+ // Track hover globally (not just under denseLayout) so the
1573
+ // Round 8 group-focus fade works at any fleet size.
1574
+ onPointerEnter={() => setHoveredAlias(session.alias)}
1575
+ onPointerLeave={() => setHoveredAlias(prev => (prev === session.alias ? null : prev))}
1576
+ onClick={() => {
1577
+ setChatAlias(session.alias);
1578
+ // Round 14 ripple — capture position, radius and status
1579
+ // colour at click time so the one-shot circle is self-
1580
+ // contained (no re-render-time recomputation needed).
1581
+ setClickRipple({
1582
+ ts: Date.now(),
1583
+ x: pos.x, y: pos.y, r0: radius,
1584
+ color: status.primary,
1585
+ });
1586
+ setTimeout(() => setClickRipple(prev =>
1587
+ prev && Date.now() - prev.ts >= 590 ? null : prev), 600);
1588
+ }}
1088
1589
  >
1089
1590
  {/* Issue #96: native hover tooltip — "Vendor · model · Runtime".
1090
1591
  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 && (
1592
+ model/runtime.
1593
+ Round 33 / Loop: cwd line answers "what is this agent on?".
1594
+ Round 34 / Loop: for offline nodes, append "last seen: 6m ago"
1595
+ so the operator knows whether to wait or chase. Online nodes
1596
+ skip the line (Round 27's 1 h ghost age-out means anything
1597
+ still online has heartbeated recently — the line would just
1598
+ be noise). Accepts both ISO ("…T06:00:28Z") and SQL-style
1599
+ ("… 06:00:28" assumed UTC) formats. */}
1600
+ {(() => {
1601
+ // Round 35 / Loop: TZ-safe parsing via the shared lib helper
1602
+ // — same parseHubTime is used by isGhost above (Round 38
1603
+ // factored it to app/lib/time.ts so both paths interpret SQL
1604
+ // bare timestamps as UTC on every browser).
1605
+ const lastSeen = !isOnline && session.last_seen_at ? relativeAgo(session.last_seen_at) : null;
1606
+ return (
1607
+ <title>{[
1608
+ identityLine(session.model, session.runtime) || session.alias,
1609
+ session.project_dir ? `cwd: ${session.project_dir}` : null,
1610
+ lastSeen ? `last seen: ${lastSeen}` : null,
1611
+ ].filter(Boolean).join('\n')}</title>
1612
+ );
1613
+ })()}
1614
+ {/* Round 2 / Loop: hover ring — a thin outer stroke that fades
1615
+ in when the cursor enters the node, signalling clickability
1616
+ (real-user feedback for the chat-popover open). Pure CSS via
1617
+ Tailwind group-hover, so it costs nothing per frame and
1618
+ respects prefers-reduced-motion via the global media query. */}
1619
+ <circle
1620
+ cx={pos.x}
1621
+ cy={pos.y}
1622
+ r={radius + 12}
1623
+ fill="none"
1624
+ stroke={status.primary}
1625
+ // strokeWidth must NOT be 1.5 (offline status ring) or 3
1626
+ // (online status ring) — the overlap test selects by those
1627
+ // exact widths and would mis-count this invisible hover
1628
+ // ring as a node footprint.
1629
+ strokeWidth="2"
1630
+ className="opacity-0 group-hover:opacity-70 transition-opacity duration-150"
1631
+ style={{ pointerEvents: 'none' }}
1632
+ />
1633
+ {/* Round 11 / Loop: chat-focus ring — when the ChatPopover is
1634
+ open targeting this node, anchor a persistent ring around
1635
+ it so the floating popover visibly links back to its source
1636
+ node. Static (not pulsing) so it reads as "selected state"
1637
+ rather than "this node is active". strokeWidth=2.5 stays
1638
+ clear of the overlap-test selectors (1.5 / 3). Sits just
1639
+ outside the halo radius+8 so it never overlaps a neighbour
1640
+ (halos already pack flush in dense grids). */}
1641
+ {chatAlias === session.alias && (
1642
+ <circle
1643
+ cx={pos.x}
1644
+ cy={pos.y}
1645
+ r={radius + 14}
1646
+ fill="none"
1647
+ stroke={status.primary}
1648
+ strokeWidth="2.5"
1649
+ opacity={isLight ? 0.85 : 0.95}
1650
+ filter={!isLight ? 'url(#topo-glow)' : undefined}
1651
+ className="transition-opacity duration-200"
1652
+ style={{ pointerEvents: 'none' }}
1653
+ />
1654
+ )}
1655
+ {isActive && !reducedMotion && (
1094
1656
  <circle cx={pos.x} cy={pos.y} r={radius + 14} fill={status.primary} opacity={isLight ? 0.08 : 0.12}>
1095
1657
  <animate attributeName="r" values={`${radius + 8};${radius + 22};${radius + 8}`} dur="2.4s" repeatCount="indefinite" />
1096
1658
  <animate attributeName="opacity" values={isLight ? '0.12;0.02;0.12' : '0.18;0.04;0.18'} dur="2.4s" repeatCount="indefinite" />
1097
1659
  </circle>
1098
1660
  )}
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)} />
1661
+ {/* Round 4 / Loop: transition-[fill,stroke,opacity] smooths
1662
+ status colour changes so idle↔working↔offline doesn't snap
1663
+ — task replies / node-rename / SSE updates ease in. */}
1664
+ <circle
1665
+ cx={pos.x}
1666
+ cy={pos.y}
1667
+ r={radius + 8}
1668
+ fill={status.halo}
1669
+ opacity={isOnline ? (isLight ? 0.85 : 0.65) : (isLight ? 0.4 : 0.25)}
1670
+ className="transition-[fill,opacity] duration-300 ease-out"
1671
+ />
1100
1672
  <circle
1101
1673
  cx={pos.x}
1102
1674
  cy={pos.y}
@@ -1106,6 +1678,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1106
1678
  strokeWidth={isOnline ? 3 : 1.5}
1107
1679
  strokeDasharray={isOnline ? 'none' : '5 5'}
1108
1680
  filter={isOnline && !isLight ? 'url(#topo-glow)' : undefined}
1681
+ className="transition-[fill,stroke] duration-300 ease-out"
1109
1682
  />
1110
1683
  {/* Issue #96: node "avatar" is now driven by the model
1111
1684
  vendor. Decision order:
@@ -1192,84 +1765,218 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1192
1765
  </g>
1193
1766
  );
1194
1767
  })()}
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
- )}
1768
+ {session.status === 'working' && (() => {
1769
+ // Round 24 / Loop: pulse rate traffic. The dot's dur
1770
+ // was a flat 1.1s every working node looked equally
1771
+ // busy. Mapping it to sse:N lets the eye land on what's
1772
+ // actually hot. 3 discrete tiers read cleaner than
1773
+ // continuous easing at a glance; thresholds picked so
1774
+ // typical 1-2-SSE Claude/Codex sessions stay in the
1775
+ // "active" tier and only multi-pane orchestrators pop
1776
+ // into the "busy" tier. Geometry unchanged (r=2.5).
1777
+ const sse = sseCountFor ?? 0;
1778
+ const dur = sse >= 4 ? '0.7s' : sse >= 2 ? '0.9s' : '1.2s';
1779
+ return (
1780
+ <circle cx={pos.x} cy={pos.y - (radius - 6)} r="2.5" fill={pal.flowParticle} data-pulse-dur={dur} opacity={reducedMotion ? 0.6 : undefined}>
1781
+ {!reducedMotion && (
1782
+ <animate attributeName="opacity" values="1;0.25;1" dur={dur} repeatCount="indefinite" />
1783
+ )}
1784
+ </circle>
1785
+ );
1786
+ })()}
1204
1787
 
1205
1788
  {/* Round 98 (issue #61): label rect 124px → 100px.
1206
1789
  Round 109/110 (Vincent P0): full opaque card below the
1207
1790
  density threshold / on hover / when zoomed; otherwise a
1208
1791
  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}` : ''}
1792
+ labelled without an opaque box covering its neighbours.
1793
+ Round 15 / Loop: when nodeScale=S the node shrinks 30%
1794
+ but the label card was staying full-size, so the label
1795
+ visually outweighed the small node. Tighten the card
1796
+ frame, alias / sub fontSize, drop-offset and truncate
1797
+ length specifically for S; M and L keep their existing
1798
+ sizes (M ≈ L for labels by design — the S user is the
1799
+ one who explicitly asked for a denser view). */}
1800
+ {(() => {
1801
+ const isSmall = nodeScale < 0.8;
1802
+ const cardW = isSmall ? 88 : 100;
1803
+ const cardH = isSmall ? 36 : 42;
1804
+ const cardTopY = isSmall ? -12 : -14;
1805
+ const aliasFs = isSmall ? 11 : 12;
1806
+ const subFs = isSmall ? 8 : 9;
1807
+ const subY = isSmall ? 15 : 17;
1808
+ const dropY = isSmall ? 18 : 22;
1809
+ const fullMax = isSmall ? 11 : 12;
1810
+ const denseFs = isSmall ? 9 : 10;
1811
+ const denseDrop = isSmall ? 12 : 14;
1812
+ // Round 26 / Loop: micro-lift the label group on hover —
1813
+ // 1.5 px upward, 200 ms ease. Pairs with the Round 18
1814
+ // group-box hover-accent treatment to give the same
1815
+ // "this is the focused element" feedback at the per-
1816
+ // node level. CSS transform stacks onto the SVG
1817
+ // positioning transform attribute (SVG 2 cascade);
1818
+ // bbox is unchanged at rest, so the overlap-test gate
1819
+ // continues to see the geometric layout it expects.
1820
+ return showFullLabel ? (
1821
+ <g transform={`translate(${pos.x}, ${pos.y + radius + dropY})`} style={{ pointerEvents: 'none' }}
1822
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]">
1823
+ <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} />
1824
+ <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize={aliasFs} fontFamily="monospace" fontWeight="700">
1825
+ {truncate(session.alias, fullMax)}
1826
+ </text>
1827
+ <text x="0" y={subY} textAnchor="middle" fill={status.primary} fontSize={subFs} fontFamily="monospace">
1828
+ {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
1829
+ </text>
1830
+ </g>
1831
+ ) : (
1832
+ <text
1833
+ x={pos.x}
1834
+ y={pos.y + radius + denseDrop}
1835
+ textAnchor="middle"
1836
+ fill={status.text}
1837
+ fontSize={denseFs}
1838
+ fontFamily="monospace"
1839
+ fontWeight="700"
1840
+ opacity={0.9}
1841
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]"
1842
+ style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
1843
+ stroke={pal.containerBg}
1844
+ strokeWidth="3"
1845
+ >
1846
+ {truncate(session.alias, isSmall ? 9 : 10)}
1218
1847
  </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
- )}
1848
+ );
1849
+ })()}
1237
1850
  </g>
1238
1851
  );
1239
1852
  })}
1240
1853
 
1854
+ {/* Round 14 / Loop: click ripple — one-shot expanding ring from
1855
+ the clicked node, ~500ms, status-coloured. Sits inside the
1856
+ zoom/pan <g> so it scales / pans with the topology. Keyed by
1857
+ ts so a re-click on any node (same or different) remounts the
1858
+ <circle> and the SMIL <animate> elements replay from t=0.
1859
+ strokeWidth=2 doesn't match the overlap-test selectors. */}
1860
+ {clickRipple && !reducedMotion && (
1861
+ <circle
1862
+ key={clickRipple.ts}
1863
+ cx={clickRipple.x}
1864
+ cy={clickRipple.y}
1865
+ r={clickRipple.r0 + 4}
1866
+ fill="none"
1867
+ stroke={clickRipple.color}
1868
+ strokeWidth="2"
1869
+ opacity="0"
1870
+ style={{ pointerEvents: 'none' }}
1871
+ >
1872
+ <animate attributeName="r" values={`${clickRipple.r0 + 4};${clickRipple.r0 + 30}`} dur="0.5s" fill="freeze" />
1873
+ <animate attributeName="opacity" values="0.7;0" dur="0.5s" fill="freeze" />
1874
+ </circle>
1875
+ )}
1876
+
1241
1877
  </g>
1242
1878
 
1243
1879
  {/* #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. */}
1880
+ zoom/pan <g> so they stay fixed while the topology pans/zooms.
1881
+ They're sized + tucked into the top corners so every corner of
1882
+ each panel is >325px from the canvas centre i.e. fully outside
1883
+ the outermost (offline) ring. No node on any ring can reach the
1884
+ corner triangles, so the panels never overlap a node, in ring
1885
+ OR grid layout (Vincent 4727 zero-overlap criterion). */}
1248
1886
  {/* 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)}
1887
+ <g transform="translate(16, 16)">
1888
+ <rect x="0" y="0" width="230" height="84" rx="10" fill={pal.legendBox.fill} stroke={pal.legendBox.stroke} opacity={isLight ? 0.97 : 0.92} />
1889
+ <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700">recent signal</text>
1890
+ <text x="150" y="21" fill={pal.legendAccent} fontSize="10" fontFamily="monospace">{messages.length} msgs</text>
1891
+ {/* Round 45 / Loop: empty state. The panel used to render
1892
+ "recent signal" + "0 msgs" with three blank slots below
1893
+ when no flow yet read as "broken" rather than "quiet".
1894
+ A muted centred placeholder makes the empty state
1895
+ deliberate. Messages count CAN diverge from flowLinks
1896
+ count (raw count vs. deduped pairs), so the placeholder
1897
+ fires on flowLinks.length=0 specifically. */}
1898
+ {flowLinks.length === 0 ? (
1899
+ <text x="115" y="60" textAnchor="middle" fill={pal.legendText} fontSize="10" fontFamily="monospace" fontStyle="italic" opacity={0.65} data-recent-signal-empty>
1900
+ no flow yet
1256
1901
  </text>
1257
- ))}
1902
+ ) : (
1903
+ flowLinks.slice(0, 3).map((link, index) => (
1904
+ <text key={link.key} x="13" y={38 + index * 16} fill={pal.legendText} fontSize="9" fontFamily="monospace">
1905
+ {truncate(link.from, 6)} {'->'} {truncate(link.to, 6)} / {link.count} / {truncate(link.content, 12)}
1906
+ </text>
1907
+ ))
1908
+ )}
1258
1909
  </g>
1259
1910
 
1260
1911
  {/* 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)" />
1912
+ <g transform="translate(760, 16)">
1913
+ <rect x="0" y="0" width="224" height="96" rx="10" fill={pal.legendBox.fill} stroke={pal.legendBox.stroke} opacity={isLight ? 0.97 : 0.92} />
1914
+ <circle cx="16" cy="24" r="5.5" fill={isLight ? '#059669' : '#22c55e'} />
1915
+ <text x="30" y="28" fill={pal.legendText} fontSize="11" fontFamily="monospace">working node</text>
1916
+ <circle cx="16" cy="48" r="5.5" fill={isLight ? '#0d9488' : '#2dd4bf'} />
1917
+ <text x="30" y="52" fill={pal.legendText} fontSize="11" fontFamily="monospace">online idle</text>
1918
+ <circle cx="16" cy="72" r="5.5" fill={isLight ? '#94a3b8' : '#6b7280'} />
1919
+ <text x="30" y="76" fill={pal.legendText} fontSize="11" fontFamily="monospace">offline / no SSE</text>
1920
+ <path d="M140,72 Q164,48 196,72" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" />
1270
1921
  </g>
1271
1922
  </svg>
1272
1923
 
1924
+ {/* Round 30 / Loop: minimap. Big fleets in fullscreen mode at high
1925
+ zoom let users lose their position — the minimap shows the
1926
+ whole topology miniaturised plus a viewport rectangle so the
1927
+ user always knows where they are. Click anywhere to recenter
1928
+ the canvas there. Only mounted when the view is non-default
1929
+ (zoomed or panned) since at 1× centered the minimap and the
1930
+ canvas show the same thing. HTML overlay so it stays fixed
1931
+ while the SVG transforms. */}
1932
+ {(() => {
1933
+ const isDefaultView = Math.abs(view.zoom - 1) < 0.01 && Math.abs(view.x) < 1 && Math.abs(view.y) < 1;
1934
+ if (isDefaultView || (onlineNodes.length + offlineNodes.length) === 0) return null;
1935
+ const MW = 120, MH = 82;
1936
+ const sx = MW / VIEWBOX_W, sy = MH / VIEWBOX_H;
1937
+ const rectX = (-view.x / view.zoom) * sx;
1938
+ const rectY = (-view.y / view.zoom) * sy;
1939
+ const rectW = (VIEWBOX_W / view.zoom) * sx;
1940
+ const rectH = (VIEWBOX_H / view.zoom) * sy;
1941
+ return (
1942
+ <div
1943
+ className="absolute right-3 rounded-md border shadow-lg shadow-black/30 overflow-hidden anet-fade-in"
1944
+ style={{ bottom: 56, background: pal.legendBox.fill, borderColor: pal.containerBorder, cursor: 'crosshair' }}
1945
+ role="img"
1946
+ aria-label="Topology minimap — click to recenter"
1947
+ title="Minimap · click to recenter"
1948
+ onClick={(e) => {
1949
+ const r = e.currentTarget.getBoundingClientRect();
1950
+ const fx = (e.clientX - r.left) / r.width;
1951
+ const fy = (e.clientY - r.top) / r.height;
1952
+ setView(prev => ({
1953
+ ...prev,
1954
+ x: VIEWBOX_W / 2 - fx * VIEWBOX_W * prev.zoom,
1955
+ y: VIEWBOX_H / 2 - fy * VIEWBOX_H * prev.zoom,
1956
+ }));
1957
+ }}
1958
+ data-topo-minimap
1959
+ >
1960
+ <svg width={MW} height={MH} viewBox={`0 0 ${MW} ${MH}`} style={{ display: 'block' }}>
1961
+ {[...onlineNodes, ...offlineNodes].map(s => {
1962
+ const p = nodePositions[s.alias];
1963
+ if (!p) return null;
1964
+ const sseN = (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
1965
+ const isOn = s.status !== 'offline' || !!sseN;
1966
+ const st = nodeStatus(s, isOn, isLight);
1967
+ 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} />;
1968
+ })}
1969
+ {/* viewport rectangle */}
1970
+ <rect
1971
+ x={Math.max(0, rectX)} y={Math.max(0, rectY)}
1972
+ width={Math.max(0, Math.min(MW - Math.max(0, rectX), rectW))}
1973
+ height={Math.max(0, Math.min(MH - Math.max(0, rectY), rectH))}
1974
+ fill="none" stroke={pal.legendAccent} strokeWidth="1" opacity="0.9"
1975
+ />
1976
+ </svg>
1977
+ </div>
1978
+ );
1979
+ })()}
1273
1980
  {/* Round 103 (issue #81): zoom / pan / fullscreen controls — HTML
1274
1981
  overlay so they stay fixed while the SVG content transforms.
1275
1982
  Round 104: Vincent 实测 — the reset action used to be hidden
@@ -1306,6 +2013,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1306
2013
  className="px-2 py-1 hover:bg-white/5 transition-colors"
1307
2014
  style={{ color: pal.legendText }}
1308
2015
  aria-label="Zoom out"
2016
+ title="Zoom out (−)"
1309
2017
  >
1310
2018
  <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
2019
  </button>
@@ -1321,6 +2029,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1321
2029
  className="px-2 py-1 hover:bg-white/5 transition-colors"
1322
2030
  style={{ color: pal.legendText }}
1323
2031
  aria-label="Zoom in"
2032
+ title="Zoom in (+)"
1324
2033
  >
1325
2034
  <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
2035
  </button>
@@ -1330,7 +2039,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1330
2039
  className="p-1.5 rounded-md border hover:bg-white/5 transition-colors"
1331
2040
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
1332
2041
  aria-label="Reset view"
1333
- title="Reset zoom + pan (or double-click the canvas)"
2042
+ title="Reset zoom + pan (0, or double-click the canvas)"
1334
2043
  >
1335
2044
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1336
2045
  <path d="M3 12a9 9 0 1 0 9-9 9 9 0 0 0-6.4 2.6L3 8" />