@sleep2agi/agent-network-dashboard 0.6.3-preview.43 → 0.6.3-preview.45

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 (246) hide show
  1. package/.next/BUILD_COMMIT +1 -0
  2. package/.next/BUILD_ID +1 -1
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/diagnostics/route-bundle-stats.json +78 -78
  5. package/.next/fallback-build-manifest.json +3 -3
  6. package/.next/server/app/_global-error.html +1 -1
  7. package/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  14. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  15. package/.next/server/app/_not-found.html +2 -2
  16. package/.next/server/app/_not-found.rsc +12 -12
  17. package/.next/server/app/_not-found.segments/_full.segment.rsc +12 -12
  18. package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  19. package/.next/server/app/_not-found.segments/_index.segment.rsc +7 -7
  20. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  21. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  22. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  23. package/.next/server/app/admin/page.js.nft.json +1 -1
  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 +1 -1
  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/index.html +2 -2
  34. package/.next/server/app/index.rsc +14 -14
  35. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  36. package/.next/server/app/index.segments/_full.segment.rsc +14 -14
  37. package/.next/server/app/index.segments/_head.segment.rsc +4 -4
  38. package/.next/server/app/index.segments/_index.segment.rsc +7 -7
  39. package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  40. package/.next/server/app/login/page.js.nft.json +1 -1
  41. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  42. package/.next/server/app/login.html +2 -2
  43. package/.next/server/app/login.rsc +14 -14
  44. package/.next/server/app/login.segments/_full.segment.rsc +14 -14
  45. package/.next/server/app/login.segments/_head.segment.rsc +4 -4
  46. package/.next/server/app/login.segments/_index.segment.rsc +7 -7
  47. package/.next/server/app/login.segments/_tree.segment.rsc +1 -1
  48. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
  49. package/.next/server/app/login.segments/login.segment.rsc +3 -3
  50. package/.next/server/app/logs/page.js.nft.json +1 -1
  51. package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
  52. package/.next/server/app/logs.html +2 -2
  53. package/.next/server/app/logs.rsc +14 -14
  54. package/.next/server/app/logs.segments/_full.segment.rsc +14 -14
  55. package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
  56. package/.next/server/app/logs.segments/_index.segment.rsc +7 -7
  57. package/.next/server/app/logs.segments/_tree.segment.rsc +1 -1
  58. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
  59. package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
  60. package/.next/server/app/messages/page.js.nft.json +1 -1
  61. package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
  62. package/.next/server/app/messages.html +2 -2
  63. package/.next/server/app/messages.rsc +14 -14
  64. package/.next/server/app/messages.segments/_full.segment.rsc +14 -14
  65. package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
  66. package/.next/server/app/messages.segments/_index.segment.rsc +7 -7
  67. package/.next/server/app/messages.segments/_tree.segment.rsc +1 -1
  68. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
  69. package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
  70. package/.next/server/app/node/page.js.nft.json +1 -1
  71. package/.next/server/app/node/page_client-reference-manifest.js +1 -1
  72. package/.next/server/app/node.html +2 -2
  73. package/.next/server/app/node.rsc +14 -14
  74. package/.next/server/app/node.segments/_full.segment.rsc +14 -14
  75. package/.next/server/app/node.segments/_head.segment.rsc +4 -4
  76. package/.next/server/app/node.segments/_index.segment.rsc +7 -7
  77. package/.next/server/app/node.segments/_tree.segment.rsc +1 -1
  78. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +4 -4
  79. package/.next/server/app/node.segments/node.segment.rsc +3 -3
  80. package/.next/server/app/nodes/page.js.nft.json +1 -1
  81. package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
  82. package/.next/server/app/nodes.html +2 -2
  83. package/.next/server/app/nodes.rsc +14 -14
  84. package/.next/server/app/nodes.segments/_full.segment.rsc +14 -14
  85. package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
  86. package/.next/server/app/nodes.segments/_index.segment.rsc +7 -7
  87. package/.next/server/app/nodes.segments/_tree.segment.rsc +1 -1
  88. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
  89. package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
  90. package/.next/server/app/page.js.nft.json +1 -1
  91. package/.next/server/app/page_client-reference-manifest.js +1 -1
  92. package/.next/server/app/providers/page.js.nft.json +1 -1
  93. package/.next/server/app/providers/page_client-reference-manifest.js +1 -1
  94. package/.next/server/app/providers.html +2 -2
  95. package/.next/server/app/providers.rsc +14 -14
  96. package/.next/server/app/providers.segments/_full.segment.rsc +14 -14
  97. package/.next/server/app/providers.segments/_head.segment.rsc +4 -4
  98. package/.next/server/app/providers.segments/_index.segment.rsc +7 -7
  99. package/.next/server/app/providers.segments/_tree.segment.rsc +1 -1
  100. package/.next/server/app/providers.segments/providers/__PAGE__.segment.rsc +4 -4
  101. package/.next/server/app/providers.segments/providers.segment.rsc +3 -3
  102. package/.next/server/app/server-logs/page.js.nft.json +1 -1
  103. package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
  104. package/.next/server/app/server-logs.html +2 -2
  105. package/.next/server/app/server-logs.rsc +14 -14
  106. package/.next/server/app/server-logs.segments/_full.segment.rsc +14 -14
  107. package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
  108. package/.next/server/app/server-logs.segments/_index.segment.rsc +7 -7
  109. package/.next/server/app/server-logs.segments/_tree.segment.rsc +1 -1
  110. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +4 -4
  111. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
  112. package/.next/server/app/servers/page.js.nft.json +1 -1
  113. package/.next/server/app/servers/page_client-reference-manifest.js +1 -1
  114. package/.next/server/app/servers.html +2 -2
  115. package/.next/server/app/servers.rsc +14 -14
  116. package/.next/server/app/servers.segments/_full.segment.rsc +14 -14
  117. package/.next/server/app/servers.segments/_head.segment.rsc +4 -4
  118. package/.next/server/app/servers.segments/_index.segment.rsc +7 -7
  119. package/.next/server/app/servers.segments/_tree.segment.rsc +1 -1
  120. package/.next/server/app/servers.segments/servers/__PAGE__.segment.rsc +4 -4
  121. package/.next/server/app/servers.segments/servers.segment.rsc +3 -3
  122. package/.next/server/app/settings/networks/page.js.nft.json +1 -1
  123. package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
  124. package/.next/server/app/settings/networks.html +2 -2
  125. package/.next/server/app/settings/networks.rsc +14 -14
  126. package/.next/server/app/settings/networks.segments/_full.segment.rsc +14 -14
  127. package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
  128. package/.next/server/app/settings/networks.segments/_index.segment.rsc +7 -7
  129. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +1 -1
  130. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +4 -4
  131. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
  132. package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
  133. package/.next/server/app/settings/page.js.nft.json +1 -1
  134. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  135. package/.next/server/app/settings/tokens/page.js.nft.json +1 -1
  136. package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
  137. package/.next/server/app/settings/tokens.html +2 -2
  138. package/.next/server/app/settings/tokens.rsc +14 -14
  139. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +14 -14
  140. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
  141. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +7 -7
  142. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +1 -1
  143. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +4 -4
  144. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
  145. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
  146. package/.next/server/app/settings.html +2 -2
  147. package/.next/server/app/settings.rsc +14 -14
  148. package/.next/server/app/settings.segments/_full.segment.rsc +14 -14
  149. package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
  150. package/.next/server/app/settings.segments/_index.segment.rsc +7 -7
  151. package/.next/server/app/settings.segments/_tree.segment.rsc +1 -1
  152. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
  153. package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
  154. package/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
  155. package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  156. package/.next/server/app/tasks/page.js.nft.json +1 -1
  157. package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
  158. package/.next/server/app/tasks.html +2 -2
  159. package/.next/server/app/tasks.rsc +14 -14
  160. package/.next/server/app/tasks.segments/_full.segment.rsc +14 -14
  161. package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
  162. package/.next/server/app/tasks.segments/_index.segment.rsc +7 -7
  163. package/.next/server/app/tasks.segments/_tree.segment.rsc +1 -1
  164. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
  165. package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
  166. package/.next/server/chunks/ssr/[root-of-the-server]__0jns048._.js +3 -0
  167. package/.next/server/chunks/ssr/[root-of-the-server]__0jns048._.js.map +1 -0
  168. package/.next/server/chunks/ssr/[root-of-the-server]__0va6h-y._.js +3 -3
  169. package/.next/server/chunks/ssr/[root-of-the-server]__0va6h-y._.js.map +1 -1
  170. package/.next/server/chunks/ssr/{[root-of-the-server]__056tw5g._.js → [root-of-the-server]__0z0lvdf._.js} +2 -2
  171. package/.next/server/chunks/ssr/[root-of-the-server]__0z0lvdf._.js.map +1 -0
  172. package/.next/server/chunks/ssr/[root-of-the-server]__13svd64._.js +3 -0
  173. package/.next/server/chunks/ssr/[root-of-the-server]__13svd64._.js.map +1 -0
  174. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_0f4savf._.js +3 -3
  175. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_0f4savf._.js.map +1 -1
  176. package/.next/server/chunks/ssr/{dash-492_agent-network-dashboard_app_12yiaen._.js → dash-492_agent-network-dashboard_app_07foku.._.js} +2 -2
  177. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_07foku.._.js.map +1 -0
  178. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0fv9~x1._.js +1 -1
  179. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0fv9~x1._.js.map +1 -1
  180. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0jey2is._.js +3 -0
  181. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0jey2is._.js.map +1 -0
  182. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0tbukk6._.js +3 -0
  183. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0tbukk6._.js.map +1 -0
  184. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_10nv4wt._.js +3 -0
  185. package/.next/server/chunks/ssr/{dash-492_agent-network-dashboard_app_02~ml1y._.js.map → dash-492_agent-network-dashboard_app_10nv4wt._.js.map} +1 -1
  186. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_components_07gcm8v._.js +3 -3
  187. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_components_07gcm8v._.js.map +1 -1
  188. package/.next/server/middleware-build-manifest.js +3 -3
  189. package/.next/server/pages/404.html +2 -2
  190. package/.next/server/pages/500.html +1 -1
  191. package/.next/static/chunks/0.t9q6b8-tp7o.js +1 -0
  192. package/.next/static/chunks/{07hmiozabjis~.js → 00.lep~gx75xc.js} +1 -1
  193. package/.next/static/chunks/0casvy.vqveum.js +7 -0
  194. package/.next/static/chunks/0e3gktr80d3im.js +2 -0
  195. package/.next/static/chunks/0gv~xarphwy7~.js +1 -0
  196. package/.next/static/chunks/0jgo1j.i.wd5q.js +7 -0
  197. package/.next/static/chunks/0k_3kdj.1c2m5.js +1 -0
  198. package/.next/static/chunks/0uj3h67aerffm.js +1 -0
  199. package/.next/static/chunks/0~rml5aiu3yw7.js +7 -0
  200. package/.next/static/chunks/0~v0m8f1.aob5.js +4 -0
  201. package/.next/static/chunks/16._eot2r-z1r.js +1 -0
  202. package/.next/static/chunks/16kecv_vyjyyv.js +1 -0
  203. package/.next/trace +2 -2
  204. package/.next/trace-build +1 -1
  205. package/app/components/ChatPopover.tsx +9 -2
  206. package/app/components/HealthBanner.tsx +21 -2
  207. package/app/components/Sidebar.tsx +21 -2
  208. package/app/components/StatsBar.tsx +32 -14
  209. package/app/components/TaskChatPanel.tsx +11 -3
  210. package/app/components/TopoGraph.tsx +25 -11
  211. package/app/lib/presence.ts +98 -0
  212. package/app/page.tsx +23 -14
  213. package/package.json +1 -1
  214. package/tests/e2e-515-presence-predicate.spec.ts +75 -0
  215. package/tests/e2e-515-presence.spec.ts +148 -0
  216. package/tests/e2e-53-blind-vs-empty.spec.ts +262 -0
  217. package/tests/e2e-53-presence-status.spec.ts +52 -0
  218. package/.next/server/chunks/ssr/[root-of-the-server]__056tw5g._.js.map +0 -1
  219. package/.next/server/chunks/ssr/[root-of-the-server]__06g2-qq._.js +0 -3
  220. package/.next/server/chunks/ssr/[root-of-the-server]__06g2-qq._.js.map +0 -1
  221. package/.next/server/chunks/ssr/[root-of-the-server]__0qv41lg._.js +0 -3
  222. package/.next/server/chunks/ssr/[root-of-the-server]__0qv41lg._.js.map +0 -1
  223. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_02~ml1y._.js +0 -3
  224. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_04nejob._.js +0 -3
  225. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_04nejob._.js.map +0 -1
  226. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0kehfih._.js +0 -3
  227. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0kehfih._.js.map +0 -1
  228. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0vvr81v._.js +0 -3
  229. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0vvr81v._.js.map +0 -1
  230. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0~9qq2o._.js +0 -3
  231. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_0~9qq2o._.js.map +0 -1
  232. package/.next/server/chunks/ssr/dash-492_agent-network-dashboard_app_12yiaen._.js.map +0 -1
  233. package/.next/static/chunks/00kg.oomong9m.js +0 -1
  234. package/.next/static/chunks/02a6-zbyr.zoy.js +0 -2
  235. package/.next/static/chunks/06ww0tn_jfcfd.js +0 -1
  236. package/.next/static/chunks/0_90al8hntttb.js +0 -1
  237. package/.next/static/chunks/0idb.71vziyhz.js +0 -7
  238. package/.next/static/chunks/0qw7i_y5i75.q.js +0 -1
  239. package/.next/static/chunks/0szzm0ma-335n.js +0 -4
  240. package/.next/static/chunks/0xxfl.bqy.bd4.js +0 -7
  241. package/.next/static/chunks/0yscbgm2a_d1u.js +0 -1
  242. package/.next/static/chunks/0zj956.f-k1jj.js +0 -7
  243. package/.next/static/chunks/17ns4oqzib99e.js +0 -1
  244. /package/.next/static/{BXw6vds1QR_zb-M-KgpT7 → EEoQxe-lggOsAx0h8hIFx}/_buildManifest.js +0 -0
  245. /package/.next/static/{BXw6vds1QR_zb-M-KgpT7 → EEoQxe-lggOsAx0h8hIFx}/_clientMiddlewareManifest.js +0 -0
  246. /package/.next/static/{BXw6vds1QR_zb-M-KgpT7 → EEoQxe-lggOsAx0h8hIFx}/_ssgManifest.js +0 -0
