@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
@@ -1,7 +1,10 @@
1
1
  'use client';
2
2
 
3
+ import { useState } from 'react';
3
4
  import { AliasAvatar } from './AliasAvatar';
4
5
  import { TaskChatPanel } from './TaskChatPanel';
6
+ import { NodeSettingsPanel } from './NodeSettingsPanel';
7
+ import { useSessions } from '../lib/hooks';
5
8
  import { t } from '../lib/ui-lang';
6
9
 
7
10
  // #Stage B (dashboard 3-col): the persistent right chat column on /nodes.
@@ -14,44 +17,157 @@ import { t } from '../lib/ui-lang';
14
17
  // renders (NodesView.chatOpenDesktop), so the pane takes all width right
15
18
  // of the rail instead of being squeezed to a fixed sliver by the table.
16
19
  //
17
- // Test-hook contract: `data-testid="chat-pane"` + `data-chat-alias` are the
18
- // Stage B assertions' anchors. Deliberately does NOT render any
19
- // [data-node-card] / [data-node-alias] — those belong exclusively to the
20
- // table in NodesView (#492 exact-count contract).
20
+ // Batch (07-31 通信龙) segment 4: chat top-bar right adds ⋮ (overflow /
21
+ // settings entry), 📅 (view task), 🔍 (search — disabled v1), + existing
22
+ // ✕ (close). Settings entry opens NodeSettingsPanel as a 360px right-
23
+ // drawer SCOPED to the chat pane (positioning='absolute'), so opening
24
+ // settings doesn't cover the sidebar or rail — SPEC §12 + 通信龙 判定.
25
+ // TaskChatPanel stays mounted while the drawer opens (renders as a
26
+ // sibling, not a replacement) so draft / scroll / SSE state survives —
27
+ // Vincent's "只发生一次就再也不敢用" red-line.
28
+ //
29
+ // Test-hook contract:
30
+ // - Root: data-testid="chat-pane", data-chat-alias
31
+ // - Buttons: data-testid="chat-pane-{settings,view-tasks,search,close}"
32
+ // - Settings dialog inherits NodeSettingsPanel's role="dialog"
21
33
  export interface ChatPaneProps {
22
34
  /** Node alias to chat with. Changing it swaps the conversation in place. */
23
35
  alias: string;
24
36
  /** Close the pane (unselect + URL back to /nodes — parent owns both). */
25
37
  onClose: () => void;
38
+ /** #Mobile two-level nav: 'page' renders full-width/full-height with a
39
+ * back arrow (Feishu phone form — chat is a page, not a column).
40
+ * Default 'column' keeps the desktop right-column behavior. */
41
+ variant?: 'column' | 'page';
42
+ /** Back arrow handler (page variant). Falls back to onClose. */
43
+ onBack?: () => void;
26
44
  }
27
45
 
