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

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 (237) 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 +14 -14
  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 +2 -2
  20. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  21. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  22. package/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  23. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  24. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  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 +2 -2
  30. package/.next/server/app/admin.segments/_full.segment.rsc +2 -2
  31. package/.next/server/app/admin.segments/_head.segment.rsc +1 -1
  32. package/.next/server/app/admin.segments/_index.segment.rsc +2 -2
  33. package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
  34. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +1 -1
  35. package/.next/server/app/admin.segments/admin.segment.rsc +1 -1
  36. package/.next/server/app/index.html +2 -2
  37. package/.next/server/app/index.rsc +3 -3
  38. package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  39. package/.next/server/app/index.segments/_full.segment.rsc +3 -3
  40. package/.next/server/app/index.segments/_head.segment.rsc +1 -1
  41. package/.next/server/app/index.segments/_index.segment.rsc +2 -2
  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 +3 -3
  47. package/.next/server/app/login.segments/_full.segment.rsc +3 -3
  48. package/.next/server/app/login.segments/_head.segment.rsc +1 -1
  49. package/.next/server/app/login.segments/_index.segment.rsc +2 -2
  50. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  51. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +2 -2
  52. package/.next/server/app/login.segments/login.segment.rsc +1 -1
  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 +2 -2
  57. package/.next/server/app/logs.segments/_full.segment.rsc +2 -2
  58. package/.next/server/app/logs.segments/_head.segment.rsc +1 -1
  59. package/.next/server/app/logs.segments/_index.segment.rsc +2 -2
  60. package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
  61. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +1 -1
  62. package/.next/server/app/logs.segments/logs.segment.rsc +1 -1
  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 +2 -2
  67. package/.next/server/app/messages.segments/_full.segment.rsc +2 -2
  68. package/.next/server/app/messages.segments/_head.segment.rsc +1 -1
  69. package/.next/server/app/messages.segments/_index.segment.rsc +2 -2
  70. package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
  71. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +1 -1
  72. package/.next/server/app/messages.segments/messages.segment.rsc +1 -1
  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 +2 -2
  77. package/.next/server/app/node.segments/_full.segment.rsc +2 -2
  78. package/.next/server/app/node.segments/_head.segment.rsc +1 -1
  79. package/.next/server/app/node.segments/_index.segment.rsc +2 -2
  80. package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
  81. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +1 -1
  82. package/.next/server/app/node.segments/node.segment.rsc +1 -1
  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 +3 -3
  89. package/.next/server/app/nodes.segments/_full.segment.rsc +3 -3
  90. package/.next/server/app/nodes.segments/_head.segment.rsc +1 -1
  91. package/.next/server/app/nodes.segments/_index.segment.rsc +2 -2
  92. package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
  93. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +2 -2
  94. package/.next/server/app/nodes.segments/nodes.segment.rsc +1 -1
  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 +2 -2
  101. package/.next/server/app/providers.segments/_full.segment.rsc +2 -2
  102. package/.next/server/app/providers.segments/_head.segment.rsc +1 -1
  103. package/.next/server/app/providers.segments/_index.segment.rsc +2 -2
  104. package/.next/server/app/providers.segments/_tree.segment.rsc +2 -2
  105. package/.next/server/app/providers.segments/providers/__PAGE__.segment.rsc +1 -1
  106. package/.next/server/app/providers.segments/providers.segment.rsc +1 -1
  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 +2 -2
  111. package/.next/server/app/server-logs.segments/_full.segment.rsc +2 -2
  112. package/.next/server/app/server-logs.segments/_head.segment.rsc +1 -1
  113. package/.next/server/app/server-logs.segments/_index.segment.rsc +2 -2
  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 +1 -1
  116. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +1 -1
  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 +2 -2
  121. package/.next/server/app/servers.segments/_full.segment.rsc +2 -2
  122. package/.next/server/app/servers.segments/_head.segment.rsc +1 -1
  123. package/.next/server/app/servers.segments/_index.segment.rsc +2 -2
  124. package/.next/server/app/servers.segments/_tree.segment.rsc +2 -2
  125. package/.next/server/app/servers.segments/servers/__PAGE__.segment.rsc +1 -1
  126. package/.next/server/app/servers.segments/servers.segment.rsc +1 -1
  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 +2 -2
  131. package/.next/server/app/settings/networks.segments/_full.segment.rsc +2 -2
  132. package/.next/server/app/settings/networks.segments/_head.segment.rsc +1 -1
  133. package/.next/server/app/settings/networks.segments/_index.segment.rsc +2 -2
  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 +1 -1
  136. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +1 -1
  137. package/.next/server/app/settings/networks.segments/settings.segment.rsc +1 -1
  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 +2 -2
  144. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +2 -2
  145. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +1 -1
  146. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +2 -2
  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 +1 -1
  149. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +1 -1
  150. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +1 -1
  151. package/.next/server/app/settings.html +2 -2
  152. package/.next/server/app/settings.rsc +3 -3
  153. package/.next/server/app/settings.segments/_full.segment.rsc +3 -3
  154. package/.next/server/app/settings.segments/_head.segment.rsc +1 -1
  155. package/.next/server/app/settings.segments/_index.segment.rsc +2 -2
  156. package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  157. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
  158. package/.next/server/app/settings.segments/settings.segment.rsc +1 -1
  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 +2 -2
  165. package/.next/server/app/tasks.segments/_full.segment.rsc +2 -2
  166. package/.next/server/app/tasks.segments/_head.segment.rsc +1 -1
  167. package/.next/server/app/tasks.segments/_index.segment.rsc +2 -2
  168. package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
  169. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +1 -1
  170. package/.next/server/app/tasks.segments/tasks.segment.rsc +1 -1
  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]__02o~-py._.js +3 -0
  174. package/.next/server/chunks/ssr/[root-of-the-server]__02o~-py._.js.map +1 -0
  175. package/.next/server/chunks/ssr/[root-of-the-server]__06w5i08._.js +1 -1
  176. package/.next/server/chunks/ssr/[root-of-the-server]__06w5i08._.js.map +1 -1
  177. package/.next/server/chunks/ssr/{[root-of-the-server]__00n84ca._.js → [root-of-the-server]__0x9nh~a._.js} +2 -2
  178. package/.next/server/chunks/ssr/[root-of-the-server]__0x9nh~a._.js.map +1 -0
  179. package/.next/server/chunks/ssr/dash-stageb-wt_0q55dv1._.js +0 -0
  180. package/.next/server/chunks/ssr/dash-stageb-wt_0q55dv1._.js.map +1 -0
  181. package/.next/server/chunks/ssr/dash-stageb-wt_13184u1._.js +0 -0
  182. package/.next/server/chunks/ssr/dash-stageb-wt_13184u1._.js.map +1 -0
  183. package/.next/server/chunks/ssr/dash-stageb-wt_app_0qn7hvc._.js +1 -1
  184. package/.next/server/chunks/ssr/dash-stageb-wt_app_0qn7hvc._.js.map +1 -1
  185. package/.next/server/chunks/ssr/dash-stageb-wt_app_0ss0pj-._.js +1 -1
  186. package/.next/server/chunks/ssr/dash-stageb-wt_app_0ss0pj-._.js.map +1 -1
  187. package/.next/server/chunks/ssr/dash-stageb-wt_app_nodes_NodesView_tsx_0patjj7._.js +1 -1
  188. package/.next/server/chunks/ssr/dash-stageb-wt_app_nodes_NodesView_tsx_0patjj7._.js.map +1 -1
  189. package/.next/server/chunks/ssr/{dash-stageb-wt_0hzfl6x._.js → dash-stageb-wt_app_page_tsx_0iyrtvj._.js} +3 -3
  190. package/.next/server/chunks/ssr/dash-stageb-wt_app_page_tsx_0iyrtvj._.js.map +1 -0
  191. package/.next/server/middleware-build-manifest.js +3 -3
  192. package/.next/server/pages/404.html +2 -2
  193. package/.next/server/pages/500.html +1 -1
  194. package/.next/server/server-reference-manifest.js +1 -1
  195. package/.next/server/server-reference-manifest.json +1 -1
  196. package/.next/static/chunks/05~lgfzsv.p~p.js +2 -0
  197. package/.next/static/chunks/092zqb~4un32l.css +1 -0
  198. package/.next/static/chunks/0aaakzcw7tey0.js +0 -0
  199. package/.next/static/chunks/0advoo2tzdcyi.js +1 -0
  200. package/.next/static/chunks/0dkk_l872ocgm.js +1 -0
  201. package/.next/static/chunks/0fz7zk.ek.154.js +1 -0
  202. package/.next/static/chunks/0njt8wek.9aq6.js +1 -0
  203. package/.next/static/chunks/{0hm52b0y~k0~z.js → 0uk67ty_0h_36.js} +1 -1
  204. package/.next/static/chunks/{0ep78sq90x_yc.js → 0z_4d13crd_af.js} +1 -1
  205. package/.next/static/chunks/0zljk8~2jqw93.js +4 -0
  206. package/.next/trace +2 -2
  207. package/.next/trace-build +1 -1
  208. package/app/components/ChatPane.tsx +108 -16
  209. package/app/components/NodeList.tsx +198 -46
  210. package/app/components/NodeListContextMenu.tsx +187 -0
  211. package/app/components/NodeSettingsPanel.tsx +29 -3
  212. package/app/nodes/NodesView.tsx +58 -0
  213. package/package.json +1 -1
  214. package/tests/e2e-9-nodes-team-tag-filter.spec.ts +3 -0
  215. package/tests/e2e-batch-list-actions.spec.ts +234 -0
  216. package/tests/e2e-stage-a-node-list.spec.ts +4 -0
  217. package/tests/e2e-stage-b-chat-pane.spec.ts +103 -0
  218. package/.next/server/chunks/ssr/0ld5_next_dist_00v047f._.js.map +0 -1
  219. package/.next/server/chunks/ssr/[root-of-the-server]__00n84ca._.js.map +0 -1
  220. package/.next/server/chunks/ssr/[root-of-the-server]__0idij1k._.js +0 -3
  221. package/.next/server/chunks/ssr/[root-of-the-server]__0idij1k._.js.map +0 -1
  222. package/.next/server/chunks/ssr/dash-stageb-wt_0hzfl6x._.js.map +0 -1
  223. package/.next/server/chunks/ssr/dash-stageb-wt_app_06.7qt9._.js +0 -0
  224. package/.next/server/chunks/ssr/dash-stageb-wt_app_06.7qt9._.js.map +0 -1
  225. package/.next/server/chunks/ssr/dash-stageb-wt_app_0y~m8-6._.js +0 -0
  226. package/.next/server/chunks/ssr/dash-stageb-wt_app_0y~m8-6._.js.map +0 -1
  227. package/.next/static/chunks/02.5_nifak.uu.js +0 -1
  228. package/.next/static/chunks/03hzj129k~odb.js +0 -1
  229. package/.next/static/chunks/0cey4i.y0a8~j.js +0 -1
  230. package/.next/static/chunks/0mhaf6lrw5nn..css +0 -1
  231. package/.next/static/chunks/0s293geid.j7e.js +0 -4
  232. package/.next/static/chunks/0zj_navpyhltx.js +0 -2
  233. package/.next/static/chunks/14y4x3de1lbug.js +0 -1
  234. package/.next/static/chunks/15ka68xi.w-i-.js +0 -1
  235. /package/.next/static/{vc_aJHtYwefsVcl2_f14Z → JL_QjNKv1KhsCF68ZK28x}/_buildManifest.js +0 -0
  236. /package/.next/static/{vc_aJHtYwefsVcl2_f14Z → JL_QjNKv1KhsCF68ZK28x}/_clientMiddlewareManifest.js +0 -0
  237. /package/.next/static/{vc_aJHtYwefsVcl2_f14Z → JL_QjNKv1KhsCF68ZK28x}/_ssgManifest.js +0 -0
