@sleep2agi/agent-network-dashboard 0.6.3-preview.50 → 0.6.3-preview.52

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 (262) hide show
  1. package/.next/BUILD_COMMIT +1 -1
  2. package/.next/BUILD_ID +1 -1
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/diagnostics/route-bundle-stats.json +50 -50
  5. package/.next/fallback-build-manifest.json +3 -3
  6. package/.next/prerender-manifest.json +3 -3
  7. package/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/server/app/_global-error.html +1 -1
  10. package/.next/server/app/_global-error.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  15. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/server/app/_not-found.html +2 -2
  19. package/.next/server/app/_not-found.rsc +13 -13
  20. package/.next/server/app/_not-found.segments/_full.segment.rsc +13 -13
  21. package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  22. package/.next/server/app/_not-found.segments/_index.segment.rsc +8 -8
  23. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  24. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  25. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  26. package/.next/server/app/admin/page.js.nft.json +1 -1
  27. package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
  28. package/.next/server/app/admin.html +2 -2
  29. package/.next/server/app/admin.rsc +15 -15
  30. package/.next/server/app/admin.segments/_full.segment.rsc +15 -15
  31. package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
  32. package/.next/server/app/admin.segments/_index.segment.rsc +8 -8
  33. package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
  34. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +4 -4
  35. package/.next/server/app/admin.segments/admin.segment.rsc +3 -3
  36. package/.next/server/app/index.html +2 -2
  37. package/.next/server/app/index.rsc +15 -15
  38. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  39. package/.next/server/app/index.segments/_full.segment.rsc +15 -15
  40. package/.next/server/app/index.segments/_head.segment.rsc +4 -4
  41. package/.next/server/app/index.segments/_index.segment.rsc +8 -8
  42. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  43. package/.next/server/app/login/page.js.nft.json +1 -1
  44. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  45. package/.next/server/app/login.html +2 -2
  46. package/.next/server/app/login.rsc +15 -15
  47. package/.next/server/app/login.segments/_full.segment.rsc +15 -15
  48. package/.next/server/app/login.segments/_head.segment.rsc +4 -4
  49. package/.next/server/app/login.segments/_index.segment.rsc +8 -8
  50. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  51. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
  52. package/.next/server/app/login.segments/login.segment.rsc +3 -3
  53. package/.next/server/app/logs/page.js.nft.json +1 -1
  54. package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
  55. package/.next/server/app/logs.html +2 -2
  56. package/.next/server/app/logs.rsc +15 -15
  57. package/.next/server/app/logs.segments/_full.segment.rsc +15 -15
  58. package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
  59. package/.next/server/app/logs.segments/_index.segment.rsc +8 -8
  60. package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
  61. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
  62. package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
  63. package/.next/server/app/messages/page.js.nft.json +1 -1
  64. package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
  65. package/.next/server/app/messages.html +2 -2
  66. package/.next/server/app/messages.rsc +15 -15
  67. package/.next/server/app/messages.segments/_full.segment.rsc +15 -15
  68. package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
  69. package/.next/server/app/messages.segments/_index.segment.rsc +8 -8
  70. package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
  71. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
  72. package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
  73. package/.next/server/app/node/page.js.nft.json +1 -1
  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 +15 -15
  77. package/.next/server/app/node.segments/_full.segment.rsc +15 -15
  78. package/.next/server/app/node.segments/_head.segment.rsc +4 -4
  79. package/.next/server/app/node.segments/_index.segment.rsc +8 -8
  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/[alias]/page.js.nft.json +1 -1
  84. package/.next/server/app/nodes/[alias]/page_client-reference-manifest.js +1 -1
  85. package/.next/server/app/nodes/page.js.nft.json +1 -1
  86. package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
  87. package/.next/server/app/nodes.html +2 -2
  88. package/.next/server/app/nodes.rsc +15 -15
  89. package/.next/server/app/nodes.segments/_full.segment.rsc +15 -15
  90. package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
  91. package/.next/server/app/nodes.segments/_index.segment.rsc +8 -8
  92. package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
  93. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
  94. package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
  95. package/.next/server/app/page.js.nft.json +1 -1
  96. package/.next/server/app/page_client-reference-manifest.js +1 -1
  97. package/.next/server/app/providers/page.js.nft.json +1 -1
  98. package/.next/server/app/providers/page_client-reference-manifest.js +1 -1
  99. package/.next/server/app/providers.html +2 -2
  100. package/.next/server/app/providers.rsc +15 -15
  101. package/.next/server/app/providers.segments/_full.segment.rsc +15 -15
  102. package/.next/server/app/providers.segments/_head.segment.rsc +4 -4
  103. package/.next/server/app/providers.segments/_index.segment.rsc +8 -8
  104. package/.next/server/app/providers.segments/_tree.segment.rsc +2 -2
  105. package/.next/server/app/providers.segments/providers/__PAGE__.segment.rsc +4 -4
  106. package/.next/server/app/providers.segments/providers.segment.rsc +3 -3
  107. package/.next/server/app/server-logs/page.js.nft.json +1 -1
  108. package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
  109. package/.next/server/app/server-logs.html +2 -2
  110. package/.next/server/app/server-logs.rsc +15 -15
  111. package/.next/server/app/server-logs.segments/_full.segment.rsc +15 -15
  112. package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
  113. package/.next/server/app/server-logs.segments/_index.segment.rsc +8 -8
  114. package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
  115. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +4 -4
  116. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
  117. package/.next/server/app/servers/page.js.nft.json +1 -1
  118. package/.next/server/app/servers/page_client-reference-manifest.js +1 -1
  119. package/.next/server/app/servers.html +2 -2
  120. package/.next/server/app/servers.rsc +15 -15
  121. package/.next/server/app/servers.segments/_full.segment.rsc +15 -15
  122. package/.next/server/app/servers.segments/_head.segment.rsc +4 -4
  123. package/.next/server/app/servers.segments/_index.segment.rsc +8 -8
  124. package/.next/server/app/servers.segments/_tree.segment.rsc +2 -2
  125. package/.next/server/app/servers.segments/servers/__PAGE__.segment.rsc +4 -4
  126. package/.next/server/app/servers.segments/servers.segment.rsc +3 -3
  127. package/.next/server/app/settings/networks/page.js.nft.json +1 -1
  128. package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
  129. package/.next/server/app/settings/networks.html +2 -2
  130. package/.next/server/app/settings/networks.rsc +15 -15
  131. package/.next/server/app/settings/networks.segments/_full.segment.rsc +15 -15
  132. package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
  133. package/.next/server/app/settings/networks.segments/_index.segment.rsc +8 -8
  134. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
  135. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +4 -4
  136. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
  137. package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
  138. package/.next/server/app/settings/page.js.nft.json +1 -1
  139. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  140. package/.next/server/app/settings/tokens/page.js.nft.json +1 -1
  141. package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
  142. package/.next/server/app/settings/tokens.html +2 -2
  143. package/.next/server/app/settings/tokens.rsc +15 -15
  144. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +15 -15
  145. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
  146. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +8 -8
  147. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
  148. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +4 -4
  149. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
  150. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
  151. package/.next/server/app/settings.html +2 -2
  152. package/.next/server/app/settings.rsc +15 -15
  153. package/.next/server/app/settings.segments/_full.segment.rsc +15 -15
  154. package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
  155. package/.next/server/app/settings.segments/_index.segment.rsc +8 -8
  156. package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  157. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
  158. package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
  159. package/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
  160. package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  161. package/.next/server/app/tasks/page.js.nft.json +1 -1
  162. package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
  163. package/.next/server/app/tasks.html +2 -2
  164. package/.next/server/app/tasks.rsc +15 -15
  165. package/.next/server/app/tasks.segments/_full.segment.rsc +15 -15
  166. package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
  167. package/.next/server/app/tasks.segments/_index.segment.rsc +8 -8
  168. package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
  169. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
  170. package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
  171. package/.next/server/chunks/ssr/{0ld5_next_dist_00v047f._.js → 0ld5_next_dist_04cce2g._.js} +2 -2
  172. package/.next/server/chunks/ssr/0ld5_next_dist_04cce2g._.js.map +1 -0
  173. package/.next/server/chunks/ssr/[root-of-the-server]__06w5i08._.js +1 -1
  174. package/.next/server/chunks/ssr/[root-of-the-server]__06w5i08._.js.map +1 -1
  175. package/.next/server/chunks/ssr/[root-of-the-server]__0dcez4x._.js +11 -0
  176. package/.next/server/chunks/ssr/[root-of-the-server]__0dcez4x._.js.map +1 -0
  177. package/.next/server/chunks/ssr/{[root-of-the-server]__0jgqpfv._.js → [root-of-the-server]__0gyqilt._.js} +2 -2
  178. package/.next/server/chunks/ssr/[root-of-the-server]__0gyqilt._.js.map +1 -0
  179. package/.next/server/chunks/ssr/[root-of-the-server]__0j-def.._.js +11 -0
  180. package/.next/server/chunks/ssr/[root-of-the-server]__0j-def.._.js.map +1 -0
  181. package/.next/server/chunks/ssr/{[root-of-the-server]__11wln8.._.js → [root-of-the-server]__0oavw6j._.js} +2 -2
  182. package/.next/server/chunks/ssr/[root-of-the-server]__0oavw6j._.js.map +1 -0
  183. package/.next/server/chunks/ssr/[root-of-the-server]__0rct4.k._.js +3 -0
  184. package/.next/server/chunks/ssr/[root-of-the-server]__0rct4.k._.js.map +1 -0
  185. package/.next/server/chunks/ssr/{[root-of-the-server]__00n84ca._.js → [root-of-the-server]__0x9nh~a._.js} +2 -2
  186. package/.next/server/chunks/ssr/[root-of-the-server]__0x9nh~a._.js.map +1 -0
  187. package/.next/server/chunks/ssr/dash-stageb-wt_0q55dv1._.js +0 -0
  188. package/.next/server/chunks/ssr/dash-stageb-wt_0q55dv1._.js.map +1 -0
  189. package/.next/server/chunks/ssr/dash-stageb-wt_13184u1._.js +0 -0
  190. package/.next/server/chunks/ssr/dash-stageb-wt_13184u1._.js.map +1 -0
  191. package/.next/server/chunks/ssr/dash-stageb-wt_app_05u4_h~._.js +2 -2
  192. package/.next/server/chunks/ssr/dash-stageb-wt_app_05u4_h~._.js.map +1 -1
  193. package/.next/server/chunks/ssr/dash-stageb-wt_app_0qn7hvc._.js +1 -1
  194. package/.next/server/chunks/ssr/dash-stageb-wt_app_0qn7hvc._.js.map +1 -1
  195. package/.next/server/chunks/ssr/dash-stageb-wt_app_0ss0pj-._.js +1 -1
  196. package/.next/server/chunks/ssr/dash-stageb-wt_app_0ss0pj-._.js.map +1 -1
  197. package/.next/server/chunks/ssr/dash-stageb-wt_app_nodes_NodesView_tsx_0patjj7._.js +2 -2
  198. package/.next/server/chunks/ssr/dash-stageb-wt_app_nodes_NodesView_tsx_0patjj7._.js.map +1 -1
  199. package/.next/server/chunks/ssr/{dash-stageb-wt_0hzfl6x._.js → dash-stageb-wt_app_page_tsx_0iyrtvj._.js} +3 -3
  200. package/.next/server/chunks/ssr/dash-stageb-wt_app_page_tsx_0iyrtvj._.js.map +1 -0
  201. package/.next/server/middleware-build-manifest.js +3 -3
  202. package/.next/server/pages/404.html +2 -2
  203. package/.next/server/pages/500.html +1 -1
  204. package/.next/server/server-reference-manifest.js +1 -1
  205. package/.next/server/server-reference-manifest.json +1 -1
  206. package/.next/static/chunks/0-ot~88nectgp.js +2 -0
  207. package/.next/static/chunks/00adqh_rd04uf.js +9 -0
  208. package/.next/static/chunks/051p0pw4z~6ag.js +1 -0
  209. package/.next/static/chunks/0aaakzcw7tey0.js +0 -0
  210. package/.next/static/chunks/{0hm52b0y~k0~z.js → 0lf8e-h91s10h.js} +1 -1
  211. package/.next/static/chunks/0njt8wek.9aq6.js +1 -0
  212. package/.next/static/chunks/0ohx4jpm9~61..js +1 -0
  213. package/.next/static/chunks/0pg4pce.q5ne_.js +9 -0
  214. package/.next/static/chunks/0u5y.gmi~jkw6.js +1 -0
  215. package/.next/static/chunks/0wf03_efeii.w.css +1 -0
  216. package/.next/static/chunks/0ws0p1n62l~ub.js +1 -0
  217. package/.next/static/chunks/0zljk8~2jqw93.js +4 -0
  218. package/.next/static/chunks/{06q7-vhy~1-y3.js → 0~rx9_~1wupcp.js} +2 -2
  219. package/.next/static/chunks/{0ep78sq90x_yc.js → 14x094g58kg61.js} +1 -1
  220. package/.next/trace +2 -2
  221. package/.next/trace-build +1 -1
  222. package/app/components/ChatPane.tsx +133 -17
  223. package/app/components/NodeList.tsx +208 -46
  224. package/app/components/NodeListContextMenu.tsx +187 -0
  225. package/app/components/NodeSettingsPanel.tsx +29 -3
  226. package/app/components/TaskChatPanel.tsx +156 -31
  227. package/app/messages/page.tsx +22 -22
  228. package/app/nodes/NodesView.tsx +110 -11
  229. package/package.json +1 -1
  230. package/tests/e2e-9-nodes-team-tag-filter.spec.ts +17 -7
  231. package/tests/e2e-batch-list-actions.spec.ts +234 -0
  232. package/tests/e2e-messages-spec-visual-align.spec.ts +244 -0
  233. package/tests/e2e-pin-mute-attr-always-present.spec.ts +171 -0
  234. package/tests/e2e-stage-a-node-list.spec.ts +18 -7
  235. package/tests/e2e-stage-b-chat-pane.spec.ts +177 -10
  236. package/tests/e2e-status-labels.spec.ts +218 -0
  237. package/.next/server/chunks/ssr/0ld5_next_dist_00v047f._.js.map +0 -1
  238. package/.next/server/chunks/ssr/[root-of-the-server]__00n84ca._.js.map +0 -1
  239. package/.next/server/chunks/ssr/[root-of-the-server]__0idij1k._.js +0 -3
  240. package/.next/server/chunks/ssr/[root-of-the-server]__0idij1k._.js.map +0 -1
  241. package/.next/server/chunks/ssr/[root-of-the-server]__0jgqpfv._.js.map +0 -1
  242. package/.next/server/chunks/ssr/[root-of-the-server]__0w7k~3f._.js +0 -9
  243. package/.next/server/chunks/ssr/[root-of-the-server]__0w7k~3f._.js.map +0 -1
  244. package/.next/server/chunks/ssr/[root-of-the-server]__11wln8.._.js.map +0 -1
  245. package/.next/server/chunks/ssr/dash-stageb-wt_0hzfl6x._.js.map +0 -1
  246. package/.next/server/chunks/ssr/dash-stageb-wt_app_06.7qt9._.js +0 -0
  247. package/.next/server/chunks/ssr/dash-stageb-wt_app_06.7qt9._.js.map +0 -1
  248. package/.next/server/chunks/ssr/dash-stageb-wt_app_0y~m8-6._.js +0 -0
  249. package/.next/server/chunks/ssr/dash-stageb-wt_app_0y~m8-6._.js.map +0 -1
  250. package/.next/static/chunks/02.5_nifak.uu.js +0 -1
  251. package/.next/static/chunks/03hzj129k~odb.js +0 -1
  252. package/.next/static/chunks/0cey4i.y0a8~j.js +0 -1
  253. package/.next/static/chunks/0mhaf6lrw5nn..css +0 -1
  254. package/.next/static/chunks/0s293geid.j7e.js +0 -4
  255. package/.next/static/chunks/0zj_navpyhltx.js +0 -2
  256. package/.next/static/chunks/11zz15q37~bz5.js +0 -7
  257. package/.next/static/chunks/14y4x3de1lbug.js +0 -1
  258. package/.next/static/chunks/15ka68xi.w-i-.js +0 -1
  259. package/.next/static/chunks/17qkc714xy04..js +0 -1
  260. /package/.next/static/{vc_aJHtYwefsVcl2_f14Z → RiXBfeE2zY5tzXRmF7kBD}/_buildManifest.js +0 -0
  261. /package/.next/static/{vc_aJHtYwefsVcl2_f14Z → RiXBfeE2zY5tzXRmF7kBD}/_clientMiddlewareManifest.js +0 -0
  262. /package/.next/static/{vc_aJHtYwefsVcl2_f14Z → RiXBfeE2zY5tzXRmF7kBD}/_ssgManifest.js +0 -0