@@ -11,6 +11,7 @@ import { vendorForModel, runtimeIdentity, identityLine } from '../lib/vendorIden
11
11
  import { parseHubTime, relativeAgo } from '../lib/time';
12
12
  import { DASHBOARD_VERSION } from '../lib/version';
13
13
  import { useChatUnread } from '../lib/chat-unread';
14
+ import { isOnline as presenceIsOnline, sseCountFor as presenceSseCountFor } from '../lib/presence';
14
15
 
15
16
  /** v0.10.0 Hero 1+2 / §3.F server-health hook — fetches the normalized
16
17
  * /api/hub/servers payload (preview.370 unblocked real-data via the
@@ -817,8 +818,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
817
818
  treeConnectors,
818
819
  treeContentBox,
819
820
  } = useMemo(() => {
820
- const sseCount = (s: { alias: string; network_id?: string }) =>
821
- (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
821
+ // #515: unified with page.tsx / Sidebar.tsx via app/lib/presence.ts.
822
+ const sseCount = (s: { alias: string; network_id?: string }) => presenceSseCountFor(s, sseSessions);
822
823
  // Round 106 (issue #83): sort by alias so same-prefix agents
823
824
  // (通信龙 / 通信牛 / 通信工程马 …, or 研究员1号 / 研究员2号 …) end up
824
825
  // adjacent in the array — the tier layout below assigns angles by
@@ -837,6 +838,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
837
838
  // come back while removing dead nodes well within an operator session.
838
839
  const GHOST_MS = 60 * 60 * 1000;
839
840
  const now = Date.now();
841
+ // #515 note: isGhost is deliberately NOT converted to presenceIsOnline —
842
+ // it's a different concept (age-based pruning of stale offline nodes) and
843
+ // its `status !== 'offline'` arm gives an intentional aliveness buffer:
844
+ // a node the hub still tags as `working` gets to stick around for the
845
+ // full GHOST_MS even if SSE dropped. Tightening that would silently
846
+ // shrink the visible topology on flaky-SSE hubs — separate change.
840
847
  const isGhost = (s: Session) => {
841
848
  if (s.status !== 'offline' || sseCount(s) || !s.last_seen_at) return false;
842
849
  // Round 35 / Loop: parseHubTime normalises SQL-style timestamps to UTC
@@ -845,8 +852,11 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
845
852
  const t = parseHubTime(s.last_seen_at);
846
853
  return t !== null && now - t > GHOST_MS;
847
854
  };
848
- const online = sessions.filter(s => s.status !== 'offline' || sseCount(s)).sort(byAlias);
849
- const offline = sessions.filter(s => s.status === 'offline' && !sseCount(s) && !isGhost(s)).sort(byAlias);
855
+ // #515: online = SSE-reachable (shared with page.tsx / Sidebar via presence.ts).
856
+ // Old predicate (`status !== 'offline' || sseCount`) is retired — hub `status` is
857
+ // an unreliable signal on its own (see presence.ts and #520).
858
+ const online = sessions.filter(s => presenceIsOnline(s, sseSessions)).sort(byAlias);
859
+ const offline = sessions.filter(s => !presenceIsOnline(s, sseSessions) && !isGhost(s)).sort(byAlias);
850
860
  const positions: Record<string, Point> = {};
851
861
 
852
862
  if (layout === 'grid') {
@@ -974,7 +984,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
974
984
  // preserving the node↔label overlap-test guarantee from R19.
975
985
  let w = 0, i = 0, o = 0;
976
986
  for (const s of band.members) {
977
- const isOn = s.status !== 'offline' || !!sseCount(s);
987
+ const isOn = presenceIsOnline(s, sseSessions);
978
988
  if (s.status === 'working') w++;
979
989
  else if (isOn) i++;
980
990
  else o++;
@@ -1214,7 +1224,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1214
1224
  const tally = (members: Session[]) => {
1215
1225
  let w = 0, i = 0, o = 0;
1216
1226
  for (const s of members) {
1217
- const isOn = s.status !== 'offline' || !!sseCount(s);
1227
+ const isOn = presenceIsOnline(s, sseSessions);
1218
1228
  if (s.status === 'working') w++;
1219
1229
  else if (isOn) i++;
1220
1230
  else o++;
@@ -4197,7 +4207,11 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4197
4207
  : null;
4198
4208
  const allSessions = [...onlineNodes, ...offlineNodes];
4199
4209
  const survivors = allSessions.filter(s => {
4200
- const isOnline = s.status !== 'offline';
4210
+ // #515: pinned-status filter shares the presence definition too.
4211
+ // Was `s.status !== 'offline'` (pure status), even more divergent
4212
+ // than the count sites — a `working`-but-SSE-disconnected node
4213
+ // would survive the `offline` pin filter and hide inside "online".
4214
+ const isOnline = presenceIsOnline(s, sseSessions);
4201
4215
  if (pinnedStatus === 'working' && s.status !== 'working') return false;
4202
4216
  if (pinnedStatus === 'idle' && !(isOnline && s.status !== 'working')) return false;
4203
4217
  if (pinnedStatus === 'offline' && isOnline) return false;
@@ -10493,8 +10507,9 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10493
10507
  const pos = nodePositions[session.alias];
10494
10508
  if (!pos) return null;
10495
10509
 
10496
- const sseCountFor = (session.network_id ? sseSessions[`${session.network_id}:${session.alias}`] : undefined) ?? sseSessions[session.alias];
10497
- const isOnline = session.status !== 'offline' || !!sseCountFor;
10510
+ // #515: main per-node render uses the shared presence definition.
10511
+ const sseCountFor = presenceSseCountFor(session, sseSessions);
10512
+ const isOnline = presenceIsOnline(session, sseSessions);
10498
10513
  const status = nodeStatus(session, isOnline, isLight);
10499
10514
  const isActive = activeAliases.has(session.alias);
10500
10515
  // #113: node size scales with the S/M/L control; halos, labels,
@@ -15290,8 +15305,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
15290
15305
  {[...onlineNodes, ...offlineNodes].map(s => {
15291
15306
  const p = nodePositions[s.alias];
15292
15307
  if (!p) return null;
15293
- const sseN = (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
15294
- const isOn = s.status !== 'offline' || !!sseN;
15308
+ const isOn = presenceIsOnline(s, sseSessions);
15295
15309
  const st = nodeStatus(s, isOn, isLight);
15296
15310
  return (
15297
15311
  /* Round 372 / Loop: minimap offline-dot opacity
@@ -0,0 +1,98 @@
1
+ // #515 / #53: single source of truth for "is this node online?" across
2
+ // stats card / sidebar count / topology halos / chat popover / task
3
+ // panel @-mention picker. If you add a new place that decides "online"
4
+ // vs "offline", import isOnline() from here — don't roll your own.
5
+ //
6
+ // Definition: **SSE-reachable with at least one live connection**.
7
+ // Adopted by #214 F2 (originally in app/page.tsx). Consolidated here so
8
+ // consumers never disagree.
9
+ //
10
+ // Why `> 0` and not `!== undefined`:
11
+ // - Hub `getSSEStats()` writes `sessions[name] = arr.length` and the
12
+ // three removal paths (cancel, rekey, dead-pushEvent) each `delete`
13
+ // the key when the array empties. So on the current hub the value
14
+ // is never `0` — but this JSON comes from a hub whose version this
15
+ // dashboard doesn't control (see the legacy alias-only fallback
16
+ // below for the version we already know we support), so treating
17
+ // the map defensively costs nothing and keeps the definition
18
+ // pointing the same direction #214 F2 took: SSE-reachable = "can I
19
+ // talk to it right now", and a zero-count row is not that.
20
+ // - `!!sseLookup(s)` in the old page.tsx also treated 0 as false;
21
+ // preserving that keeps this a pure refactor for page.tsx.
22
+ //
23
+ // SSE keys in health.sse_sessions are `network_id:alias` since v0.7+
24
+ // (per-network scoping). Legacy fallback: bare alias.
25
+
26
+ export type SseSessionsMap = Record<string, unknown>;
27
+
28
+ export type PresenceSubject = {
29
+ alias: string;
30
+ network_id?: string;
31
+ };
32
+
33
+ export function sseCountFor(session: PresenceSubject, sseSessions: SseSessionsMap | undefined): number | undefined {
34
+ if (!sseSessions) return undefined;
35
+ const scoped = session.network_id ? sseSessions[`${session.network_id}:${session.alias}`] : undefined;
36
+ const value = (scoped ?? sseSessions[session.alias]) as number | undefined;
37
+ return typeof value === 'number' ? value : undefined;
38
+ }
39
+
40
+ export function isOnline(session: PresenceSubject, sseSessions: SseSessionsMap | undefined): boolean {
41
+ const n = sseCountFor(session, sseSessions);
42
+ return typeof n === 'number' && n > 0;
43
+ }
44
+
45
+ // -----------------------------------------------------------------------
46
+ // #53: three-state ambient presence signal.
47
+ //
48
+ // `sse_sessions` on hub's /health is **auth-gated** (post `#495-followup`
49
+ // / `f28a6c1b`): an anonymous or wrong-scope caller gets the field
50
+ // **completely omitted** — indistinguishable at the JSON level from
51
+ // "no nodes connected".
52
+ //
53
+ // If we hand that state to isOnline() and only render numbers, the UI
54
+ // shows a confident, consistent, and *wrong* 0 online everywhere and
55
+ // the "three sites disagree" signal #515 used to have — which was
56
+ // itself the clue that something upstream was broken — is gone.
57
+ //
58
+ // So callers need to distinguish three cases:
59
+ //
60
+ // 'ready' — sseSessions is present (even if empty {}). Trust the
61
+ // count. `filter(isOnline).length` is the answer.
62
+ // 'blind' — sseSessions is absent but hub `sse_connections > 0`.
63
+ // Two fields contradict → we are seeing an anonymized
64
+ // view. Do NOT render "0 online"; render an alert.
65
+ // 'loading' — sseSessions is absent and no positive contradiction
66
+ // (either health hasn't loaded, or hub reports 0
67
+ // connections and no map). Render a placeholder — don't
68
+ // inflate the count, but don't cry wolf either.
69
+ //
70
+ // Callers that render user-visible counts MUST branch on this. Callers
71
+ // that just need a per-node boolean (halos, chat-popover badges) can
72
+ // stay on isOnline() — those degrade gracefully to "everyone offline"
73
+ // which is the safer visual, while the ambient banner handles the
74
+ // "why".
75
+ // -----------------------------------------------------------------------
76
+
77
+ export type PresenceStatus = 'ready' | 'blind' | 'loading';
78
+
79
+ export type HealthShape = {
80
+ sse_connections?: number;
81
+ } | null | undefined;
82
+
83
+ export function presenceStatus(
84
+ sseSessions: SseSessionsMap | undefined,
85
+ health: HealthShape,
86
+ ): PresenceStatus {
87
+ if (sseSessions !== undefined) return 'ready';
88
+ // sseSessions is absent. Are we blind or truly empty/loading?
89
+ const conns = health && typeof health.sse_connections === 'number' ? health.sse_connections : 0;
90
+ // The contradiction case — hub says there are connections but the
91
+ // map isn't in this response — is the strongest "you're anonymous"
92
+ // signal we have.
93
+ if (conns > 0) return 'blind';
94
+ // No map AND hub reports 0 connections (or health hasn't loaded).
95
+ // Both look identical to us, and both should render placeholders,
96
+ // not "0 online" numbers. Conservative default = 'loading'.
97
+ return 'loading';
98
+ }
package/app/page.tsx CHANGED
@@ -18,6 +18,7 @@ import { useSessions, useHealth, useTasks, useStats } from './lib/hooks';
18
18
  import { useChatUnread } from './lib/chat-unread';
19
19
  import { isPinned, usePinVersion } from './lib/chat-pin';
20
20
  import { useSSE } from './lib/useSSE';
21
+ import { isOnline as presenceIsOnline, sseCountFor as presenceSseCountFor, presenceStatus } from './lib/presence';
21
22
  import { useSWRConfig } from 'swr';
22
23
 
23
24
  export default function Dashboard() {
@@ -82,18 +83,20 @@ export default function Dashboard() {
82
83
  void pinVersion; // re-sort when pins change (isPinned used in comparator)
83
84
  if (isLoading) return <LoadingSkeleton />;
84
85
 
86
+ // #53: distinguish "we have the SSE map" from "we can't see it". The
87
+ // per-node `isOnline` predicate below is fine for halo/legend rendering
88
+ // (degrades to "everyone offline") but the header count MUST NOT
89
+ // confidently show 0 online when we're actually blind — HealthBanner
90
+ // surfaces the "why", and StatsBar renders '?' for online.
91
+ const presenceState = presenceStatus(health?.sse_sessions, health);
85
92
  const sseSessions = health?.sse_sessions || {};
86
- // SSE keys are `network_id:alias` since v0.7+ (per-network scoping).
87
- // Fall back to alias-only for legacy hubs.
88
- const sseLookup = (s: { alias: string; network_id?: string }) =>
89
- (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
90
- // #214 F2: online = SSE-reachable ("can I talk to it right now").
91
- // The old definition (status !== 'offline' OR sse) inflated the count
92
- // with stale hub statuses and disagreed with /admin and /nodes, which
93
- // both count pure SSE — and a "online" card you can't chat with
94
- // contradicts the M2 tap-to-chat model. One definition everywhere.
95
- const isOnline = (s: { alias: string; status: string; network_id?: string }) => !!sseLookup(s);
93
+ // #515 / #214 F2: presence = SSE-reachable ("can I talk to it right now").
94
+ // Definition lives in app/lib/presence.ts so stats card / sidebar /
95
+ // topology halos always agree. See presence.ts for why hub `status` is
96
+ // an unreliable signal on its own (#520).
97
+ const isOnline = (s: { alias: string; network_id?: string }) => presenceIsOnline(s, sseSessions);
96
98
  const online = sessions.filter(isOnline).length;
99
+ const presenceUnknown = presenceState !== 'ready';
97
100
  const total = sessions.length;
98
101
  const working = sessions.filter(s => s.status === 'working').length;
99
102
  // Task stats: prefer /api/stats, fallback to manual
@@ -135,7 +138,7 @@ export default function Dashboard() {
135
138
 
136
139
  return (
137
140
  <div className="min-h-screen bg-[#0b0b0d] text-gray-100 p-4 sm:p-6">
138
- <StatsBar online={online} working={working} total={total} />
141
+ <StatsBar online={online} working={working} total={total} presenceUnknown={presenceUnknown} />
139
142
 
140
143
  {/* #217 S5 (Vincent: "极简极简,这些都可以放到设置里面去"): the
141
144
  Dispatch button and UserBar row are gone from Overview.
@@ -209,12 +212,18 @@ export default function Dashboard() {
209
212
  </div>
210
213
  )}
211
214
 
212
- {sessions.length === 0 && !sessError ? (
215
+ {sessions.length === 0 && !sessError && presenceState === 'ready' ? (
216
+ // #53: only pitch "Spin up your first agent" when we actually
217
+ // know the fleet is empty. If presence is blind or still loading,
218
+ // suppress the empty state — HealthBanner already surfaces the
219
+ // real reason and the "first agent" CTA in that context would
220
+ // read as "the fleet died overnight", exactly the failure mode
221
+ // #53 was opened for.
213
222
  <EmptyState
214
223
  hint={sessHint}
215
224
  taskHistoryCount={Object.values(taskStats).reduce((a, b) => a + b, 0)}
216
225
  />
217
- ) : (() => {
226
+ ) : sessions.length === 0 && !sessError ? null : (() => {
218
227
  const counts = {
219
228
  all: sortedSessions.length,
220
229
  working: sortedSessions.filter(s => isOnline(s) && s.status === 'working').length,
@@ -266,7 +275,7 @@ export default function Dashboard() {
266
275
  xl breakpoint auto-inherits lg=3 cols; 2xl bumps to 4. */}