@@ -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,111 @@ 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
+ data-node-pinned={pinned ? 'true' : undefined}
83
+ data-node-muted={muted ? 'true' : undefined}
84
+ onClick={() => onSelect(s.alias)}
85
+ onContextMenu={e => onContextMenu(s.alias, e)}
86
+ // SPEC §11.1: pinned row = whole-row light background
87
+ // rgba(125,211,252,0.04) (hover 0.08); no pin ICON (Feishu
88
+ // pattern). Selected wins over both.
89
+ className={`w-full flex items-center gap-2.5 px-3 py-2.5 text-left transition-colors border-b border-[#1a1a1d] ${
90
+ selected
91
+ ? 'bg-cyan-500/8'
92
+ : pinned
93
+ ? 'bg-[rgba(125,211,252,0.04)] hover:bg-[rgba(125,211,252,0.08)]'
94
+ : 'hover:bg-[#161618]'
95
+ }`}
96
+ >
97
+ <div className="relative shrink-0">
98
+ <AliasAvatar alias={s.alias} size={32} />
99
+ {s.online && (
100
+ <span
101
+ aria-hidden
102
+ className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-green-400 ring-2 ring-[#0F1320]"
103
+ />
104
+ )}
105
+ </div>
106
+ <div className="flex-1 min-w-0">
107
+ <div className="flex items-center justify-between gap-2">
108
+ {/* SPEC §11: alias + optional bell-slash icon at RIGHT of
109
+ alias when muted (12×12, #64748B). NO pin icon — pinned
110
+ state is signaled by row background per §11.1. */}
111
+ <span className={`text-[13px] truncate flex items-center gap-1.5 min-w-0 ${selected ? 'text-cyan-200 font-medium' : 'text-gray-200'}`}>
112
+ <span className="truncate">{s.alias}</span>
113
+ {muted && (
114
+ <svg
115
+ aria-label={t('已免打扰', 'Muted')}
116
+ data-mute-icon
117
+ width="12"
118
+ height="12"
119
+ viewBox="0 0 24 24"
120
+ fill="none"
121
+ stroke="#64748B"
122
+ strokeWidth={1.5}
123
+ strokeLinecap="round"
124
+ strokeLinejoin="round"
125
+ className="shrink-0"
126
+ >
127
+ <path d="M15 17h5l-1.4-1.4A2 2 0 0118 14.2V11a6 6 0 00-4-5.7" />
128
+ <path d="M6 8v3.2c0 .5-.2 1-.6 1.4L4 14h9" />
129
+ <path d="M13.7 21a2 2 0 01-3.4 0" />
130
+ <path d="M3 3l18 18" />
131
+ </svg>
132
+ )}
133
+ </span>
134
+ {/* SPEC §11.3: unread badge color depends on mute:
135
+ - not-muted → red (#EF4444 / white)
136
+ - muted → gray (#475569 / #CBD5E1) — count still shown,
137
+ per "static + silent" (mute ≠ mark-read).
138
+ Kept as one <span> with data-unread-badge on both
139
+ variants so tests can find it regardless of state. */}
140
+ {unread > 0 && (
141
+ <span
142
+ aria-label={muted
143
+ ? t(`已免打扰,未读 ${unread}`, `Muted, ${unread} unread`)
144
+ : t(`${unread} 条未读`, `${unread} unread`)
145
+ }
146
+ data-unread-badge
147
+ data-badge-variant={muted ? 'muted' : 'default'}
148
+ className={`shrink-0 min-w-[16px] h-4 px-1 rounded-full text-[9px] font-semibold leading-4 text-center ${
149
+ muted
150
+ ? 'bg-[#475569] text-[#CBD5E1]'
151
+ : 'bg-[#EF4444] text-white'
152
+ }`}
153
+ >
154
+ {unread > 99 ? '99+' : unread}
155
+ </span>
156
+ )}
157
+ </div>
158
+ <div className="mt-0.5 flex items-center gap-1.5">
159
+ <LastLine alias={s.alias} session={s} />
160
+ </div>
161
+ </div>
162
+ </button>
163
+ );
164
+ }
165
+
51
166
  function LastLine({ alias, session }: { alias: string; session: SessionRow }) {
52
167
  const draft = useHasDraft(alias);
53
168
  if (draft) {
@@ -68,6 +183,69 @@ function LastLine({ alias, session }: { alias: string; session: SessionRow }) {
68
183
 
69
184
  export function NodeList({ sessions, selectedAlias, onSelect, search, onSearchChange }: NodeListProps) {
70
185
  const { unreadCount } = useChatUnread();
186
+ // Batch (07-31) segment 2: right-click context menu state. `null` when
187
+ // closed; `{alias, x, y}` when open. Positioning is at the mouse
188
+ // coords from the contextmenu event so the menu appears where the
189
+ // user right-clicked, not at a static offset.
190
+ const [menu, setMenu] = useState<{ alias: string; x: number; y: number } | null>(null);
191
+
192
+ // Batch (07-31) segment 3: keyboard ↑↓ walks the list, Enter opens
193
+ // the focused entry (URL changes via parent's onSelect → router.push).
194
+ //
195
+ // Focus discipline: window-level keydown, guarded to skip when the
196
+ // user is typing in a text control — the "chat input" case Vincent
197
+ // explicitly named, plus any generic input/textarea/contenteditable
198
+ // so we don't hijack keystrokes anywhere in the app.
199
+ //
200
+ // Ref pattern: parent's `onSelect` and `sessions` change reference
201
+ // every render (arrow fn / filtered array). If we used them in the
202
+ // deps array, the effect would re-attach the listener on every
203
+ // render — brief windows with no listener could miss keydowns.
204
+ // Instead: attach ONCE (empty deps), read latest values via ref.
205
+ const scrollRef = useRef<HTMLDivElement | null>(null);
206
+ const stateRef = useRef({ sessions, selectedAlias, onSelect });
207
+ stateRef.current = { sessions, selectedAlias, onSelect };
208
+ useEffect(() => {
209
+ if (typeof window === 'undefined') return;
210
+ const onKey = (e: KeyboardEvent) => {
211
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown' && e.key !== 'Enter') return;
212
+ const active = document.activeElement as HTMLElement | null;
213
+ if (active) {
214
+ const tag = active.tagName;
215
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
216
+ if (active.isContentEditable) return;
217
+ }
218
+ const { sessions: s, selectedAlias: sel, onSelect: pick } = stateRef.current;
219
+ if (s.length === 0) return;
220
+ const currentIdx = sel ? s.findIndex(x => x.alias === sel) : -1;
221
+ if (e.key === 'Enter') {
222
+ if (currentIdx === -1) {
223
+ e.preventDefault();
224
+ pick(s[0].alias);
225
+ }
226
+ return;
227
+ }
228
+ e.preventDefault();
229
+ const nextIdx = currentIdx === -1
230
+ ? (e.key === 'ArrowDown' ? 0 : s.length - 1)
231
+ : (e.key === 'ArrowDown'
232
+ ? Math.min(s.length - 1, currentIdx + 1)
233
+ : Math.max(0, currentIdx - 1));
234
+ if (nextIdx !== currentIdx) pick(s[nextIdx].alias);
235
+ };
236
+ window.addEventListener('keydown', onKey);
237
+ return () => window.removeEventListener('keydown', onKey);
238
+ }, []);
239
+
240
+ // Auto-scroll: after selection change, bring the selected entry into
241
+ // the rail's viewport. Runs on every render — cheap and safe. Uses
242
+ // scrollIntoView block:'nearest' so it doesn't jerk the list when
243
+ // the selected entry is already visible.
244
+ useEffect(() => {
245
+ if (!selectedAlias) return;
246
+ const el = scrollRef.current?.querySelector(`[data-node-list-alias="${CSS.escape(selectedAlias)}"]`) as HTMLElement | null;
247
+ if (el) el.scrollIntoView({ block: 'nearest' });
248
+ }, [selectedAlias]);
71
249
 
72
250
  return (
73
251
  <div
@@ -90,7 +268,15 @@ export function NodeList({ sessions, selectedAlias, onSelect, search, onSearchCh
90
268
  <div className="px-3 py-2 text-[11px] text-gray-500 tracking-wide uppercase border-b border-[#1e1e22] shrink-0">
91
269
  {t(`节点 ${sessions.length}`, `Nodes · ${sessions.length}`)}
92
270
  </div>
93
- <div data-testid="node-list-scroll" className="flex-1 min-h-0 overflow-y-auto">
271
+ {menu && (
272
+ <NodeListContextMenu
273
+ alias={menu.alias}
274
+ x={menu.x}
275
+ y={menu.y}
276
+ onClose={() => setMenu(null)}
277
+ />
278
+ )}
279
+ <div ref={scrollRef} data-testid="node-list-scroll" className="flex-1 min-h-0 overflow-y-auto">
94
280
  {sessions.length === 0 ? (
95
281
  search ? (
96
282
  /* #Stage D (Vincent ③ 判据): a no-match search MUST look
@@ -112,51 +298,17 @@ export function NodeList({ sessions, selectedAlias, onSelect, search, onSearchCh
112
298
  </div>
113
299
  )
114
300
  ) : (
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
- })
301
+ sessions.map(s => <NodeListRow
302
+ key={s.alias}
303
+ session={s}
304
+ selected={s.alias === selectedAlias}
305
+ unread={unreadCount(s.alias)}
306
+ onSelect={onSelect}
307
+ onContextMenu={(alias, e) => {
308
+ e.preventDefault();
309
+ setMenu({ alias, x: e.clientX, y: e.clientY });
310
+ }}
311
+ />)
160
312
  )}
161
313
  </div>
162
314
  </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
+ }
@@ -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]">
@@ -122,6 +122,7 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
122
122
  try { window.history.pushState({}, '', `/nodes/${encodeURIComponent(alias)}`); } catch {}
123
123
  };
124
124
  const closeChat = () => {
125
+ userClosedRef.current = true; // ③ explicit close wins over auto-select
125
126
  setChatAlias(null);
126
127
  try { window.history.pushState({}, '', '/nodes'); } catch {}
127
128
  };
@@ -157,6 +158,42 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
157
158
  ro.observe(document.body);
158
159
  return () => { window.removeEventListener('resize', apply); ro.disconnect(); };
159
160
  }, []);
161
+ // #Vincent 插单 (07-31): entering /nodes with nothing selected showed the
162
+ // management table full-bleed — "这样特别难看". Feishu opens INTO a
163
+ // conversation, so desktop /nodes auto-selects the first (sorted) session.
164
+ // Three traps, each deliberately handled:
165
+ // ① URL via history.REPLACE, never push — push would trap the browser
166
+ // back button (/nodes → auto-select → /nodes/x → back → /nodes →
167
+ // auto-select again, forever).
168
+ // ② the table is NOT gone — it's the "manage all nodes" view, reachable
169
+ // via the explicit 管理视图 toggle below (localStorage-persisted; a
170
+ // user who prefers the table keeps it as their default and auto-select
171
+ // stays off).
172
+ // ③ an explicit ✕ close must NOT be answered by an immediate re-select
173
+ // (the ✕ would look broken) — userClosedRef gates auto-select off for
174
+ // the rest of this mount.
175
+ const [manageView, setManageView] = useState<boolean>(() => {
176
+ if (typeof window === 'undefined') return false;
177
+ return window.localStorage.getItem('anet-nodes-manage-view') === '1';
178
+ });
179
+ const autoSelectedRef = useRef(false);
180
+ const userClosedRef = useRef(false);
181
+ const toggleManageView = () => {
182
+ setManageView(v => {
183
+ const next = !v;
184
+ try { window.localStorage.setItem('anet-nodes-manage-view', next ? '1' : '0'); } catch {}
185
+ if (next) {
186
+ // entering manage view: drop any open chat, land on the table
187
+ userClosedRef.current = true;
188
+ if (chatAlias) closeChat();
189
+ } else {
190
+ // back to conversation view: let auto-select pick up again
191
+ userClosedRef.current = false;
192
+ autoSelectedRef.current = false;
193
+ }
194
+ return next;
195
+ });
196
+ };
160
197
  const [showCreate, setShowCreate] = useState(false);
161
198
  // #Stage A (dashboard 3-col, 07-31): persistent narrow node list rail
162
199
  // to the left of the existing table. Default on; user can hide to
@@ -230,6 +267,15 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
230
267
  // "rail count == table count" true at all times.
231
268
  const railSessions = filtered;
232
269
 
270
+ const firstAlias = filtered.length > 0 ? filtered[0].alias : null;
271
+ useEffect(() => {
272
+ if (!isDesktop || manageView || chatAlias || userClosedRef.current || autoSelectedRef.current) return;
273
+ if (!firstAlias) return; // empty list: nothing to select, no loop
274
+ autoSelectedRef.current = true;
275
+ setChatAlias(firstAlias);
276
+ try { window.history.replaceState({}, '', `/nodes/${encodeURIComponent(firstAlias)}`); } catch {}
277
+ }, [isDesktop, manageView, chatAlias, firstAlias]);
278
+
233
279
  // #Stage C: with a node selected on desktop, the page is LIST + CHAT —
234
280
  // the middle table does not render (通信龙 production screenshot catch:
235
281
  // rail and table were painting the same 199 nodes twice, squeezing the
@@ -276,6 +322,18 @@ export function NodesView({ initialAlias = null }: { initialAlias?: string | nul
276
322
  search={search}
277
323
  onSearchChange={setSearch}
278
324
  />
325
+ {/* #Vincent 插单 ②: explicit doorway between conversation-first
326
+ and table-first ("manage all nodes") modes. Lives in the
327
+ aside (NodesView's JSX) — NOT in NodeList, which IM马 is
328
+ editing concurrently. */}
329
+ <button
330
+ type="button"
331
+ onClick={toggleManageView}
332
+ data-testid="manage-view-toggle"
333
+ 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"
334
+ >
335
+ {manageView ? t('≡ 对话视图', '≡ Conversation view') : t('☷ 管理视图', '☷ Manage view')}
336
+ </button>
279
337
  </aside>
280
338
  )}
281
339
  {/* #Stage C: middle column (header + filters + table/grid) renders
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.51",
4
4
  "description": "Agent Network Dashboard — Web UI for managing AI Agent networks",
5
5
  "main": "apps/desktop/electron/main.cjs",
6
6
  "scripts": {
@@ -97,6 +97,9 @@ async function login(context: Page['context'] extends () => infer C ? C : never)
97
97
  }
98
98
 
99
99
  async function mockCommon(page: Page, statusBody: object, nodesBody: object) {
100
+ // #Vincent auto-select (07-31): filter suite exercises the manage/table
101
+ // view — opt in explicitly now that /nodes defaults to conversation-first.
102
+ await page.addInitScript(() => window.localStorage.setItem('anet-nodes-manage-view', '1'));
100
103
  await page.route('**/api/hub/status**', r =>
101
104
  r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(statusBody) }));
102
105
  await page.route('**/api/hub/nodes**', r =>