@@ -311,7 +311,20 @@ function NumberField({ label, value, onChange, placeholder }: { label: string; v
311
311
  );
312
312
  }
313
313
 
314
- export function NodeSettingsPanel({ session: s, onClose }: { session: Session; onClose: () => void }) {
314
+ export function NodeSettingsPanel({ session: s, onClose, positioning = 'fixed' }: {
315
+ session: Session;
316
+ onClose: () => void;
317
+ /** 07-31 通信龙 判定: 'fixed' (default) is the legacy fullscreen
318
+ * drawer used by AgentCard (covers whole viewport). 'absolute'
319
+ * scopes the drawer to its nearest positioned ancestor + shrinks
320
+ * to 360px, used by ChatPane so it only covers the chat column
321
+ * (SPEC §12: settings should not steal from the reclaimed 1104px
322
+ * chat width; fourth-column would push chat back to 664px).
323
+ * Absolute mode also drops the black backdrop — clicking outside
324
+ * the drawer inside the chat pane still closes, but the rest of
325
+ * the app stays interactive. */
326
+ positioning?: 'fixed' | 'absolute';
327
+ }) {
315
328
  const runtime = s.runtime || '—';
316
329
  const initialModel = s.model || s.agent || '';
317
330
  // imageCapable: read-only, auto from model (MiniMax-M3 / Claude = yes).
@@ -640,14 +653,27 @@ export function NodeSettingsPanel({ session: s, onClose }: { session: Session; o
640
653
  }
641
654
  }
642
655
 
656
+ const scoped = positioning === 'absolute';
643
657
  return (
644
658
  <>
645
- <div className="fixed inset-0 bg-black/40 z-40" onClick={onClose} aria-hidden />
659
+ {/* Backdrop — 'fixed' mode covers the whole viewport;
660
+ 'absolute' mode scopes to the positioned ancestor
661
+ (ChatPane's <aside>) so it only dims the chat column, not
662
+ the sidebar or rail. */}
663
+ <div
664
+ className={scoped
665
+ ? 'absolute inset-0 bg-black/40 z-40'
666
+ : 'fixed inset-0 bg-black/40 z-40'}
667
+ onClick={onClose}
668
+ aria-hidden
669
+ />
646
670
 
647
671
  <div
648
672
  role="dialog"
649
673
  aria-label={`${s.alias} 节点设置`}
650
- className="fixed top-0 right-0 h-[100dvh] w-full sm:w-[440px] bg-[#0f0f11] border-l border-[#26262b] z-50 flex flex-col shadow-2xl shadow-black/60 animate-slide-in"
674
+ className={scoped
675
+ ? 'absolute top-0 right-0 h-full w-full sm:w-[360px] max-w-[360px] bg-[#0f0f11] border-l border-[#26262b] z-50 flex flex-col animate-slide-in'
676
+ : 'fixed top-0 right-0 h-[100dvh] w-full sm:w-[440px] bg-[#0f0f11] border-l border-[#26262b] z-50 flex flex-col shadow-2xl shadow-black/60 animate-slide-in'}
651
677
  >
652
678
  {/* Header */}
653
679
  <div className="flex items-center gap-2.5 px-4 py-3 border-b border-[#26262b]">
@@ -315,45 +315,170 @@ function LocalFilePreview({ file, onRemove }: { file: File; onRemove: () => void
315
315
  );
316
316
  }
317
317
 
318
- const STATUS_STEPS = ['created', 'delivered', 'running', 'replied'];
319
- const STATUS_COLORS: Record<string, string> = {
320
- created: 'bg-gray-400', delivered: 'bg-blue-400', running: 'bg-green-400',
321
- replied: 'bg-purple-400', failed: 'bg-red-400', closed: 'bg-gray-500',
318
+ // 通信龙 07-31 GAP 2nd tier: translate hub status codes into human
319
+ // labels. The old form was `● ─ ● ─ ○ ─ ○ delivered` — a colored dot
320
+ // progression + the raw English word. Nobody could tell "delivered"
321
+ // (hub-side mark) from "acked" (node process received) from "replied"
322
+ // (agent actually processed).
323
+ //
324
+ // Two things this rewrite fixes:
325
+ //
326
+ // 1. STATUS_STEPS was `['created','delivered','running','replied']`.
327
+ // Neither `created` nor `running` correspond to hub-emitted terminal
328
+ // statuses (`created` is client-side optimistic, `running` is READ
329
+ // by hub queries but never WRITTEN — grep server/src for `= 'running'`
330
+ // returns zero). Real hub progression is queued → delivered → acked
331
+ // → replied, and the old third dot literally never lit up.
332
+ //
333
+ // 2. `delivered` was labeled as if it meant "message reached the node".
334
+ // Reading hub source (server/src/index.ts:1568 + tools.ts:1080/1232)
335
+ // shows it's set at task-insert time — same instant as delivered_at
336
+ // (which is why delivered_at delay is empirically 0). The label now
337
+ // says "已推送" and the hover explicitly disclaims node reception.
338
+ //
339
+ // Vocabulary — must stay in sync with hub emits:
340
+ // queued / delivered / acked / replied / failed ← the primary 5
341
+ // created ← client-side pre-POST
342
+ // closed / expired / cancelled ← terminal, non-reply
343
+ // running ← reserved (never written today)
344
+ // <anything else> ← fail-loudly (raw + banner)
345
+ const STATUS_STEPS = ['queued', 'delivered', 'acked', 'replied'] as const;
346
+ type StatusInfo = { label: string; hover: string; color: string; icon: React.ReactNode };
347
+ const STATUS_INFO: Record<string, StatusInfo> = {
348
+ created: { label: '发送中', hover: '客户端已建消息,尚未收到 hub 回执。', color: 'text-gray-400', icon: <IconSending /> },
349
+ queued: { label: '排队中', hover: '已入队,尚未推给节点。节点可能离线、或通道拥堵。', color: 'text-gray-400', icon: <IconClock /> },
350
+ delivered: { label: '已推送', hover: 'hub 侧已标记推送。尚未确认节点是否收到(delivered_at 是入队时刻,不代表 SSE 已到达)。', color: 'text-sky-400', icon: <IconCheck /> },
351
+ acked: { label: '已接收', hover: '节点进程已确认收到。未必已处理——对方 agent 可能尚未唤醒。', color: 'text-gray-300', icon: <IconCheckDouble /> },
352
+ replied: { label: '已回复', hover: '对方已针对此消息回复。', color: 'text-emerald-400', icon: <IconCheckDouble filled /> },
353
+ failed: { label: '失败', hover: '发送失败——详细原因见消息下方文本。', color: 'text-red-400', icon: <IconWarn /> },
354
+ closed: { label: '已关闭', hover: '会话已被关闭,不再期待回复。', color: 'text-gray-500', icon: <IconClose /> },
355
+ expired: { label: '已超时', hover: '在期限内未收到回复,任务已超时。', color: 'text-gray-500', icon: <IconClock /> },
356
+ cancelled: { label: '已取消', hover: '任务被主动取消。', color: 'text-gray-500', icon: <IconClose /> },
357
+ running: { label: '处理中', hover: '节点进程正在处理(今日 hub 不写入此值,见组件注释)。', color: 'text-green-400', icon: <IconClock /> },
322
358
  };
323
359
 
324
- function StatusBar({ status }: { status: string }) {
325
- const idx = STATUS_STEPS.indexOf(status);
326
- const isFailed = status === 'failed';
360
+ function StatusBar({ status, result }: { status: string; result?: string }) {
361
+ const info = STATUS_INFO[status];
362
+ const known = !!info;
363
+ // Progression label list for the hover — same string appended after
364
+ // the per-status hover so the reader gets a compact map of the flow.
365
+ // Not per-step timestamps: hub doesn't expose queued_at / acked_at
366
+ // separately from delivered_at, so a per-step timeline would be
367
+ // fabricated data. See feedback_assert_the_fact_not_the_declaration.
368
+ const idx = STATUS_STEPS.indexOf(status as typeof STATUS_STEPS[number]);
369
+ const flow = STATUS_STEPS.map((s, i) => {
370
+ const mark = i < idx || (i === idx && idx >= 0) ? '✓' : '○';
371
+ return `${mark} ${STATUS_INFO[s].label}`;
372
+ }).join(' → ');
373
+ const hoverText = known
374
+ ? `${info.hover}\n\n进度:${flow}`
375
+ : `未知状态「${status}」——请报 bug。UI 词库没有匹配项,可能 hub 加了新 status 但前端没跟上。`;
376
+ const label = known ? info.label : status;
377
+ const color = known ? info.color : 'text-yellow-400';
378
+
379
+ // failed inline reason — hub has no dedicated failure_reason column
380
+ // (task table: task_id/from_*/to_*/status/content/result/... —
381
+ // grep-verified as of 2026-07-31). We show `result` first 40 chars
382
+ // when present. When result is empty, we explicitly say "节点未提供
383
+ // 原因" rather than falling back to a generic "未知错误" — that
384
+ // distinguishes "hub has no error field" from "reason field exists
385
+ // but got a bad value".
386
+ const failedReason = status === 'failed'
387
+ ? ((result && result.trim()) ? result.trim().slice(0, 40) : '节点未提供原因')
388
+ : null;
389
+
327
390
  return (
328
- <div className="flex items-center gap-1 mt-1.5">
329
- {STATUS_STEPS.map((s, i) => (
330
- <div key={s} className="flex items-center gap-1">
331
- <div className={`w-2 h-2 rounded-full transition-all duration-700 ${
332
- isFailed ? (i === 0 ? 'bg-red-400' : 'bg-[var(--border)]')
333
- : i <= idx ? (STATUS_COLORS[s] || 'bg-gray-600') : 'bg-[var(--border)]'
334
- }`} />
335
- {i < STATUS_STEPS.length - 1 && (
336
- <div className={`w-4 h-px transition-colors duration-700 ${
337
- isFailed ? 'bg-red-800' : i < idx ? 'bg-gray-500' : 'bg-[var(--border)]'
338
- }`} />
339
- )}
340
- </div>
341
- ))}
342
- <span className={`text-[10px] ml-1.5 font-medium ${
343
- isFailed ? 'text-red-400' : status === 'replied' ? 'text-purple-400'
344
- : status === 'running' ? 'text-green-400' : 'text-[var(--fg-muted)]'
345
- }`}>{status}</span>
346
- {(status === 'running' || status === 'delivered') && (
347
- <span className="ml-1">
348
- <span className="inline-block w-1 h-1 rounded-full bg-green-400 animate-pulse" />
349
- <span className="inline-block w-1 h-1 rounded-full bg-green-400 animate-pulse ml-0.5" style={{ animationDelay: '0.2s' }} />
350
- <span className="inline-block w-1 h-1 rounded-full bg-green-400 animate-pulse ml-0.5" style={{ animationDelay: '0.4s' }} />
391
+ <div
392
+ data-status-key={status}
393
+ data-status-known={known ? 'true' : 'false'}
394
+ data-status-label={label}
395
+ className="inline-flex flex-col gap-0.5 mt-1.5"
396
+ >
397
+ <span
398
+ title={hoverText}
399
+ aria-label={known ? info.label : `未知状态 ${status}`}
400
+ className={`inline-flex items-center gap-1.5 text-[11px] font-medium ${color}`}
401
+ >
402
+ {known ? info.icon : <IconQuestion />}
403
+ <span>{label}</span>
404
+ {(status === 'delivered' || status === 'created') && (
405
+ <span aria-hidden className="inline-flex items-center gap-0.5 ml-0.5">
406
+ <span className="inline-block w-1 h-1 rounded-full bg-current opacity-70 animate-pulse" />
407
+ <span className="inline-block w-1 h-1 rounded-full bg-current opacity-70 animate-pulse" style={{ animationDelay: '0.2s' }} />
408
+ <span className="inline-block w-1 h-1 rounded-full bg-current opacity-70 animate-pulse" style={{ animationDelay: '0.4s' }} />
409
+ </span>
410
+ )}
411
+ </span>
412
+ {failedReason !== null && (
413
+ <span
414
+ data-status-reason={failedReason}
415
+ className="text-[10px] text-red-400/80 max-w-[260px] truncate"
416
+ title={result || undefined}
417
+ >
418
+ {failedReason}
351
419
  </span>
352
420
  )}
353
421
  </div>
354
422
  );
355
423
  }
356
424
 
425
+ // Icon primitives — inline SVG, single-color (currentColor), sized to
426
+ // match the 11px label text. No external icon library — keeps the
427
+ // StatusBar self-contained and avoids a Tailwind purge/tree-shake
428
+ // dance for icons only this component uses.
429
+ function IconClock() {
430
+ return (
431
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
432
+ <circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" />
433
+ </svg>
434
+ );
435
+ }
436
+ function IconCheck() {
437
+ return (
438
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
439
+ <path d="M4 12l6 6L20 6" />
440
+ </svg>
441
+ );
442
+ }
443
+ function IconCheckDouble({ filled }: { filled?: boolean } = {}) {
444
+ return (
445
+ <svg width="14" height="12" viewBox="0 0 28 24" fill="none" stroke="currentColor" strokeWidth={filled ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round">
446
+ <path d="M2 12l6 6L18 6" />
447
+ <path d="M10 12l6 6L26 6" opacity={filled ? 1 : 0.7} />
448
+ </svg>
449
+ );
450
+ }
451
+ function IconWarn() {
452
+ return (
453
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
454
+ <path d="M12 2L2 20h20L12 2z" /><path d="M12 9v5" /><circle cx="12" cy="17.5" r="0.6" fill="currentColor" />
455
+ </svg>
456
+ );
457
+ }
458
+ function IconClose() {
459
+ return (
460
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
461
+ <circle cx="12" cy="12" r="10" /><path d="M8 12h8" />
462
+ </svg>
463
+ );
464
+ }
465
+ function IconSending() {
466
+ return (
467
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
468
+ <path d="M4 12l16-8-6 16-3-7-7-1z" />
469
+ </svg>
470
+ );
471
+ }
472
+ function IconQuestion() {
473
+ return (
474
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
475
+ <circle cx="12" cy="12" r="10" />
476
+ <path d="M9.5 9.5a2.5 2.5 0 015 0c0 1.5-2.5 2-2.5 3.5" />
477
+ <circle cx="12" cy="17" r="0.6" fill="currentColor" />
478
+ </svg>
479
+ );
480
+ }
481
+
357
482
  // Full markdown rendering — handles tables, headings, lists, links, code,
358
483
  // blockquotes via react-markdown + GFM. Earlier hand-rolled parser only did
359
484
  // code blocks + bold; tables / headings showed as raw `|---|` text.
@@ -1442,7 +1567,7 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes, active }
1442
1567
  ))}
1443
1568
  </div>
1444
1569
  <div className="flex flex-wrap items-center justify-between mt-1.5 gap-2 sm:gap-3">
1445
- <StatusBar status={m.status} />
1570
+ <StatusBar status={m.status} result={m.result} />
1446
1571
  <div className="flex items-center gap-2 shrink-0">
1447
1572
  {/* R4: WeChat send-feedback loop — spinner while the
1448
1573
  optimistic bubble is still in flight; tap-to-resend
@@ -146,7 +146,7 @@ export default function MessagesPage() {
146
146
  }, [filtered]);
147
147
 
148
148
  return (
149
- <div className="min-h-screen bg-[#0b0b0d] text-gray-100 p-4 sm:p-6">
149
+ <div data-testid="messages-shell" className="min-h-screen bg-[#141826] text-gray-100 p-4 sm:p-6">
150
150
  {/* #209 R31 (mobile vertical rhythm — same pattern as R28/R30):
151
151
  header row + quick-from chips row both mb-6 → mb-4 sm:mb-6.
152
152
  #209 R33: header restructured to mirror /nodes R32 — title
@@ -253,7 +253,7 @@ export default function MessagesPage() {
253
253
  <button
254
254
  type="button"
255
255
  onClick={() => setSearch('')}
256
- className="shrink-0 rounded-full border border-gray-700 px-3 py-1 text-xs text-gray-500 hover:text-gray-200"
256
+ className="shrink-0 rounded-full border border-gray-700 px-3 py-1 text-[11px] text-[#64748B] hover:text-gray-200"
257
257
  >
258
258
  Clear
259
259
  </button>
@@ -336,9 +336,9 @@ export default function MessagesPage() {
336
336
  Halve it on mobile. */}
337
337
  {gapExceeded && (
338
338
  <div className="my-2 sm:my-4 flex items-center gap-3">
339
- <div className="h-px flex-1 bg-[#26262b]" />
340
- <div className="text-[11px] text-gray-600">{formatDividerLabel(message.created_at)}</div>
341
- <div className="h-px flex-1 bg-[#26262b]" />
339
+ <div className="h-px flex-1 bg-white/[0.06]" />
340
+ <div className="text-[13px] font-medium text-[#94A3B8]">{formatDividerLabel(message.created_at)}</div>
341
+ <div className="h-px flex-1 bg-white/[0.06]" />
342
342
  </div>
343
343
  )}
344
344
 
@@ -348,21 +348,21 @@ export default function MessagesPage() {
348
348
  390px. Desktop unchanged from sm: up. */}
349
349
  {variant === 'broadcast' ? (
350
350
  <div className={samePrev ? 'mt-0.5 sm:mt-1' : 'mt-2 sm:mt-3'}>
351
- <div className="rounded-2xl border border-purple-500/20 bg-purple-500/10 px-3 py-2 sm:px-4 sm:py-3 shadow-sm w-full">
351
+ <div className="rounded-2xl border border-white/[0.06] border-l-2 border-l-[#7DD3FC] bg-[#1E293B] px-3 py-2 sm:px-4 sm:py-3 shadow-sm w-full">
352
352
  <div className="mb-1 sm:mb-2 flex flex-wrap items-center gap-2">
353
353
  <span className={`text-xs px-2 py-0.5 rounded-md border ${TYPE_COLORS[message.type || ''] || 'bg-gray-500/10 text-gray-400 border-gray-500/20'}`}>
354
354
  {message.type || 'unknown'}
355
355
  </span>
356
- <span className="text-sm font-medium text-purple-200">{fromAlias}</span>
357
- {message.to_alias && <span className="text-xs text-purple-200/80">to {message.to_alias}</span>}
358
- {message.priority === 'high' && <span className="text-xs text-red-400">HIGH</span>}
359
- <span className="ml-auto text-xs text-gray-500">{message.created_at ? timeAgo(message.created_at) : '--'}</span>
356
+ <span className="text-sm font-medium text-[#E2E8F0]">{fromAlias}</span>
357
+ {message.to_alias && <span className="text-xs text-[#CBD5E1]">to {message.to_alias}</span>}
358
+ {message.priority === 'high' && <span className="text-xs text-[#EF4444]">HIGH</span>}
359
+ <span className="ml-auto text-[11px] text-[#64748B]">{message.created_at ? timeAgo(message.created_at) : '--'}</span>
360
360
  </div>
361
361
  {/* Round 79: break-words + overflow-wrap:anywhere so
362
362
  long unbroken runs (URLs, ASCII rules like
363
363
  `═══════════════`) wrap instead of pushing the
364
364
  chat bubble past the mobile viewport. */}
365
- <div className="whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-sm leading-snug sm:leading-relaxed text-gray-200">
365
+ <div className="whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-sm leading-snug sm:leading-relaxed text-[#E2E8F0]">
366
366
  {renderHighlighted(message.content, search)}
367
367
  </div>
368
368
  {/* #492: broadcast may carry attachments too. */}
@@ -377,23 +377,23 @@ export default function MessagesPage() {
377
377
  <div className="w-8 shrink-0 pt-1">
378
378
  {!samePrev && <AliasAvatar alias={fromAlias} size={32} />}
379
379
  </div>
380
- <div className={`min-w-0 max-w-3xl rounded-2xl border px-3 py-2 sm:px-4 sm:py-3 shadow-sm ${
380
+ <div className={`min-w-0 max-w-[min(640px,65%)] rounded-2xl border border-white/[0.06] px-3 py-2 sm:px-4 sm:py-3 shadow-sm ${
381
381
  variant === 'outgoing'
382
- ? 'border-green-500/20 bg-green-500/10'
383
- : 'border-blue-500/20 bg-blue-500/10'
382
+ ? 'bg-[#1E3A5F]'
383
+ : 'bg-[#1E293B]'
384
384
  }`}>
385
385
  {!samePrev && (
386
386
  <div className="mb-1 sm:mb-2 flex flex-wrap items-center gap-2">
387
387
  <span className={`text-xs px-2 py-0.5 rounded-md border ${TYPE_COLORS[message.type || ''] || 'bg-gray-500/10 text-gray-400 border-gray-500/20'}`}>
388
388
  {message.type || 'unknown'}
389
389
  </span>
390
- <span className={`text-sm font-medium ${variant === 'outgoing' ? 'text-green-300' : 'text-blue-300'}`}>
390
+ <span className={`text-sm font-medium ${variant === 'outgoing' ? 'text-[#F1F5F9]' : 'text-[#E2E8F0]'}`}>
391
391
  {fromAlias}
392
392
  </span>
393
- <span className="text-xs text-gray-600">&rarr;</span>
394
- <span className="text-sm text-gray-300">{message.to_alias || '--'}</span>
395
- {message.priority === 'high' && <span className="text-xs text-red-400">HIGH</span>}
396
- <span className="ml-auto text-xs text-gray-500">{message.created_at ? timeAgo(message.created_at) : '--'}</span>
393
+ <span className="text-xs text-[#64748B]">&rarr;</span>
394
+ <span className="text-sm text-[#CBD5E1]">{message.to_alias || '--'}</span>
395
+ {message.priority === 'high' && <span className="text-xs text-[#EF4444]">HIGH</span>}
396
+ <span className="ml-auto text-[11px] text-[#64748B]">{message.created_at ? timeAgo(message.created_at) : '--'}</span>
397
397
  </div>
398
398
  )}
399
399
 
@@ -401,7 +401,7 @@ export default function MessagesPage() {
401
401
  long unbroken runs (URLs, ASCII rules like
402
402
  `═══════════════`) wrap instead of pushing the
403
403
  chat bubble past the mobile viewport. */}
404
- <div className="whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-sm leading-snug sm:leading-relaxed text-gray-200">
404
+ <div className="whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-sm leading-snug sm:leading-relaxed text-[#E2E8F0]">
405
405
  {renderHighlighted(message.content, search)}
406
406
  </div>
407
407
 
@@ -417,13 +417,13 @@ export default function MessagesPage() {
417
417
 
418
418
  {/* When in a streak, surface the timestamp inline-right so users still know cadence */}
419
419
  {samePrev && (
420
- <div className="mt-1 text-[10px] text-gray-600 text-right">
420
+ <div className="mt-1 text-[11px] text-[#64748B] text-right">
421
421
  {message.created_at ? timeAgo(message.created_at) : ''}
422
422
  </div>
423
423
  )}
424
424
 
425
425
  {debug && (
426
- <div className="mt-3 border-t border-white/10 pt-3 text-xs text-gray-500 space-y-1">
426
+ <div className="mt-3 border-t border-white/10 pt-3 text-[11px] text-[#64748B] space-y-1">
427
427
  <div>ID: {message.id}</div>
428
428
  {message.task_id && <div>Task ID: {message.task_id}</div>}
429
429
  <div>Created: {message.created_at}</div>
@@ -4,7 +4,6 @@ import { useState, useRef, useEffect, useSyncExternalStore } from 'react';
4
4
  import { timeAgo } from '../components/utils';
5
5
  import { useSessions, useHealth, useNodeLifecycle } from '../lib/hooks';
6
6
  import { useSWRConfig } from 'swr';
7
- import { TaskChatPanel } from '../components/TaskChatPanel';
8
7
  import { CreateNodeWizard } from '../components/CreateNodeWizard';
9
8
  import { NodeLifecycleMenu } from '../components/NodeLifecycleMenu';
10
9
  import { EmptyState, NodesEmptyState } from '../components/EmptyState';
@@ -118,10 +117,15 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
118
117
  const [chatAlias, setChatAlias] = useState<string | null>(initialAlias);
119
118
  const isDesktop = useIsDesktop();
120
119
  const selectNode = (alias: string) => {
120
+ try {
121
+ const el = document.querySelector('[data-testid="node-list-scroll"]');
122
+ if (el) savedListScrollRef.current = el.scrollTop;
123
+ } catch {}
121
124
  setChatAlias(alias);
122
125
  try { window.history.pushState({}, '', `/nodes/${encodeURIComponent(alias)}`); } catch {}
123
126
  };
124
127
  const closeChat = () => {
128
+ userClosedRef.current = true; // ③ explicit close wins over auto-select
125
129
  setChatAlias(null);
126
130
  try { window.history.pushState({}, '', '/nodes'); } catch {}
127
131
  };
@@ -157,6 +161,42 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
157
161
  ro.observe(document.body);
158
162
  return () => { window.removeEventListener('resize', apply); ro.disconnect(); };
159
163
  }, []);
164
+ // #Vincent 插单 (07-31): entering /nodes with nothing selected showed the
165
+ // management table full-bleed — "这样特别难看". Feishu opens INTO a
166
+ // conversation, so desktop /nodes auto-selects the first (sorted) session.
167
+ // Three traps, each deliberately handled:
168
+ // ① URL via history.REPLACE, never push — push would trap the browser
169
+ // back button (/nodes → auto-select → /nodes/x → back → /nodes →
170
+ // auto-select again, forever).
171
+ // ② the table is NOT gone — it's the "manage all nodes" view, reachable
172
+ // via the explicit 管理视图 toggle below (localStorage-persisted; a
173
+ // user who prefers the table keeps it as their default and auto-select
174
+ // stays off).
175
+ // ③ an explicit ✕ close must NOT be answered by an immediate re-select
176
+ // (the ✕ would look broken) — userClosedRef gates auto-select off for
177
+ // the rest of this mount.
178
+ const [manageView, setManageView] = useState<boolean>(() => {
179
+ if (typeof window === 'undefined') return false;
180
+ return window.localStorage.getItem('anet-nodes-manage-view') === '1';
181
+ });
182
+ const autoSelectedRef = useRef(false);
183
+ const userClosedRef = useRef(false);
184
+ const toggleManageView = () => {
185
+ setManageView(v => {
186
+ const next = !v;
187
+ try { window.localStorage.setItem('anet-nodes-manage-view', next ? '1' : '0'); } catch {}
188
+ if (next) {
189
+ // entering manage view: drop any open chat, land on the table
190
+ userClosedRef.current = true;
191
+ if (chatAlias) closeChat();
192
+ } else {
193
+ // back to conversation view: let auto-select pick up again
194
+ userClosedRef.current = false;
195
+ autoSelectedRef.current = false;
196
+ }
197
+ return next;
198
+ });
199
+ };
160
200
  const [showCreate, setShowCreate] = useState(false);
161
201
  // #Stage A (dashboard 3-col, 07-31): persistent narrow node list rail
162
202
  // to the left of the existing table. Default on; user can hide to
@@ -230,6 +270,15 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
230
270
  // "rail count == table count" true at all times.
231
271
  const railSessions = filtered;
232
272
 
273
+ const firstAlias = filtered.length > 0 ? filtered[0].alias : null;
274
+ useEffect(() => {
275
+ if (!isDesktop || manageView || chatAlias || userClosedRef.current || autoSelectedRef.current) return;
276
+ if (!firstAlias) return; // empty list: nothing to select, no loop
277
+ autoSelectedRef.current = true;
278
+ setChatAlias(firstAlias);
279
+ try { window.history.replaceState({}, '', `/nodes/${encodeURIComponent(firstAlias)}`); } catch {}
280
+ }, [isDesktop, manageView, chatAlias, firstAlias]);
281
+
233
282
  // #Stage C: with a node selected on desktop, the page is LIST + CHAT —
234
283
  // the middle table does not render (通信龙 production screenshot catch:
235
284
  // rail and table were painting the same 199 nodes twice, squeezing the
@@ -249,6 +298,31 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
249
298
  // column rendered.
250
299
  const chatOpenDesktop = !!chatAlias && isDesktop && sessions.length > 0;
251
300
 
301
+ // #Mobile two-level nav (Feishu phone form, 通信龙 07-31, breakpoint md
302
+ // per my own ruling — no third media query):
303
+ // < md unselected = conversation LIST fullscreen (not the table)
304
+ // < md selected = chat as a full PAGE with a back arrow
305
+ // manage mode = the old table, reachable via the same toggle
306
+ // Desktop auto-selects; mobile deliberately does NOT (the list IS the
307
+ // first screen — auto-select effect above is gated on isDesktop).
308
+ const mobileListLevel = !isDesktop && !chatAlias && !manageView && sessions.length > 0;
309
+ const mobileChatLevel = !isDesktop && !!chatAlias;
310
+
311
+ // #Mobile back restores the list scroll position (判据: enter from row
312
+ // ~50, come back, offset ≤200px — snapping to top is the annoying bug).
313
+ // Saved at selection time; restored when the list level remounts.
314
+ // Desktop is exempt: its rail never unmounts while chatting, and the
315
+ // user may have scrolled it meanwhile.
316
+ const savedListScrollRef = useRef(0);
317
+ useEffect(() => {
318
+ if (isDesktop || chatAlias) return;
319
+ const id = requestAnimationFrame(() => {
320
+ const el = document.querySelector('[data-testid="node-list-scroll"]');
321
+ if (el) el.scrollTop = savedListScrollRef.current;
322
+ });
323
+ return () => cancelAnimationFrame(id);
324
+ }, [isDesktop, chatAlias]);
325
+
252
326
  return (
253
327
  <div
254
328
  ref={rootRef}
@@ -264,10 +338,10 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
264
338
  #Stage C: a selected node FORCES the rail on (even for dense-view
265
339
  users with showRail=false) — in the list+chat layout the rail is
266
340
  the only way to switch nodes, so it must be there. */}
267
- {(showRail || chatOpenDesktop) && sessions.length > 0 && (
341
+ {(mobileListLevel || (isDesktop && (showRail || chatOpenDesktop) && sessions.length > 0)) && (
268
342
  <aside
269
343
  data-testid="node-list-rail"
270
- className="hidden sm:flex sm:flex-col w-[280px] shrink-0 h-full min-h-0 bg-[#0F1320]"
344
+ className="flex flex-col w-full md:w-[280px] shrink-0 h-full min-h-0 bg-[#0F1320]"
271
345
  >
272
346
  <NodeList
273
347
  sessions={railSessions}
@@ -276,13 +350,37 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
276
350
  search={search}
277
351
  onSearchChange={setSearch}
278
352
  />
353
+ {/* #Vincent 插单 ②: explicit doorway between conversation-first
354
+ and table-first ("manage all nodes") modes. Lives in the
355
+ aside (NodesView's JSX) — NOT in NodeList, which IM马 is
356
+ editing concurrently. */}
357
+ <button
358
+ type="button"
359
+ onClick={toggleManageView}
360
+ data-testid="manage-view-toggle"
361
+ className="shrink-0 border-t border-[rgba(255,255,255,0.06)] bg-[#0F1320] px-3 py-2 text-left text-[11px] text-gray-500 hover:text-gray-200 transition-colors"
362
+ >
363
+ {manageView ? t('≡ 对话视图', '≡ Conversation view') : t('☷ 管理视图', '☷ Manage view')}
364
+ </button>
279
365
  </aside>
280
366
  )}
281
367
  {/* #Stage C: middle column (header + filters + table/grid) renders
282
368
  only when NO node is selected on desktop — see chatOpenDesktop
283
369
  note above. Mobile keeps it always (chat is an overlay there). */}
284
- {!chatOpenDesktop && (
285
- <div className="flex-1 min-w-0 max-w-full h-full overflow-y-auto overflow-x-hidden p-4 sm:p-6">
370
+ {(isDesktop ? !chatOpenDesktop : (manageView && !chatAlias)) && (
371
+ <div className="flex-1 min-w-0 max-w-full h-full overflow-y-auto overflow-x-hidden p-4 sm:p-6 pb-[calc(5rem+env(safe-area-inset-bottom))] lg:pb-6">
372
+ {/* #Mobile manage mode: way back to the conversation list — the rail
373
+ (which hosts the toggle) is hidden at this level on phones. */}
374
+ {!isDesktop && manageView && (
375
+ <button
376
+ type="button"
377
+ onClick={toggleManageView}
378
+ data-testid="manage-view-toggle-mobile"
379
+ className="md:hidden mb-3 rounded-lg border border-[rgba(255,255,255,0.08)] bg-[#0F1320] px-3 py-1.5 text-[12px] text-gray-400 hover:text-gray-200"
380
+ >
381
+ {t('≡ 返回对话视图', '≡ Back to conversations')}
382
+ </button>
383
+ )}
286
384
  {/* #209 R31 (mobile vertical rhythm — goal "大幅提升移动端体验",
287
385
  extending R28's Overview pattern + R30's /tasks pattern):
288
386
  this header row + the status-bar wrapper + the filter row
@@ -702,18 +800,19 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
702
800
  </div>
703
801
  )}
704
802
 
705
- {/* #Stage B: below md the chat keeps the pre-B behavior — a
706
- full-screen overlay push (TaskChatPanel's own fixed shell).
707
- On md+ the persistent right pane below renders instead; the
708
- two are mutually exclusive via isDesktop so only ONE
709
- TaskChatPanel is ever mounted (no double SSE / mark-read). */}
710
- {chatAlias && !isDesktop && <TaskChatPanel alias={chatAlias} onClose={closeChat} />}
803
+ {/* (#Stage B's mobile overlay is gone — < md chat is now a full
804
+ PAGE, rendered as ChatPane variant="page" below. isDesktop
805
+ still guarantees only ONE chat body is ever mounted.) */}
711
806
  </div>
712
807
  )}
713
808
  {/* #Stage B: persistent right chat column (md+) — see ChatPane.tsx
714
809
  (ChatPopover minus the floating shell; TaskChatPanel inline body).
715
810
  Switching nodes swaps `alias` in place (no remount). */}
716
811
  {chatAlias && isDesktop && <ChatPane alias={chatAlias} onClose={closeChat} />}
812
+ {/* #Mobile two-level nav: chat is a full PAGE (not the old overlay).
813
+ Back arrow and browser back both land on the list (closeChat /
814
+ popstate) — 判据: 行为一致. */}
815
+ {mobileChatLevel && <ChatPane alias={chatAlias!} variant="page" onBack={closeChat} onClose={closeChat} />}
717
816
  </div>
718
817
  );
719
818
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/agent-network-dashboard",
3
- "version": "0.6.3-preview.50",
3
+ "version": "0.6.3-preview.52",
4
4
  "description": "Agent Network Dashboard — Web UI for managing AI Agent networks",
5
5
  "main": "apps/desktop/electron/main.cjs",
6
6
  "scripts": {
@@ -80,16 +80,23 @@ const HUB_HEALTH_EMPTY = {
80
80
  sse_sessions: {},
81
81
  };
82
82
 
83
+ // Login rate limit is 10/15min/IP (app/lib/rate-limit.ts): with 4 spec
84
+ // files in one combined run, per-test logins blow the budget and late
85
+ // tests land on the sign-in page (false red). Login once per worker.
86
+ let cachedSessionCookie: string | null = null;
83
87
  async function login(context: Page['context'] extends () => infer C ? C : never) {
84
- const res = await context.request.post(`${BASE}/api/auth/login`, {
85
- data: { password: process.env.ANET_DASHBOARD_PASSWORD || 'admin123' },
86
- });
87
- const setCookie = res.headers()['set-cookie'] || '';
88
- const m = setCookie.match(/anet_dashboard_session=([^;]+)/);
89
- if (m) {
88
+ if (!cachedSessionCookie) {
89
+ const res = await context.request.post(`${BASE}/api/auth/login`, {
90
+ data: { password: process.env.ANET_DASHBOARD_PASSWORD || 'admin123' },
91
+ });
92
+ const setCookie = res.headers()['set-cookie'] || '';
93
+ const m = setCookie.match(/anet_dashboard_session=([^;]+)/);
94
+ if (m) cachedSessionCookie = decodeURIComponent(m[1]);
95
+ }
96
+ if (cachedSessionCookie) {
90
97
  await context.addCookies([{
91
98
  name: 'anet_dashboard_session',
92
- value: decodeURIComponent(m[1]),
99
+ value: cachedSessionCookie,
93
100
  domain: new URL(BASE).hostname,
94
101
  path: '/',
95
102
  }]);
@@ -97,6 +104,9 @@ async function login(context: Page['context'] extends () => infer C ? C : never)
97
104
  }
98
105
 
99
106
  async function mockCommon(page: Page, statusBody: object, nodesBody: object) {
107
+ // #Vincent auto-select (07-31): filter suite exercises the manage/table
108
+ // view — opt in explicitly now that /nodes defaults to conversation-first.
109
+ await page.addInitScript(() => window.localStorage.setItem('anet-nodes-manage-view', '1'));
100
110
  await page.route('**/api/hub/status**', r =>
101
111
  r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(statusBody) }));
102
112
  await page.route('**/api/hub/nodes**', r =>