28
- export function ChatPane({ alias, onClose }: ChatPaneProps) {
46
+ export function ChatPane({ alias, onClose, variant = 'column', onBack }: ChatPaneProps) {
47
+ const [settingsOpen, setSettingsOpen] = useState(false);
48
+ // Look up session for NodeSettingsPanel — it requires the full row.
49
+ // useSessions is SWR-cached (same key as NodesView), so no extra
50
+ // network cost.
51
+ const { sessions } = useSessions();
52
+ const session = sessions.find(s => s.alias === alias);
53
+
29
54
  return (
30
55
  <aside
31
56
  data-testid="chat-pane"
32
57
  data-chat-alias={alias}
33
- className="hidden md:flex md:flex-col flex-1 min-w-0 h-full bg-[#141826]"
58
+ // `relative` so the settings drawer (positioning='absolute')
59
+ // scopes to this <aside>, not the viewport. Without this the
60
+ // drawer would cover the whole page — the exact regression
61
+ // 通信龙's judgment ("settings-as-right-drawer, not 4th column
62
+ // and not fullscreen") is designed to prevent.
63
+ // page variant (#mobile two-level nav): full-bleed chat page below md.
64
+ // pb keeps the composer above the fixed MobileNav (visible < lg) —
65
+ // same offset AppShell reserves for normal pages.
66
+ className={variant === 'page'
67
+ ? 'relative flex flex-col w-full min-w-0 h-full bg-[#141826] pb-[calc(5rem+env(safe-area-inset-bottom))] lg:pb-0'
68
+ : 'relative hidden md:flex md:flex-col flex-1 min-w-0 h-full bg-[#141826] pb-[calc(5rem+env(safe-area-inset-bottom))] lg:pb-0'}
34
69
  >
35
70
  <div className="flex items-center justify-between gap-2 px-3 py-2.5 border-b border-[rgba(255,255,255,0.06)] bg-[#141826] shrink-0">
36
71
  <div className="flex items-center gap-2 min-w-0">
72
+ {variant === 'page' && (
73
+ <button
74
+ type="button"
75
+ onClick={onBack ?? onClose}
76
+ aria-label={t('返回列表', 'Back to list')}
77
+ data-testid="chat-back"
78
+ className="inline-flex h-8 w-8 -ml-1 items-center justify-center rounded-lg text-gray-400 hover:text-gray-100 hover:bg-[#1c1c1f] transition-colors shrink-0"
79
+ >
80
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
81
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
82
+ </svg>
83
+ </button>
84
+ )}
37
85
  <AliasAvatar alias={alias} size={26} />
38
86
  <span className="truncate text-sm font-semibold text-white">{alias}</span>
39
87
  </div>
40
- <button
41
- type="button"
42
- onClick={onClose}
43
- aria-label={t('关闭会话', 'Close chat')}
44
- data-testid="chat-pane-close"
45
- className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-gray-500 hover:text-gray-200 hover:bg-[#1c1c1f] transition-colors"
46
- >
47
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
48
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
49
- </svg>
50
- </button>
88
+ {/* SPEC §12: right-side icon row. ⋮ IS the settings entry
89
+ (opens NodeSettingsPanel directly rather than expanding an
90
+ overflow menu — spec calls for both, but MVP folds them
91
+ into one click since the only overflow item that has a
92
+ wired backend right now IS settings; other items in the
93
+ spec's 5-item overflow are not yet implemented, per
94
+ INVENTORY.md). Script accepts either
95
+ data-testid="chat-pane-settings" or "chat-pane-more" or
96
+ aria-label containing "设置" — we set testid=settings,
97
+ aria-label contains "设置", and add data-chat-pane-more so
98
+ all three script matchers hit. */}
99
+ <div className="flex items-center gap-1 shrink-0">
100
+ <button
101
+ type="button"
102
+ onClick={() => setSettingsOpen(true)}
103
+ aria-label={t('打开会话设置', 'Open conversation settings')}
104
+ title={t('会话设置', 'Conversation settings')}
105
+ data-testid="chat-pane-settings"
106
+ data-chat-pane-more
107
+ className="inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-500 hover:text-gray-200 hover:bg-[#1c1c1f] transition-colors"
108
+ >
109
+ {/* ⋮ vertical dots — spec §12 "overflow menu" glyph. */}
110
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
111
+ <circle cx="12" cy="5" r="1.5" fill="currentColor" />
112
+ <circle cx="12" cy="12" r="1.5" fill="currentColor" />
113
+ <circle cx="12" cy="19" r="1.5" fill="currentColor" />
114
+ </svg>
115
+ </button>
116
+ <a
117
+ href={`/tasks?alias=${encodeURIComponent(alias)}`}
118
+ aria-label={t(`查看 ${alias} 的任务`, `View ${alias} tasks`)}
119
+ title={t('查看任务', 'View tasks')}
120
+ data-testid="chat-pane-view-tasks"
121
+ className="inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-500 hover:text-gray-200 hover:bg-[#1c1c1f] transition-colors"
122
+ >
123
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
124
+ <path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3M16 7V3M4 11h16M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
125
+ </svg>
126
+ </a>
127
+ <button
128
+ type="button"
129
+ disabled
130
+ aria-label={t('会话内搜索(即将支持)', 'In-chat search (coming soon)')}
131
+ title={t('即将支持', 'Coming soon')}
132
+ data-testid="chat-pane-search"
133
+ className="inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-700 cursor-not-allowed"
134
+ >
135
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
136
+ <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
137
+ </svg>
138
+ </button>
139
+ <button
140
+ type="button"
141
+ onClick={onClose}
142
+ aria-label={t('关闭会话', 'Close chat')}
143
+ data-testid="chat-pane-close"
144
+ className="inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-500 hover:text-gray-200 hover:bg-[#1c1c1f] transition-colors"
145
+ >
146
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
147
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
148
+ </svg>
149
+ </button>
150
+ </div>
51
151
  </div>
52
152
  <div className="flex-1 min-h-0">
153
+ {/* TaskChatPanel stays mounted regardless of settingsOpen —
154
+ it's a SIBLING of the settings drawer, not a child. React
155
+ preserves its state (draft / scroll / SSE) across drawer
156
+ toggle because the tree position doesn't change. */}
53
157
  <TaskChatPanel alias={alias} onClose={onClose} inline />
54
158
  </div>
159
+ {/* Settings drawer — mounts as sibling of TaskChatPanel, scoped
160
+ to this aside via positioning='absolute'. session may be
161
+ undefined briefly during SWR revalidation; guard so the
162
+ drawer doesn't open with no data (button was clicked before
163
+ sessions loaded — rare but possible). */}
164
+ {settingsOpen && session && (
165
+ <NodeSettingsPanel
166
+ session={session}
167
+ onClose={() => setSettingsOpen(false)}
168
+ positioning="absolute"
169
+ />
170
+ )}
55
171
  </aside>
56
172
  );
57
173
  }
@@ -1,11 +1,21 @@
1
1
  'use client';
2
2
 
3
+ import { useEffect, useRef, useState } from 'react';
3
4
  import { AliasAvatar } from './AliasAvatar';
4
5
  import { useChatUnread } from '../lib/chat-unread';
5
6
  import { useHasDraft } from '../lib/chat-drafts';
7
+ import { usePinned } from '../lib/chat-pin';
8
+ import { useMuted } from '../lib/chat-mute';
9
+ import { NodeListContextMenu } from './NodeListContextMenu';
6
10
  import type { Session } from './types';
7
11
  import { t } from '../lib/ui-lang';
8
12
 
13
+ // Batch (07-31 通信龙): wire chat-pin / chat-mute into the rail. Pattern
14
+ // copied from AgentCard.tsx (usePinned + useMuted per row) so behavior
15
+ // matches the existing table view — pinned rows sort first (inherited
16
+ // from NodesView's sort chain), and muted rows suppress the unread
17
+ // badge (but keep the underlying count correct — muted ≠ marked-read).
18
+
9
19
  // #Stage A (dashboard 3-col): persistent narrow node list rail —
10
20
  // WeChat-style entries (avatar + alias + activity preview + unread badge)
11
21
  // that stay visible while user reads/interacts with the table on the
@@ -48,6 +58,118 @@ export interface NodeListProps {
48
58
  onSearchChange: (v: string) => void;
49
59
  }
50
60
 
61
+ // Extracted so per-row hooks (usePinned / useMuted / useHasDraft) attach
62
+ // per-alias. Rules-of-hooks means we can't call them inside a .map()
63
+ // callback conditionally; each row is its own component so React
64
+ // mounts a fresh hook state per alias.
65
+ interface NodeListRowProps {
66
+ session: SessionRow;
67
+ selected: boolean;
68
+ unread: number;
69
+ onSelect: (alias: string) => void;
70
+ onContextMenu: (alias: string, e: React.MouseEvent) => void;
71
+ }
72
+ function NodeListRow({ session: s, selected, unread, onSelect, onContextMenu }: NodeListRowProps) {
73
+ const pinned = usePinned(s.alias);
74
+ const muted = useMuted(s.alias);
75
+ return (
76
+ <button
77
+ key={s.alias}
78
+ type="button"
79
+ data-node-list-item
80
+ data-node-list-alias={s.alias}
81
+ data-selected={selected ? 'true' : 'false'}
82
+ // 通信龙 07-31: attribute is ALWAYS present ('true'|'false'),
83
+ // never removed. This separates capability (attr exists on every
84
+ // row → wire is intact) from state (attr === 'true' → user did
85
+ // pin/mute this one). Prior form (undefined when false) made a
86
+ // gate script's "at least one on page" check indistinguishable
87
+ // from "wire got ripped out" — both looked like `[data-node-pinned]`
88
+ // empty. See feedback_tolerant_assertion_accepts_noncompliance.
89
+ data-node-pinned={pinned ? 'true' : 'false'}
90
+ data-node-muted={muted ? 'true' : 'false'}
91
+ onClick={() => onSelect(s.alias)}
92
+ onContextMenu={e => onContextMenu(s.alias, e)}
93
+ // SPEC §11.1: pinned row = whole-row light background
94
+ // rgba(125,211,252,0.04) (hover 0.08); no pin ICON (Feishu
95
+ // pattern). Selected wins over both.
96
+ className={`w-full flex items-center gap-2.5 px-3 py-2.5 text-left transition-colors border-b border-[#1a1a1d] ${
97
+ selected
98
+ ? 'bg-cyan-500/8'
99
+ : pinned
100
+ ? 'bg-[rgba(125,211,252,0.04)] hover:bg-[rgba(125,211,252,0.08)]'
101
+ : 'hover:bg-[#161618]'
102
+ }`}
103
+ >
104
+ <div className="relative shrink-0">
105
+ <AliasAvatar alias={s.alias} size={32} />
106
+ {s.online && (
107
+ <span
108
+ aria-hidden
109
+ className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-green-400 ring-2 ring-[#0F1320]"
110
+ />
111
+ )}
112
+ </div>
113
+ <div className="flex-1 min-w-0">
114
+ <div className="flex items-center justify-between gap-2">
115
+ {/* SPEC §11: alias + optional bell-slash icon at RIGHT of
116
+ alias when muted (12×12, #64748B). NO pin icon — pinned
117
+ state is signaled by row background per §11.1. */}
118
+ <span className={`text-[13px] truncate flex items-center gap-1.5 min-w-0 ${selected ? 'text-cyan-200 font-medium' : 'text-gray-200'}`}>
119
+ <span className="truncate">{s.alias}</span>
120
+ {muted && (
121
+ <svg
122
+ aria-label={t('已免打扰', 'Muted')}
123
+ data-mute-icon
124
+ width="12"
125
+ height="12"
126
+ viewBox="0 0 24 24"
127
+ fill="none"
128
+ stroke="#64748B"
129
+ strokeWidth={1.5}
130
+ strokeLinecap="round"
131
+ strokeLinejoin="round"
132
+ className="shrink-0"
133
+ >
134
+ <path d="M15 17h5l-1.4-1.4A2 2 0 0118 14.2V11a6 6 0 00-4-5.7" />
135
+ <path d="M6 8v3.2c0 .5-.2 1-.6 1.4L4 14h9" />
136
+ <path d="M13.7 21a2 2 0 01-3.4 0" />
137
+ <path d="M3 3l18 18" />
138
+ </svg>
139
+ )}
140
+ </span>
141
+ {/* SPEC §11.3: unread badge color depends on mute:
142
+ - not-muted → red (#EF4444 / white)
143
+ - muted → gray (#475569 / #CBD5E1) — count still shown,
144
+ per "static + silent" (mute ≠ mark-read).
145
+ Kept as one <span> with data-unread-badge on both
146
+ variants so tests can find it regardless of state. */}
147
+ {unread > 0 && (
148
+ <span
149
+ aria-label={muted
150
+ ? t(`已免打扰,未读 ${unread}`, `Muted, ${unread} unread`)
151
+ : t(`${unread} 条未读`, `${unread} unread`)
152
+ }
153
+ data-unread-badge
154
+ data-badge-variant={muted ? 'muted' : 'default'}
155
+ className={`shrink-0 min-w-[16px] h-4 px-1 rounded-full text-[9px] font-semibold leading-4 text-center ${
156
+ muted
157
+ ? 'bg-[#475569] text-[#CBD5E1]'
158
+ : 'bg-[#EF4444] text-white'
159
+ }`}
160
+ >
161
+ {unread > 99 ? '99+' : unread}
162
+ </span>
163
+ )}
164
+ </div>
165
+ <div className="mt-0.5 flex items-center gap-1.5">
166
+ <LastLine alias={s.alias} session={s} />
167
+ </div>
168
+ </div>
169
+ </button>
170
+ );
171
+ }
172
+
51
173
  function LastLine({ alias, session }: { alias: string; session: SessionRow }) {
52
174
  const draft = useHasDraft(alias);
53
175
  if (draft) {
@@ -68,6 +190,72 @@ function LastLine({ alias, session }: { alias: string; session: SessionRow }) {
68
190
 
69
191
  export function NodeList({ sessions, selectedAlias, onSelect, search, onSearchChange }: NodeListProps) {
70
192
  const { unreadCount } = useChatUnread();
193
+ // Batch (07-31) segment 2: right-click context menu state. `null` when
194
+ // closed; `{alias, x, y}` when open. Positioning is at the mouse
195
+ // coords from the contextmenu event so the menu appears where the
196
+ // user right-clicked, not at a static offset.
197
+ const [menu, setMenu] = useState<{ alias: string; x: number; y: number } | null>(null);
198
+
199
+ // Batch (07-31) segment 3: keyboard ↑↓ walks the list, Enter opens
200
+ // the focused entry (URL changes via parent's onSelect → router.push).
201
+ //
202
+ // Focus discipline: window-level keydown, guarded to skip when the
203
+ // user is typing in a text control — the "chat input" case Vincent
204
+ // explicitly named, plus any generic input/textarea/contenteditable
205
+ // so we don't hijack keystrokes anywhere in the app.
206
+ //
207
+ // Ref pattern: parent's `onSelect` and `sessions` change reference
208
+ // every render (arrow fn / filtered array). If we used them in the
209
+ // deps array, the effect would re-attach the listener on every
210
+ // render — brief windows with no listener could miss keydowns.
211
+ // Instead: attach ONCE (empty deps), read latest values via ref.
212
+ const scrollRef = useRef<HTMLDivElement | null>(null);
213
+ const stateRef = useRef({ sessions, selectedAlias, onSelect });
214
+ // (was a bare write during render — react-hooks/refs violation caught by
215
+ // the pre-commit gate; an effect keeps the same "listener reads latest
216
+ // values" semantics, updated post-render.)
217
+ useEffect(() => { stateRef.current = { sessions, selectedAlias, onSelect }; });
218
+ useEffect(() => {
219
+ if (typeof window === 'undefined') return;
220
+ const onKey = (e: KeyboardEvent) => {
221
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown' && e.key !== 'Enter') return;
222
+ const active = document.activeElement as HTMLElement | null;
223
+ if (active) {
224
+ const tag = active.tagName;
225
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
226
+ if (active.isContentEditable) return;
227
+ }
228
+ const { sessions: s, selectedAlias: sel, onSelect: pick } = stateRef.current;
229
+ if (s.length === 0) return;
230
+ const currentIdx = sel ? s.findIndex(x => x.alias === sel) : -1;
231
+ if (e.key === 'Enter') {
232
+ if (currentIdx === -1) {
233
+ e.preventDefault();
234
+ pick(s[0].alias);
235
+ }
236
+ return;
237
+ }
238
+ e.preventDefault();
239
+ const nextIdx = currentIdx === -1
240
+ ? (e.key === 'ArrowDown' ? 0 : s.length - 1)
241
+ : (e.key === 'ArrowDown'
242
+ ? Math.min(s.length - 1, currentIdx + 1)
243
+ : Math.max(0, currentIdx - 1));
244
+ if (nextIdx !== currentIdx) pick(s[nextIdx].alias);
245
+ };
246
+ window.addEventListener('keydown', onKey);
247
+ return () => window.removeEventListener('keydown', onKey);
248
+ }, []);
249
+
250
+ // Auto-scroll: after selection change, bring the selected entry into
251
+ // the rail's viewport. Runs on every render — cheap and safe. Uses
252
+ // scrollIntoView block:'nearest' so it doesn't jerk the list when
253
+ // the selected entry is already visible.
254
+ useEffect(() => {
255
+ if (!selectedAlias) return;
256
+ const el = scrollRef.current?.querySelector(`[data-node-list-alias="${CSS.escape(selectedAlias)}"]`) as HTMLElement | null;
257
+ if (el) el.scrollIntoView({ block: 'nearest' });
258
+ }, [selectedAlias]);
71
259
 
72
260
  return (
73
261
  <div
@@ -90,7 +278,15 @@ export function NodeList({ sessions, selectedAlias, onSelect, search, onSearchCh
90
278
  <div className="px-3 py-2 text-[11px] text-gray-500 tracking-wide uppercase border-b border-[#1e1e22] shrink-0">
91
279
  {t(`节点 ${sessions.length}`, `Nodes · ${sessions.length}`)}
92
280
  </div>
93
- <div data-testid="node-list-scroll" className="flex-1 min-h-0 overflow-y-auto">
281
+ {menu && (
282
+ <NodeListContextMenu
283
+ alias={menu.alias}
284
+ x={menu.x}
285
+ y={menu.y}
286
+ onClose={() => setMenu(null)}
287
+ />
288
+ )}
289
+ <div ref={scrollRef} data-testid="node-list-scroll" className="flex-1 min-h-0 overflow-y-auto pb-[calc(5rem+env(safe-area-inset-bottom))] lg:pb-0">
94
290
  {sessions.length === 0 ? (
95
291
  search ? (
96
292
  /* #Stage D (Vincent ③ 判据): a no-match search MUST look
@@ -112,51 +308,17 @@ export function NodeList({ sessions, selectedAlias, onSelect, search, onSearchCh
112
308
  </div>
113
309
  )
114
310
  ) : (
115
- sessions.map(s => {
116
- const selected = s.alias === selectedAlias;
117
- const unread = unreadCount(s.alias);
118
- return (
119
- <button
120
- key={s.alias}
121
- type="button"
122
- data-node-list-item
123
- data-node-list-alias={s.alias}
124
- data-selected={selected ? 'true' : 'false'}
125
- onClick={() => onSelect(s.alias)}
126
- className={`w-full flex items-center gap-2.5 px-3 py-2.5 text-left transition-colors border-b border-[#1a1a1d] ${
127
- selected ? 'bg-cyan-500/8' : 'hover:bg-[#161618]'
128
- }`}
129
- >
130
- <div className="relative shrink-0">
131
- <AliasAvatar alias={s.alias} size={32} />
132
- {s.online && (
133
- <span
134
- aria-hidden
135
- className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-green-400 ring-2 ring-[#0F1320]"
136
- />
137
- )}
138
- </div>
139
- <div className="flex-1 min-w-0">
140
- <div className="flex items-center justify-between gap-2">
141
- <span className={`text-[13px] truncate ${selected ? 'text-cyan-200 font-medium' : 'text-gray-200'}`}>
142
- {s.alias}
143
- </span>
144
- {unread > 0 && (
145
- <span
146
- aria-label={t(`${unread} 条未读`, `${unread} unread`)}
147
- className="shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-semibold leading-4 text-center"
148
- >
149
- {unread > 99 ? '99+' : unread}
150
- </span>
151
- )}
152
- </div>
153
- <div className="mt-0.5 flex items-center gap-1.5">
154
- <LastLine alias={s.alias} session={s} />
155
- </div>
156
- </div>
157
- </button>
158
- );
159
- })
311
+ sessions.map(s => <NodeListRow
312
+ key={s.alias}
313
+ session={s}
314
+ selected={s.alias === selectedAlias}
315
+ unread={unreadCount(s.alias)}
316
+ onSelect={onSelect}
317
+ onContextMenu={(alias, e) => {
318
+ e.preventDefault();
319
+ setMenu({ alias, x: e.clientX, y: e.clientY });
320
+ }}
321
+ />)
160
322
  )}
161
323
  </div>
162
324
  </div>
@@ -0,0 +1,187 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { togglePin, usePinned } from '../lib/chat-pin';
6
+ import { toggleMute, useMuted } from '../lib/chat-mute';
7
+ import { markChatRead, useChatUnread } from '../lib/chat-unread';
8
+ import { useNetworkId } from '../lib/network-context';
9
+ import { t } from '../lib/ui-lang';
10
+
11
+ // SPEC §10 (dashboard-ui-ref/SPEC.md): right-click context menu for
12
+ // NodeList rail entries. Actions: 置顶 / 免打扰 / 标记已读.
13
+ //
14
+ // Positioning (SPEC §10 "贴边行为"):
15
+ // - default: right-and-down of cursor
16
+ // - if rect.bottom + menuHeight > viewport.bottom → flip up
17
+ // - if rect.right + menuWidth > viewport.right → flip left
18
+ // - at least 8px margin from every viewport edge
19
+ // - useLayoutEffect measures menu then adjusts position (menu size
20
+ // is bounded but not fixed — item labels change per state)
21
+ //
22
+ // Deep-styling: NO shadow (SPEC §2 conclusion — dark theme shadow
23
+ // renders as gray haze). Use brighter bg (#1E293B vs chat's #141826)
24
+ // + 1px border for shape.
25
+ //
26
+ // Test-hook contract:
27
+ // - Root: data-testid="node-context-menu", data-target-alias, role="menu"
28
+ // - Items: data-testid="ctx-pin"|"ctx-mute"|"ctx-mark-read", role="menuitem"
29
+ // - data-flipped-{y,x}="true" when position was flipped for tests.
30
+
31
+ export interface NodeListContextMenuProps {
32
+ alias: string;
33
+ x: number; // cursor clientX at right-click
34
+ y: number; // cursor clientY at right-click
35
+ onClose: () => void;
36
+ }
37
+
38
+ const MENU_W = 200; // SPEC §10: 200px fixed width
39
+ const EDGE = 8; // SPEC §10: ≥8px margin from viewport edge
40
+
41
+ export function NodeListContextMenu({ alias, x, y, onClose }: NodeListContextMenuProps) {
42
+ const pinned = usePinned(alias);
43
+ const muted = useMuted(alias);
44
+ const { networkId } = useNetworkId();
45
+ const { unreadCount } = useChatUnread();
46
+ const unread = unreadCount(alias);
47
+ const ref = useRef<HTMLDivElement | null>(null);
48
+ const [pos, setPos] = useState<{ top: number; left: number; flippedY: boolean; flippedX: boolean }>({
49
+ top: y,
50
+ left: x,
51
+ flippedY: false,
52
+ flippedX: false,
53
+ });
54
+
55
+ // SPEC §10 "贴边行为" — measure after paint, flip axis if would
56
+ // overflow. useLayoutEffect so users don't see a paint at the
57
+ // wrong location before the flip.
58
+ useLayoutEffect(() => {
59
+ if (typeof window === 'undefined') return;
60
+ const el = ref.current;
61
+ if (!el) return;
62
+ const rect = el.getBoundingClientRect();
63
+ const menuH = rect.height || 120; // fallback if getBoundingClientRect races
64
+ const menuW = MENU_W;
65
+ const vh = window.innerHeight;
66
+ const vw = window.innerWidth;
67
+ let top = y;
68
+ let left = x;
69
+ let flippedY = false;
70
+ let flippedX = false;
71
+ if (y + menuH + EDGE > vh) {
72
+ top = Math.max(EDGE, y - menuH);
73
+ flippedY = true;
74
+ }
75
+ if (x + menuW + EDGE > vw) {
76
+ left = Math.max(EDGE, x - menuW);
77
+ flippedX = true;
78
+ }
79
+ // Final clamp — in case the flip still overflows on tiny viewports.
80
+ top = Math.max(EDGE, Math.min(top, vh - menuH - EDGE));
81
+ left = Math.max(EDGE, Math.min(left, vw - menuW - EDGE));
82
+ setPos({ top, left, flippedY, flippedX });
83
+ }, [x, y]);
84
+
85
+ useEffect(() => {
86
+ // mousedown-capture so a right-click INSIDE the menu doesn't dismiss.
87
+ const onClick = (e: MouseEvent) => {
88
+ if (ref.current && !ref.current.contains(e.target as Node)) onClose();
89
+ };
90
+ const onKey = (e: KeyboardEvent) => {
91
+ if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
92
+ };
93
+ document.addEventListener('mousedown', onClick, true);
94
+ document.addEventListener('keydown', onKey);
95
+ return () => {
96
+ document.removeEventListener('mousedown', onClick, true);
97
+ document.removeEventListener('keydown', onKey);
98
+ };
99
+ }, [onClose]);
100
+
101
+ const runAndClose = (fn: () => void) => () => { fn(); onClose(); };
102
+
103
+ // SPEC §10 三态色值 (Tailwind can't do arbitrary custom hex on
104
+ // pseudo-classes easily; the group-hover + `data-*` selectors on the
105
+ // Menu item below drive the state colors via inline CSS variables to
106
+ // keep both bg + text + icon flipping together on hover.)
107
+ const itemCls =
108
+ 'group w-full text-left flex items-center gap-2.5 px-2.5 py-1.5 text-[13px] font-medium text-[#E2E8F0] ' +
109
+ 'hover:bg-[rgba(125,211,252,0.10)] hover:text-white active:bg-[rgba(125,211,252,0.18)] ' +
110
+ 'disabled:text-[#475569] disabled:cursor-not-allowed disabled:hover:bg-transparent';
111
+
112
+ const menu = (
113
+ <div
114
+ ref={ref}
115
+ role="menu"
116
+ aria-label={t(`节点 ${alias} 操作菜单`, `Actions for node ${alias}`)}
117
+ data-testid="node-context-menu"
118
+ data-target-alias={alias}
119
+ data-flipped-y={pos.flippedY ? 'true' : undefined}
120
+ data-flipped-x={pos.flippedX ? 'true' : undefined}
121
+ style={{ position: 'fixed', top: pos.top, left: pos.left, width: MENU_W, zIndex: 60 }}
122
+ className="rounded-lg border border-[rgba(255,255,255,0.10)] bg-[#1E293B] py-1"
123
+ onContextMenu={e => e.preventDefault()}
124
+ >
125
+ <button
126
+ type="button"
127
+ role="menuitem"
128
+ data-testid="ctx-pin"
129
+ onClick={runAndClose(() => togglePin(alias))}
130
+ className={itemCls}
131
+ >
132
+ <IconPin className="shrink-0 w-5 h-5 text-[#94A3B8] group-hover:text-[#7DD3FC]" />
133
+ {pinned ? t('取消置顶', 'Unpin') : t('置顶会话', 'Pin conversation')}
134
+ </button>
135
+ <button
136
+ type="button"
137
+ role="menuitem"
138
+ data-testid="ctx-mute"
139
+ onClick={runAndClose(() => toggleMute(alias))}
140
+ className={itemCls}
141
+ >
142
+ <IconBell muted className="shrink-0 w-5 h-5 text-[#94A3B8] group-hover:text-[#7DD3FC]" />
143
+ {muted ? t('取消免打扰', 'Unmute') : t('免打扰', 'Mute')}
144
+ </button>
145
+ <button
146
+ type="button"
147
+ role="menuitem"
148
+ data-testid="ctx-mark-read"
149
+ onClick={runAndClose(() => markChatRead(alias, networkId))}
150
+ disabled={unread === 0}
151
+ className={itemCls}
152
+ >
153
+ <IconCheck className="shrink-0 w-5 h-5 text-[#94A3B8] group-hover:text-[#7DD3FC] group-disabled:text-[#475569]" />
154
+ {t(unread > 0 ? `标记已读 (${unread})` : '标记已读', unread > 0 ? `Mark ${unread} read` : 'Mark read')}
155
+ </button>
156
+ </div>
157
+ );
158
+
159
+ if (typeof document === 'undefined') return null;
160
+ return createPortal(menu, document.body);
161
+ }
162
+
163
+ function IconPin({ className }: { className?: string }) {
164
+ return (
165
+ <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
166
+ <path d="M12 17v5" />
167
+ <path d="M5 10l7 7 7-7" />
168
+ <path d="M5 10l3-8h8l3 8z" />
169
+ </svg>
170
+ );
171
+ }
172
+ function IconBell({ className, muted }: { className?: string; muted?: boolean }) {
173
+ return (
174
+ <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
175
+ <path d="M15 17h5l-1.4-1.4A2 2 0 0118 14.2V11a6 6 0 00-12 0v3.2c0 .5-.2 1-.6 1.4L4 17h5" />
176
+ <path d="M13.7 21a2 2 0 01-3.4 0" />
177
+ {muted && <path d="M3 3l18 18" />}
178
+ </svg>
179
+ );
180
+ }
181
+ function IconCheck({ className }: { className?: string }) {
182
+ return (
183
+ <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
184
+ <path d="M5 13l4 4L19 7" />
185
+ </svg>
186
+ );
187
+ }