267
276
  <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3 sm:gap-4">
268
277
  {filtered.map(s => (
269
- <AgentCard key={s.alias} session={s} hasSse={isOnline(s)} sseCount={sseLookup(s) || 0} onChat={cmd.openTab} unreadCount={unreadCount(s.alias)} />
278
+ <AgentCard key={s.alias} session={s} hasSse={isOnline(s)} sseCount={presenceSseCountFor(s, sseSessions) ?? 0} onChat={cmd.openTab} unreadCount={unreadCount(s.alias)} />
270
279
  ))}
271
280
  </div>
272
281
  {filtered.length === 0 && (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/agent-network-dashboard",
3
- "version": "0.6.3-preview.43",
3
+ "version": "0.6.3-preview.45",
4
4
  "description": "Agent Network Dashboard — Web UI for managing AI Agent networks",
5
5
  "main": "apps/desktop/electron/main.cjs",
6
6
  "scripts": {
@@ -0,0 +1,75 @@
1
+ import { test, expect } from '@playwright/test';
2
+ import { isOnline, sseCountFor } from '../app/lib/presence';
3
+
4
+ // #515 predicate unit test — the "narrow" companion to
5
+ // tests/e2e-515-presence.spec.ts. That one proves the three DOM
6
+ // consumers agree (integration). This one nails down the specific
7
+ // invariant every consumer now inherits from app/lib/presence.ts:
8
+ //
9
+ // A node with `status='working'` but NO SSE entry MUST classify as
10
+ // NOT online — because hub `status` doesn't age out on heartbeat
11
+ // loss (see presence.ts and #520). Historically L4204's pinnedStatus
12
+ // filter used pure `s.status !== 'offline'`, so pinning "offline"
13
+ // dropped this zombie into "online" and it hid from the offline
14
+ // filter — exactly the bug 通信龙 flagged as needing its own
15
+ // assertion so a future refactor can't silently regress it.
16
+ //
17
+ // If any downstream consumer of `isOnline(...)` starts special-casing
18
+ // `status` again, THIS test goes red — matching the "unified predicate"
19
+ // contract more precisely than a UI-level test could without hitting
20
+ // the pin-intersection chip's two-dim activation requirement.
21
+
22
+ const NET = 'net_a';
23
+
24
+ test.describe('#515 predicate — SSE ground-truths presence, status alone lies', () => {
25
+ test('SSE-reachable with any status → online', () => {
26
+ const sse = { [`${NET}:working-sse`]: 1, [`${NET}:idle-sse`]: 1, [`${NET}:offline-sse`]: 2 };
27
+ expect(isOnline({ alias: 'working-sse', network_id: NET }, sse)).toBe(true);
28
+ expect(isOnline({ alias: 'idle-sse', network_id: NET }, sse)).toBe(true);
29
+ // Even a node the hub already flagged offline is "online" if SSE
30
+ // is talking — the hub row is stale, the socket is ground truth.
31
+ expect(isOnline({ alias: 'offline-sse', network_id: NET }, sse)).toBe(true);
32
+ });
33
+
34
+ test('SSE-less with status=working → NOT online (the L4204 zombie case)', () => {
35
+ const sse = { [`${NET}:someone-else`]: 1 };
36
+ // The regression bait — before #515 this same session slipped
37
+ // through the pinned-offline filter and hid inside "online".
38
+ expect(isOnline({ alias: 'working-no-sse', network_id: NET }, sse)).toBe(false);
39
+ });
40
+
41
+ test('SSE-less with any status → NOT online', () => {
42
+ const sse = {};
43
+ expect(isOnline({ alias: 'w', network_id: NET }, sse)).toBe(false);
44
+ expect(isOnline({ alias: 'i', network_id: NET }, sse)).toBe(false);
45
+ expect(isOnline({ alias: 'o', network_id: NET }, sse)).toBe(false);
46
+ });
47
+
48
+ test('legacy alias-only SSE key still resolves (no network_id scoping)', () => {
49
+ const sse = { 'legacy-node': 1 };
50
+ // Older hubs (< v0.7) key sse_sessions by bare alias. presence
51
+ // falls back to bare-alias when scoped lookup misses.
52
+ expect(isOnline({ alias: 'legacy-node' }, sse)).toBe(true);
53
+ expect(isOnline({ alias: 'legacy-node', network_id: NET }, sse)).toBe(true);
54
+ expect(sseCountFor({ alias: 'legacy-node' }, sse)).toBe(1);
55
+ });
56
+
57
+ test('undefined sseSessions map → NOT online (fail-closed)', () => {
58
+ // If health hasn't loaded yet the map is undefined. Presence
59
+ // MUST fail-closed — treating undefined as "everyone online"
60
+ // would inflate the count during boot.
61
+ expect(isOnline({ alias: 'x', network_id: NET }, undefined)).toBe(false);
62
+ });
63
+
64
+ test('SSE value === 0 → NOT online (PR #52 round-2 finding 1)', () => {
65
+ // Older hubs may leave a zero-count entry in sse_sessions when the
66
+ // last connection dropped without cleanup. `!== undefined` would
67
+ // count this as online — inflating in exactly the direction #214 F2
68
+ // set out to eliminate. `> 0` is the SSE-reachable ground truth.
69
+ // page.tsx's old `!!sseLookup(s)` also treated 0 as false — so
70
+ // preserving `> 0` keeps this a pure refactor for that call site.
71
+ const sse = { [`${NET}:dead-edge`]: 0 };
72
+ expect(sseCountFor({ alias: 'dead-edge', network_id: NET }, sse)).toBe(0);
73
+ expect(isOnline({ alias: 'dead-edge', network_id: NET }, sse)).toBe(false);
74
+ });
75
+ });
@@ -0,0 +1,148 @@
1
+ import { test, expect, Page } from '@playwright/test';
2
+
3
+ const BASE = process.env.TEST_URL || 'http://localhost:3100';
4
+
5
+ // #515 regression test: stats card / sidebar / topology legend must show
6
+ // the SAME "online" count when fed identical hub state. Historically each
7
+ // site computed its own predicate (`isOnline = ...`) and they drifted
8
+ // (page 95, sidebar 99, legend 99) — the fix extracts a single shared
9
+ // presence.isOnline() in app/lib/presence.ts.
10
+ //
11
+ // SCOPE OF THIS TEST — this proves "given the same data, the 3 DOM
12
+ // consumers render the same number", AND that number is the SSE-reachable
13
+ // count (not the status-based count). It does NOT prove the wire fetch
14
+ // gets sse_sessions from the right upstream endpoint — for that, see the
15
+ // production-dashboard "before" screenshot referenced in the PR body and
16
+ // the deploy-time smoke test. See memory
17
+ // feedback_unit_mock_hides_wire_shape_bugs for why one class of evidence
18
+ // doesn't cover the other.
19
+ //
20
+ // Fixture design (deliberately asymmetric):
21
+ // - SSE-reachable count differs from status-based count in ABSOLUTE
22
+ // value, so `expect(x).toBe(EXPECTED)` catches a mutation of the
23
+ // shared predicate (finding: card=sidebar=legend but wrong number)
24
+ // as well as drift (finding: two of three read the same, one different).
25
+ // - Includes a `SSE:0` node to gate the `> 0` fix from PR #52 round 2
26
+ // (findings 1) — a `!== undefined` predicate would count agent-06.
27
+
28
+ const FIXTURE_STATUS = {
29
+ sessions: [
30
+ // present in SSE map AND status alive
31
+ { alias: 'agent-01', status: 'working', network_id: 'net_a' },
32
+ { alias: 'agent-02', status: 'idle', network_id: 'net_a' },
33
+ // present in SSE map but hub row is stale — SSE wins
34
+ { alias: 'agent-03', status: 'offline', network_id: 'net_a' },
35
+ // NOT in SSE map, hub row still says working (the classic zombie)
36
+ { alias: 'agent-04', status: 'working', network_id: 'net_a' },
37
+ // NOT in SSE map, hub row still says idle
38
+ { alias: 'agent-05', status: 'idle', network_id: 'net_a' },
39
+ // SSE map value is literally 0 (dead-connection edge from an older
40
+ // hub that didn't clean up empty client arrays; #515 round-2
41
+ // finding 1). Correct behaviour: NOT online.
42
+ { alias: 'agent-06', status: 'working', network_id: 'net_a' },
43
+ ],
44
+ };
45
+
46
+ const FIXTURE_HEALTH = {
47
+ ok: true,
48
+ version: '0.9.0-test',
49
+ sessions: 6,
50
+ sse_connections: 3,
51
+ sse_sessions: {
52
+ 'net_a:agent-01': 1,
53
+ 'net_a:agent-02': 1,
54
+ 'net_a:agent-03': 1,
55
+ 'net_a:agent-06': 0, // dead-connection edge — MUST NOT count as online
56
+ },
57
+ uptime: 1,
58
+ };
59
+
60
+ // SSE-reachable with count > 0: agent-01, 02, 03 = 3
61
+ // Status-based (retired): agent-01, 02, 04, 05, 06 = 5
62
+ // `sseCountFor !== undefined` (finding 1 bug): 01, 02, 03, 06 = 4
63
+ // Under status mutation → all three consumers agree on 5, absolute
64
+ // assertion catches. Under `!== undefined` regression → all three
65
+ // agree on 4, absolute assertion catches too. Under drift (one
66
+ // consumer reverts to inline old) → one reads 5, others read 3.
67
+ const EXPECTED_ONLINE = 3;
68
+
69
+ async function login(context: Page['context'] extends () => infer C ? C : never) {
70
+ const res = await context.request.post(`${BASE}/api/auth/login`, {
71
+ data: { password: process.env.ANET_DASHBOARD_PASSWORD || 'admin123' },
72
+ });
73
+ const setCookie = res.headers()['set-cookie'] || '';
74
+ const match = setCookie.match(/anet_dashboard_session=([^;]+)/);
75
+ if (match) {
76
+ const domain = new URL(BASE).hostname;
77
+ await context.addCookies([{
78
+ name: 'anet_dashboard_session',
79
+ value: decodeURIComponent(match[1]),
80
+ domain,
81
+ path: '/',
82
+ }]);
83
+ }
84
+ }
85
+
86
+ async function mockHubEndpoints(page: Page) {
87
+ await page.route('**/api/hub/status**', async (route) => {
88
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(FIXTURE_STATUS) });
89
+ });
90
+ await page.route('**/api/hub/health', async (route) => {
91
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(FIXTURE_HEALTH) });
92
+ });
93
+ await page.route('**/api/hub/nodes**', async (route) => {
94
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ nodes: [] }) });
95
+ });
96
+ await page.route('**/api/hub/tasks**', async (route) => {
97
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ tasks: [], _hint: {} }) });
98
+ });
99
+ await page.route('**/api/hub/stats**', async (route) => {
100
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) });
101
+ });
102
+ await page.route('**/api/hub/messages**', async (route) => {
103
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ messages: [] }) });
104
+ });
105
+ }
106
+
107
+ test.describe('#515 presence — one online count everywhere', () => {
108
+ test('stats card / sidebar / topology legend all show the same number', async ({ page, context }) => {
109
+ await login(context);
110
+ await mockHubEndpoints(page);
111
+ await page.goto(`${BASE}/`);
112
+ await expect(page.locator('text=/\\d+\\s*\\/\\s*\\d+\\s+online/i').first()).toBeVisible({ timeout: 20000 });
113
+ await page.waitForTimeout(1500);
114
+
115
+ // Structural anchors — body-text regex is too greedy across the
116
+ // multiple "N Online" phrases on the page.
117
+ const cardText = await page.getByText('Online', { exact: true }).first()
118
+ .evaluate(el => el.previousElementSibling?.textContent ?? null);
119
+ const sidebarText = await page.getByRole('link', { name: /Agent Network/i }).first().innerText();
120
+ const sidebarMatch = sidebarText.match(/(\d+)\s*\/\s*\d+\s+online/i);
121
+ const legendName = await page.getByRole('link', { name: /^\d+\s+online$/i }).first().getAttribute('aria-label')
122
+ || await page.getByRole('link', { name: /^\d+\s+online$/i }).first().innerText();
123
+ const legendMatch = legendName.match(/(\d+)/);
124
+
125
+ const readings = {
126
+ card: cardText ? Number(cardText.trim()) : null,
127
+ sidebar: sidebarMatch ? Number(sidebarMatch[1]) : null,
128
+ legend: legendMatch ? Number(legendMatch[1]) : null,
129
+ };
130
+
131
+ console.log('#515 presence readings:', JSON.stringify(readings));
132
+
133
+ // Strict equality per #515 acceptance: no tolerance, no `>0` sanity.
134
+ // The absolute value catches "shared predicate regressed" (all three
135
+ // agree but wrong); the pairwise equality catches "one consumer
136
+ // reverted to inline old predicate" (drift).
137
+ expect(readings.card).toBe(EXPECTED_ONLINE);
138
+ expect(readings.sidebar).toBe(EXPECTED_ONLINE);
139
+ expect(readings.legend).toBe(EXPECTED_ONLINE);
140
+ expect(readings.card).toBe(readings.sidebar);
141
+ expect(readings.sidebar).toBe(readings.legend);
142
+
143
+ await page.screenshot({
144
+ path: 'test-results/e2e-515-three-numbers-agree.png',
145
+ fullPage: false,
146
+ });
147
+ });
148
+ });