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

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 (258) 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 +97 -97
  5. package/.next/fallback-build-manifest.json +3 -3
  6. package/.next/prerender-manifest.json +3 -3
  7. package/.next/server/app/_global-error.html +1 -1
  8. package/.next/server/app/_global-error.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  15. package/.next/server/app/_not-found.html +5 -5
  16. package/.next/server/app/_not-found.rsc +13 -13
  17. package/.next/server/app/_not-found.segments/_full.segment.rsc +13 -13
  18. package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  19. package/.next/server/app/_not-found.segments/_index.segment.rsc +8 -8
  20. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  21. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  22. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
  24. package/.next/server/app/admin.html +5 -5
  25. package/.next/server/app/admin.rsc +15 -15
  26. package/.next/server/app/admin.segments/_full.segment.rsc +15 -15
  27. package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
  28. package/.next/server/app/admin.segments/_index.segment.rsc +8 -8
  29. package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
  30. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +4 -4
  31. package/.next/server/app/admin.segments/admin.segment.rsc +3 -3
  32. package/.next/server/app/index.html +5 -5
  33. package/.next/server/app/index.rsc +15 -15
  34. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  35. package/.next/server/app/index.segments/_full.segment.rsc +15 -15
  36. package/.next/server/app/index.segments/_head.segment.rsc +4 -4
  37. package/.next/server/app/index.segments/_index.segment.rsc +8 -8
  38. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  39. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  40. package/.next/server/app/login.html +2 -2
  41. package/.next/server/app/login.rsc +15 -15
  42. package/.next/server/app/login.segments/_full.segment.rsc +15 -15
  43. package/.next/server/app/login.segments/_head.segment.rsc +4 -4
  44. package/.next/server/app/login.segments/_index.segment.rsc +8 -8
  45. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  46. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
  47. package/.next/server/app/login.segments/login.segment.rsc +3 -3
  48. package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
  49. package/.next/server/app/logs.html +5 -5
  50. package/.next/server/app/logs.rsc +15 -15
  51. package/.next/server/app/logs.segments/_full.segment.rsc +15 -15
  52. package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
  53. package/.next/server/app/logs.segments/_index.segment.rsc +8 -8
  54. package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
  55. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
  56. package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
  57. package/.next/server/app/messages/page.js.nft.json +1 -1
  58. package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
  59. package/.next/server/app/messages.html +5 -5
  60. package/.next/server/app/messages.rsc +15 -15
  61. package/.next/server/app/messages.segments/_full.segment.rsc +15 -15
  62. package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
  63. package/.next/server/app/messages.segments/_index.segment.rsc +8 -8
  64. package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
  65. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
  66. package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
  67. package/.next/server/app/node/page.js.nft.json +1 -1
  68. package/.next/server/app/node/page_client-reference-manifest.js +1 -1
  69. package/.next/server/app/node.html +5 -5
  70. package/.next/server/app/node.rsc +15 -15
  71. package/.next/server/app/node.segments/_full.segment.rsc +15 -15
  72. package/.next/server/app/node.segments/_head.segment.rsc +4 -4
  73. package/.next/server/app/node.segments/_index.segment.rsc +8 -8
  74. package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
  75. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +4 -4
  76. package/.next/server/app/node.segments/node.segment.rsc +3 -3
  77. package/.next/server/app/nodes/[alias]/page.js.nft.json +1 -1
  78. package/.next/server/app/nodes/[alias]/page_client-reference-manifest.js +1 -1
  79. package/.next/server/app/nodes/page.js.nft.json +1 -1
  80. package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
  81. package/.next/server/app/nodes.html +5 -5
  82. package/.next/server/app/nodes.rsc +15 -15
  83. package/.next/server/app/nodes.segments/_full.segment.rsc +15 -15
  84. package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
  85. package/.next/server/app/nodes.segments/_index.segment.rsc +8 -8
  86. package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
  87. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
  88. package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
  89. package/.next/server/app/page.js.nft.json +1 -1
  90. package/.next/server/app/page_client-reference-manifest.js +1 -1
  91. package/.next/server/app/providers/page_client-reference-manifest.js +1 -1
  92. package/.next/server/app/providers.html +5 -5
  93. package/.next/server/app/providers.rsc +15 -15
  94. package/.next/server/app/providers.segments/_full.segment.rsc +15 -15
  95. package/.next/server/app/providers.segments/_head.segment.rsc +4 -4
  96. package/.next/server/app/providers.segments/_index.segment.rsc +8 -8
  97. package/.next/server/app/providers.segments/_tree.segment.rsc +2 -2
  98. package/.next/server/app/providers.segments/providers/__PAGE__.segment.rsc +4 -4
  99. package/.next/server/app/providers.segments/providers.segment.rsc +3 -3
  100. package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
  101. package/.next/server/app/server-logs.html +5 -5
  102. package/.next/server/app/server-logs.rsc +15 -15
  103. package/.next/server/app/server-logs.segments/_full.segment.rsc +15 -15
  104. package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
  105. package/.next/server/app/server-logs.segments/_index.segment.rsc +8 -8
  106. package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
  107. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +4 -4
  108. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
  109. package/.next/server/app/servers/page_client-reference-manifest.js +1 -1
  110. package/.next/server/app/servers.html +5 -5
  111. package/.next/server/app/servers.rsc +15 -15
  112. package/.next/server/app/servers.segments/_full.segment.rsc +15 -15
  113. package/.next/server/app/servers.segments/_head.segment.rsc +4 -4
  114. package/.next/server/app/servers.segments/_index.segment.rsc +8 -8
  115. package/.next/server/app/servers.segments/_tree.segment.rsc +2 -2
  116. package/.next/server/app/servers.segments/servers/__PAGE__.segment.rsc +4 -4
  117. package/.next/server/app/servers.segments/servers.segment.rsc +3 -3
  118. package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
  119. package/.next/server/app/settings/networks.html +5 -5
  120. package/.next/server/app/settings/networks.rsc +15 -15
  121. package/.next/server/app/settings/networks.segments/_full.segment.rsc +15 -15
  122. package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
  123. package/.next/server/app/settings/networks.segments/_index.segment.rsc +8 -8
  124. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
  125. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +4 -4
  126. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
  127. package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
  128. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  129. package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
  130. package/.next/server/app/settings/tokens.html +5 -5
  131. package/.next/server/app/settings/tokens.rsc +15 -15
  132. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +15 -15
  133. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
  134. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +8 -8
  135. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
  136. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +4 -4
  137. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
  138. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
  139. package/.next/server/app/settings.html +5 -5
  140. package/.next/server/app/settings.rsc +15 -15
  141. package/.next/server/app/settings.segments/_full.segment.rsc +15 -15
  142. package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
  143. package/.next/server/app/settings.segments/_index.segment.rsc +8 -8
  144. package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  145. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
  146. package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
  147. package/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
  148. package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  149. package/.next/server/app/tasks/page.js.nft.json +1 -1
  150. package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
  151. package/.next/server/app/tasks.html +5 -5
  152. package/.next/server/app/tasks.rsc +15 -15
  153. package/.next/server/app/tasks.segments/_full.segment.rsc +15 -15
  154. package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
  155. package/.next/server/app/tasks.segments/_index.segment.rsc +8 -8
  156. package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
  157. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
  158. package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
  159. package/.next/server/chunks/ssr/[root-of-the-server]__05z_qq6._.js +3 -0
  160. package/.next/server/chunks/ssr/[root-of-the-server]__05z_qq6._.js.map +1 -0
  161. package/.next/server/chunks/ssr/[root-of-the-server]__06w5i08._.js +3 -0
  162. package/.next/server/chunks/ssr/[root-of-the-server]__06w5i08._.js.map +1 -0
  163. package/.next/server/chunks/ssr/[root-of-the-server]__0jgqpfv._.js +3 -0
  164. package/.next/server/chunks/ssr/[root-of-the-server]__0jgqpfv._.js.map +1 -0
  165. package/.next/server/chunks/ssr/[root-of-the-server]__0w7k~3f._.js +1 -1
  166. package/.next/server/chunks/ssr/[root-of-the-server]__0w7k~3f._.js.map +1 -1
  167. package/.next/server/chunks/ssr/[root-of-the-server]__11wln8.._.js +3 -0
  168. package/.next/server/chunks/ssr/[root-of-the-server]__11wln8.._.js.map +1 -0
  169. package/.next/server/chunks/ssr/{dash-stageb-wt_app_0e7vp44._.js → dash-stageb-wt_app_045dcq5._.js} +2 -2
  170. package/.next/server/chunks/ssr/dash-stageb-wt_app_045dcq5._.js.map +1 -0
  171. package/.next/server/chunks/ssr/{dash-stageb-wt_app_0g~a.7i._.js → dash-stageb-wt_app_05u4_h~._.js} +8 -2
  172. package/.next/server/chunks/ssr/dash-stageb-wt_app_05u4_h~._.js.map +1 -0
  173. package/.next/server/chunks/ssr/{dash-stageb-wt_app_0qpn85e._.js → dash-stageb-wt_app_0nuizh~._.js} +2 -2
  174. package/.next/server/chunks/ssr/dash-stageb-wt_app_0nuizh~._.js.map +1 -0
  175. package/.next/server/chunks/ssr/dash-stageb-wt_app_0qn7hvc._.js +1 -1
  176. package/.next/server/chunks/ssr/dash-stageb-wt_app_0qn7hvc._.js.map +1 -1
  177. package/.next/server/chunks/ssr/dash-stageb-wt_app_0ss0pj-._.js +1 -1
  178. package/.next/server/chunks/ssr/dash-stageb-wt_app_0ss0pj-._.js.map +1 -1
  179. package/.next/server/chunks/ssr/dash-stageb-wt_app_components_0vzsmx1._.js +5 -5
  180. package/.next/server/chunks/ssr/dash-stageb-wt_app_components_0vzsmx1._.js.map +1 -1
  181. package/.next/server/chunks/ssr/dash-stageb-wt_app_nodes_NodesView_tsx_0patjj7._.js +1 -1
  182. package/.next/server/chunks/ssr/dash-stageb-wt_app_nodes_NodesView_tsx_0patjj7._.js.map +1 -1
  183. package/.next/server/chunks/ssr/dash-stageb-wt_app_tasks_TasksView_tsx_0pnz7dd._.js +3 -0
  184. package/.next/server/chunks/ssr/dash-stageb-wt_app_tasks_TasksView_tsx_0pnz7dd._.js.map +1 -0
  185. package/.next/server/middleware-build-manifest.js +3 -3
  186. package/.next/server/pages/404.html +5 -5
  187. package/.next/server/pages/500.html +1 -1
  188. package/.next/server/server-reference-manifest.js +1 -1
  189. package/.next/server/server-reference-manifest.json +1 -1
  190. package/.next/static/chunks/02.5_nifak.uu.js +1 -0
  191. package/.next/static/chunks/03hzj129k~odb.js +1 -0
  192. package/.next/static/chunks/06q7-vhy~1-y3.js +7 -0
  193. package/.next/static/chunks/0_4ljrbp.stxy.js +1 -0
  194. package/.next/static/chunks/0cey4i.y0a8~j.js +1 -0
  195. package/.next/static/chunks/{0i8qbq4v47v3r.js → 0ep78sq90x_yc.js} +1 -1
  196. package/.next/static/chunks/{0_xyyy_x0k42j.js → 0hm52b0y~k0~z.js} +1 -1
  197. package/.next/static/chunks/0kncmrm2a_q43.js +1 -0
  198. package/.next/static/chunks/0mhaf6lrw5nn..css +1 -0
  199. package/.next/static/chunks/{12jbb28xy2hw..js → 0qf8xjihw3zi..js} +1 -1
  200. package/.next/static/chunks/0xyu92zdvvghl.js +7 -0
  201. package/.next/static/chunks/0zj_navpyhltx.js +2 -0
  202. package/.next/static/chunks/10px7ehmo7eq0.js +1 -0
  203. package/.next/static/chunks/{0tv-.l-_mw4ye.js → 11zz15q37~bz5.js} +1 -1
  204. package/.next/static/chunks/14y4x3de1lbug.js +1 -0
  205. package/.next/static/chunks/15ka68xi.w-i-.js +1 -0
  206. package/.next/static/chunks/17qkc714xy04..js +1 -0
  207. package/.next/trace +2 -2
  208. package/.next/trace-build +1 -1
  209. package/app/components/ChatPane.tsx +2 -2
  210. package/app/components/NodeList.tsx +40 -4
  211. package/app/components/Sidebar.tsx +180 -29
  212. package/app/components/TaskChatPanel.tsx +1 -1
  213. package/app/components/TaskDetail.tsx +407 -0
  214. package/app/components/TaskList.tsx +117 -0
  215. package/app/nodes/NodesView.tsx +28 -3
  216. package/app/tasks/TasksView.tsx +353 -0
  217. package/app/tasks/[id]/page.tsx +23 -176
  218. package/app/tasks/page.tsx +10 -453
  219. package/package.json +1 -1
  220. package/tests/e2e-sidebar-default.spec.ts +141 -0
  221. package/tests/e2e-sidebar-icon-rail.spec.ts +261 -0
  222. package/tests/e2e-stage-b-chat-pane.spec.ts +286 -0
  223. package/tests/e2e-stage-tasks-two-column.spec.ts +353 -0
  224. package/.next/server/chunks/ssr/[root-of-the-server]__0b-b6yh._.js +0 -3
  225. package/.next/server/chunks/ssr/[root-of-the-server]__0b-b6yh._.js.map +0 -1
  226. package/.next/server/chunks/ssr/[root-of-the-server]__0fs-j~c._.js +0 -3
  227. package/.next/server/chunks/ssr/[root-of-the-server]__0fs-j~c._.js.map +0 -1
  228. package/.next/server/chunks/ssr/[root-of-the-server]__0ma5-5u._.js +0 -3
  229. package/.next/server/chunks/ssr/[root-of-the-server]__0ma5-5u._.js.map +0 -1
  230. package/.next/server/chunks/ssr/[root-of-the-server]__0~hwpo-._.js +0 -3
  231. package/.next/server/chunks/ssr/[root-of-the-server]__0~hwpo-._.js.map +0 -1
  232. package/.next/server/chunks/ssr/dash-stageb-wt_app_095q0~q._.js +0 -3
  233. package/.next/server/chunks/ssr/dash-stageb-wt_app_095q0~q._.js.map +0 -1
  234. package/.next/server/chunks/ssr/dash-stageb-wt_app_0e7vp44._.js.map +0 -1
  235. package/.next/server/chunks/ssr/dash-stageb-wt_app_0g~a.7i._.js.map +0 -1
  236. package/.next/server/chunks/ssr/dash-stageb-wt_app_0qpn85e._.js.map +0 -1
  237. package/.next/server/chunks/ssr/dash-stageb-wt_app_0tppjr_._.js +0 -9
  238. package/.next/server/chunks/ssr/dash-stageb-wt_app_0tppjr_._.js.map +0 -1
  239. package/.next/server/chunks/ssr/dash-stageb-wt_app_0y764mt._.js +0 -3
  240. package/.next/server/chunks/ssr/dash-stageb-wt_app_0y764mt._.js.map +0 -1
  241. package/.next/server/chunks/ssr/dash-stageb-wt_app_tasks_page_tsx_0.fhtup._.js +0 -3
  242. package/.next/server/chunks/ssr/dash-stageb-wt_app_tasks_page_tsx_0.fhtup._.js.map +0 -1
  243. package/.next/static/chunks/027yjbjm9afuk.js +0 -1
  244. package/.next/static/chunks/07wf61~wof.l~.js +0 -1
  245. package/.next/static/chunks/0ev252k-tf_aa.css +0 -1
  246. package/.next/static/chunks/0j-jjs3w89dei.js +0 -1
  247. package/.next/static/chunks/0ko39v-gezo7d.js +0 -1
  248. package/.next/static/chunks/0l8vf4q2t5x4d.js +0 -7
  249. package/.next/static/chunks/0xcazs6t8~l4q.js +0 -2
  250. package/.next/static/chunks/10sr651~7vm42.js +0 -7
  251. package/.next/static/chunks/13_i5b0qmko7z.js +0 -1
  252. package/.next/static/chunks/140suz13o9va6.js +0 -1
  253. package/.next/static/chunks/144cf7ds3_9~b.js +0 -1
  254. package/.next/static/chunks/17c4wlsbmmgk9.js +0 -1
  255. package/.next/static/chunks/17cfly-04z1j0.js +0 -1
  256. /package/.next/static/{HIKVEkFShWE481BxQDjwH → vc_aJHtYwefsVcl2_f14Z}/_buildManifest.js +0 -0
  257. /package/.next/static/{HIKVEkFShWE481BxQDjwH → vc_aJHtYwefsVcl2_f14Z}/_clientMiddlewareManifest.js +0 -0
  258. /package/.next/static/{HIKVEkFShWE481BxQDjwH → vc_aJHtYwefsVcl2_f14Z}/_ssgManifest.js +0 -0
@@ -8,6 +8,35 @@ import useSWR from 'swr';
8
8
  import { useNetworkId } from '../lib/network-context';
9
9
  import { isOnline as presenceIsOnline, presenceStatus } from '../lib/presence';
10
10
  import { useHealth } from '../lib/hooks';
11
+ import { useChatUnread, badgeLabel } from '../lib/chat-unread';
12
+
13
+ // Sidebar has THREE render modes (通信龙 07-31 brief, dashboard 类飞书):
14
+ // 'expanded' — w-52, icons + labels. Original default; unchanged.
15
+ // 'collapsed' — w-16, icons only, native `title=` tooltips. Existing
16
+ // pre-icon-rail form; kept intact for users on it.
17
+ // 'icon-rail' — w-14, Feishu-style narrow icon-only rail with
18
+ // UNREAD BADGES on nav items that can carry them, and
19
+ // tighter spacing. Distinguishes from 'collapsed' by
20
+ // the badges + width — collapsed has neither.
21
+ //
22
+ // Cycle order (single toggle button, bottom-left arrow): expanded →
23
+ // collapsed → icon-rail → expanded. localStorage key `anet-sidebar-mode`
24
+ // persists the choice across reloads. Migration: pre-existing users
25
+ // have no key set → default to 'expanded' (backward compat: nobody who
26
+ // hadn't touched it gets a surprise on next visit).
27
+ //
28
+ // The `collapsed` bool that existing markup consumes is derived
29
+ // (`mode !== 'expanded'`) — so all pre-icon-rail styling paths keep
30
+ // working exactly as before, with icon-rail adding only its extras
31
+ // (narrower width class + badges + a data attribute for tests).
32
+ type SidebarMode = 'expanded' | 'collapsed' | 'icon-rail';
33
+ const SIDEBAR_MODES: readonly SidebarMode[] = ['expanded', 'collapsed', 'icon-rail'] as const;
34
+ const SIDEBAR_MODE_STORAGE = 'anet-sidebar-mode';
35
+ const SIDEBAR_MODE_LABEL: Record<SidebarMode, string> = {
36
+ expanded: '展开',
37
+ collapsed: '折叠',
38
+ 'icon-rail': '图标',
39
+ };
11
40
 
12
41
  const networkFetcher = (url: string) => fetch(url).then(r => r.ok ? r.json() : { networks: [] });
13
42
 
@@ -38,7 +67,45 @@ const ROLE_ICON: Record<string, string> = { owner: '⭐', admin: '🔧', member:
38
67
 
39
68
  export function Sidebar() {
40
69
  const pathname = usePathname();
41
- const [collapsed, setCollapsed] = useState(false);
70
+ // Default = 'icon-rail' per Vincent 07-31 "飞书同版体验": nobody clicks
71
+ // a toggle to make their sidebar narrow — the narrow form IS the
72
+ // experience. Existing user preferences (whoever has already picked
73
+ // expanded or collapsed via the cycle button) are preserved via the
74
+ // useEffect below, which is the ONLY thing standing between "sane
75
+ // new-user default" and "silently overwriting an existing choice" —
76
+ // the second failure is the one that reads to users as "the app
77
+ // forgot my setting" and it has no error surface, so it needs its
78
+ // own witnessed-red assertion (see e2e-sidebar-default.spec.ts).
79
+ const [mode, setMode] = useState<SidebarMode>('icon-rail');
80
+ useEffect(() => {
81
+ try {
82
+ const v = localStorage.getItem(SIDEBAR_MODE_STORAGE);
83
+ if (v && (SIDEBAR_MODES as readonly string[]).includes(v)) setMode(v as SidebarMode);
84
+ } catch {}
85
+ }, []);
86
+ const cycleMode = () => {
87
+ setMode(current => {
88
+ const idx = SIDEBAR_MODES.indexOf(current);
89
+ const next = SIDEBAR_MODES[(idx + 1) % SIDEBAR_MODES.length];
90
+ try { localStorage.setItem(SIDEBAR_MODE_STORAGE, next); } catch {}
91
+ return next;
92
+ });
93
+ };
94
+ // Derived: existing markup uses `collapsed` (any non-expanded state
95
+ // hides text + narrows). icon-rail behaves like collapsed by default
96
+ // but adds badges + narrower width class further down.
97
+ const collapsed = mode !== 'expanded';
98
+ const iconRail = mode === 'icon-rail';
99
+ // Only pulled in icon-rail mode so the SWR fetch cost (which happens
100
+ // anyway on other pages via useChatUnread) isn't duplicated for
101
+ // no visual gain when badges aren't drawn.
102
+ const { totalUnread } = useChatUnread();
103
+ // Which nav entries carry a badge in icon-rail mode. Kept as an
104
+ // exact map (not a fuzzy lookup) so a future nav-item rename can't
105
+ // silently orphan a badge onto the wrong route.
106
+ const NAV_BADGE: Partial<Record<string, number>> = {
107
+ '/nodes': totalUnread,
108
+ };
42
109
  // Theme switcher (Vincent 07-20 "主题还不能切换吗" — the Settings entry was
43
110
  // too buried; a sidebar control is findable from every page). Cycle order
44
111
  // matches Settings: 深色(默认) → 浅色 → Slack.
@@ -86,26 +153,95 @@ export function Sidebar() {
86
153
  };
87
154
 
88
155
  const nav = (
89
- <nav className="flex flex-col gap-1 px-2 py-4">
90
- {NAV_ITEMS.map(item => (
91
- <Link
92
- key={item.href}
93
- href={item.href}
94
- prefetch={false}
95
- onClick={() => setMobileOpen(false)}
96
- title={collapsed ? item.label : undefined}
97
- className={`relative flex items-center gap-3 px-3 py-3 rounded-lg text-sm transition-colors active:bg-[#232327] ${
98
- isActive(item.href)
99
- ? 'bg-cyan-500/10 text-cyan-300 border border-cyan-500/20 anet-nav-active'
100
- : 'text-gray-400 hover:text-gray-200 hover:bg-[#1c1c1f]'
101
- } ${collapsed ? 'justify-center px-0' : ''}`}
102
- >
103
- <svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
104
- <path strokeLinecap="round" strokeLinejoin="round" d={item.icon} />
105
- </svg>
106
- {!collapsed && <span>{item.label}</span>}
107
- </Link>
108
- ))}
156
+ <nav
157
+ className={`flex flex-col ${iconRail ? 'gap-2 px-0 py-2 items-center' : 'gap-1 px-2 py-4'}`}
158
+ data-sidebar-nav
159
+ >
160
+ {NAV_ITEMS.map(item => {
161
+ const badge = iconRail ? (NAV_BADGE[item.href] ?? 0) : 0;
162
+ const active = isActive(item.href);
163
+ if (iconRail) {
164
+ // SPEC v1 (设计负责人, 07-31, /home/vansin/dashboard-ui-ref/SPEC.md §3):
165
+ // - Container: 44×44 tap area (icon 24 + 10px padding all sides)
166
+ // - Icon SVG: 24×24, centered
167
+ // - Default icon color: #64748B; hover: #E2E8F0 + bg rgba(255,255,255,0.04)
168
+ // - Selected: icon #7DD3FC + bg rgba(125,211,252,0.12) rounded 8px 40×40 + 3px left bar length 20px
169
+ // - Gap between items: 8px (nav's gap-2)
170
+ // - No border between rail and next column (contrast via bg brightness)
171
+ return (
172
+ <Link
173
+ key={item.href}
174
+ href={item.href}
175
+ prefetch={false}
176
+ onClick={() => setMobileOpen(false)}
177
+ title={item.label}
178
+ aria-label={item.label}
179
+ data-nav-item
180
+ data-nav-href={item.href}
181
+ data-nav-active={active ? 'true' : undefined}
182
+ data-badge={badge > 0 ? String(badge) : undefined}
183
+ className="relative flex items-center justify-center w-11 h-11 group anet-nav-item-rail"
184
+ >
185
+ {/* 3px left indicator bar for selected — 20px length, 2px
186
+ round, matches spec. Positioned absolute so it hangs
187
+ off the container without moving the icon. */}
188
+ {active && (
189
+ <span
190
+ aria-hidden
191
+ data-nav-active-bar
192
+ className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-[2px] bg-[#7DD3FC]"
193
+ />
194
+ )}
195
+ {/* 40×40 icon background square (selected only) — inner
196
+ visual, centered inside the 44×44 tap container. */}
197
+ <span
198
+ className={`flex items-center justify-center w-10 h-10 rounded-lg transition-colors ${
199
+ active
200
+ ? 'bg-[rgba(125,211,252,0.12)] text-[#7DD3FC]'
201
+ : 'text-[#64748B] hover:text-[#E2E8F0] hover:bg-[rgba(255,255,255,0.04)]'
202
+ }`}
203
+ >
204
+ <svg className="w-6 h-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
205
+ <path strokeLinecap="round" strokeLinejoin="round" d={item.icon} />
206
+ </svg>
207
+ </span>
208
+ {badge > 0 && (
209
+ <span
210
+ aria-label={`${item.label} — ${badgeLabel(badge)} unread`}
211
+ data-nav-badge
212
+ className="absolute top-0 right-0 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-semibold leading-4 text-center pointer-events-none"
213
+ >
214
+ {badgeLabel(badge)}
215
+ </span>
216
+ )}
217
+ </Link>
218
+ );
219
+ }
220
+ // Non-icon-rail (expanded / collapsed) — unchanged legacy behavior.
221
+ return (
222
+ <Link
223
+ key={item.href}
224
+ href={item.href}
225
+ prefetch={false}
226
+ onClick={() => setMobileOpen(false)}
227
+ title={collapsed ? item.label : undefined}
228
+ aria-label={item.label}
229
+ data-nav-item
230
+ data-nav-href={item.href}
231
+ data-nav-active={active ? 'true' : undefined}
232
+ className={`relative flex items-center gap-3 rounded-lg text-sm transition-colors active:bg-[#232327] ${
233
+ active
234
+ ? 'bg-cyan-500/10 text-cyan-300 border border-cyan-500/20 anet-nav-active'
235
+ : 'text-gray-400 hover:text-gray-200 hover:bg-[#1c1c1f]'
236
+ } ${collapsed ? 'justify-center px-0 py-3' : 'px-3 py-3'}`}
237
+ >
238
+ <svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
239
+ <path strokeLinecap="round" strokeLinejoin="round" d={item.icon} />
240
+ </svg>
241
+ {!collapsed && <span>{item.label}</span>}
242
+ </Link>
243
+ );
244
+ })}
109
245
  </nav>
110
246
  );
111
247
 
@@ -142,10 +278,14 @@ export function Sidebar() {
142
278
  in light themes so the sidebar reads as its own card.
143
279
  Round 47: explicit ease-out curve + slight shadow so the drawer
144
280
  edge "leaves a trail" as it slides in. */}
145
- <aside data-anet-sidebar="true" className={`
146
- fixed top-0 left-0 h-full z-40 bg-[#111113] border-r border-[#26262b]
281
+ <aside
282
+ data-anet-sidebar="true"
283
+ data-sidebar-mode={mode}
284
+ data-total-unread={totalUnread}
285
+ className={`
286
+ fixed top-0 left-0 h-full z-40
147
287
  transition-transform duration-200 ease-out
148
- ${collapsed ? 'w-16' : 'w-52'}
288
+ ${mode === 'expanded' ? 'w-52 bg-[#111113] border-r border-[#26262b]' : mode === 'icon-rail' ? 'w-14 bg-[#0A0C14]' : 'w-16 bg-[#111113] border-r border-[#26262b]'}
149
289
  ${mobileOpen ? 'translate-x-0 shadow-2xl shadow-black/40 lg:shadow-none' : '-translate-x-full'}
150
290
  lg:translate-x-0 lg:static lg:shrink-0 lg:shadow-none
151
291
  `}>
@@ -252,12 +392,23 @@ export function Sidebar() {
252
392
  </button>
253
393
  <div className={`flex items-center ${collapsed ? 'flex-col px-0 py-2' : 'px-3 py-2 justify-end'}`}>
254
394
  <button
255
- onClick={() => setCollapsed(!collapsed)}
256
- title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
257
- className="hidden lg:flex p-1.5 rounded text-gray-600 hover:text-gray-400 transition-colors"
258
- aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
395
+ onClick={cycleMode}
396
+ title={`当前:${SIDEBAR_MODE_LABEL[mode]} 点击切换(展开 折叠 图标)`}
397
+ data-testid="sidebar-mode-cycle"
398
+ className="hidden lg:flex p-1.5 rounded text-gray-600 hover:text-gray-400 transition-colors items-center gap-1"
399
+ aria-label={`Cycle sidebar mode (current: ${mode})`}
259
400
  >
260
- <svg className={`w-4 h-4 transition-transform ${collapsed ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
401
+ {/* Chevron rotates to hint at direction expanded
402
+ points left (will collapse); collapsed → left too
403
+ (will narrow further); icon-rail → right (will
404
+ expand back to full width, wrapping the cycle). */}
405
+ <svg
406
+ className={`w-4 h-4 transition-transform ${mode === 'icon-rail' ? 'rotate-180' : ''}`}
407
+ fill="none"
408
+ viewBox="0 0 24 24"
409
+ stroke="currentColor"
410
+ strokeWidth={2}
411
+ >
261
412
  <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
262
413
  </svg>
263
414
  </button>
@@ -1260,7 +1260,7 @@ export function TaskChatPanel({ alias, onClose, inline, availableNodes, active }
1260
1260
  mobile and the per-pair grouping (line 540) from space-y-2
1261
1261
  to space-y-1.5 so messages read denser without losing the
1262
1262
  speaker-turn rhythm. Desktop unchanged at sm: and up. */}
1263
- <div ref={scrollRef} onScroll={onMessagesScroll} className="flex-1 overflow-y-auto px-3 py-3 sm:px-4 sm:py-4 space-y-2 sm:space-y-4">
1263
+ <div ref={scrollRef} onScroll={onMessagesScroll} data-testid="chat-messages-scroll" className="flex-1 overflow-y-auto px-3 py-3 sm:px-4 sm:py-4 space-y-2 sm:space-y-4">
1264
1264
  {searchOpen ? (
1265
1265
  <div className="sticky top-0 z-20 -mx-1 flex items-center gap-2 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)]/95 px-2 py-1.5 shadow-lg shadow-black/30 backdrop-blur">
1266
1266
  <input
@@ -0,0 +1,407 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import useSWR from 'swr';
5
+ import { AliasAvatar } from './AliasAvatar';
6
+ import { STATUS_CHIP_CLASS as STATUS_CHIP } from '../lib/status';
7
+
8
+ // Detail pane for the /tasks two-column layout.
9
+ //
10
+ // Feature-parity target (通信龙 07-31 parity 硬门, migrating from the
11
+ // old full-page /tasks/[id]/page.tsx): five blocks, each with its own
12
+ // positive assertion in tests + real data visible (not just "the div
13
+ // is there"):
14
+ // 1. Timeline card — steps: created / delivered / started / completed
15
+ // 2. Info card — task_id / from / to / priority / status / expires
16
+ // 3. Content
17
+ // 4. Result
18
+ // 5. Events feed — /api/hub/task-events?limit=50 (10s poll)
19
+ //
20
+ // Poll cadence — the old full-page useSWR was 5s (task) + 10s (events).
21
+ // Do NOT widen either interval: "page's there but data's stale" is worse
22
+ // than "page's not there" because it looks fine to anyone glancing at it.
23
+ // The intervals are part of parity — tests should assert both refetch.
24
+
25
+ interface TaskDetailData {
26
+ task_id: string;
27
+ network_id?: string;
28
+ from_name: string;
29
+ to_name: string;
30
+ status: string;
31
+ priority: string;
32
+ content: string;
33
+ result: string;
34
+ created_at: string;
35
+ updated_at: string;
36
+ delivered_at: string;
37
+ started_at: string;
38
+ completed_at: string;
39
+ expires_at: string;
40
+ }
41
+
42
+ interface TaskEvent {
43
+ id: number;
44
+ event_type: string;
45
+ from_status: string;
46
+ to_status: string;
47
+ detail: string;
48
+ created_at: string;
49
+ }
50
+
51
+ // 🔴 Poll cadences are declared as module-scope constants so the useSWR
52
+ // refreshInterval and the DOM `data-poll-*-ms` attribute are *literally*
53
+ // the same number. Two separate literals meant the attribute-based test
54
+ // assertion could go green even if someone widened refreshInterval —
55
+ // the assertion was watching the string, not the fact.
56
+ // (通信龙 07-31 catch: "断言的是声明, 不是事实.")
57
+ // Widen either interval only alongside a paired update to the test
58
+ // asserting cadence, and re-run witnessed-red by bumping one of these
59
+ // to a wrong value.
60
+ const POLL_TASK_MS = 5000;
61
+ const POLL_EVENTS_MS = 10000;
62
+
63
+ function timeAgo(dateStr: string): string {
64
+ if (!dateStr) return '--';
65
+ const diff = Date.now() - new Date(dateStr.replace(' ', 'T') + 'Z').getTime();
66
+ const s = Math.floor(diff / 1000);
67
+ if (s < 60) return `${s}s ago`;
68
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
69
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
70
+ return `${Math.floor(s / 86400)}d ago`;
71
+ }
72
+
73
+ function priorityClass(priority: string) {
74
+ if (priority === 'high') return 'text-red-400';
75
+ if (priority === 'low') return 'text-gray-600';
76
+ return 'text-gray-400';
77
+ }
78
+
79
+ const fetcher = async (url: string) => {
80
+ const res = await fetch(url);
81
+ if (res.status === 401) {
82
+ window.location.assign('/login');
83
+ throw new Error('unauthorized');
84
+ }
85
+ return res.json();
86
+ };
87
+
88
+ export function TaskDetail({
89
+ taskId,
90
+ onClose,
91
+ onRetried,
92
+ }: {
93
+ taskId: string;
94
+ /** X in the header — desktop returns pane to empty, mobile dismisses overlay.
95
+ * Either way the URL drops back to /tasks (parent handles). Preserves the
96
+ * 返回入口 the old full-page detail had via its "← Tasks" link. */
97
+ onClose?: () => void;
98
+ /** After a successful retry so the parent list can refetch. */
99
+ onRetried?: () => void;
100
+ }) {
101
+ // 🔴 Poll intervals are part of parity: 5s task, 10s events. Do not widen
102
+ // without updating the corresponding cadence assertion.
103
+ const { data: taskData, isLoading, error, mutate: refetchTask } = useSWR<{
104
+ tasks: TaskDetailData[];
105
+ }>(
106
+ `/api/hub/tasks?task_id=${encodeURIComponent(taskId)}`,
107
+ fetcher,
108
+ { refreshInterval: POLL_TASK_MS },
109
+ );
110
+ const { data: eventsData } = useSWR<{ events: TaskEvent[] }>(
111
+ `/api/hub/task-events?task_id=${encodeURIComponent(taskId)}&limit=50`,
112
+ fetcher,
113
+ { refreshInterval: POLL_EVENTS_MS },
114
+ );
115
+ const [retrying, setRetrying] = useState(false);
116
+
117
+ const task = taskData?.tasks?.[0];
118
+ const events = eventsData?.events || [];
119
+
120
+ const doRetry = async () => {
121
+ if (!task) return;
122
+ setRetrying(true);
123
+ try {
124
+ await fetch('/api/hub/send', {
125
+ method: 'POST',
126
+ headers: { 'Content-Type': 'application/json' },
127
+ body: JSON.stringify({
128
+ alias: task.to_name,
129
+ task: task.content,
130
+ priority: task.priority,
131
+ ...(task.network_id ? { network_id: task.network_id } : {}),
132
+ }),
133
+ });
134
+ refetchTask();
135
+ onRetried?.();
136
+ } finally {
137
+ setRetrying(false);
138
+ }
139
+ };
140
+
141
+ const timelineSteps: { label: string; time: string; color: string }[] = task
142
+ ? [
143
+ { label: 'Created', time: task.created_at, color: 'text-gray-400' },
144
+ { label: 'Delivered', time: task.delivered_at, color: 'text-blue-400' },
145
+ { label: 'Started', time: task.started_at, color: 'text-green-400' },
146
+ { label: 'Completed', time: task.completed_at, color: 'text-purple-400' },
147
+ ]
148
+ : [];
149
+
150
+ return (
151
+ <div
152
+ data-testid="task-detail-pane"
153
+ data-task-id={taskId}
154
+ data-poll-task-ms={String(POLL_TASK_MS)}
155
+ data-poll-events-ms={String(POLL_EVENTS_MS)}
156
+ className="flex flex-col h-full max-h-screen overflow-hidden"
157
+ >
158
+ <div className="flex items-center justify-between px-4 py-3 border-b border-[#1c1c1f]">
159
+ <div className="min-w-0 flex-1 flex items-center gap-2">
160
+ <div className="text-xs text-gray-500 truncate" title={taskId}>
161
+ <span className="text-gray-600">Task ID:</span>{' '}
162
+ <span className="font-mono">{taskId.slice(0, 12)}…</span>
163
+ </div>
164
+ {task && (
165
+ <span
166
+ data-testid="task-detail-status"
167
+ className={`text-xs px-2 py-0.5 rounded-md border ${
168
+ STATUS_CHIP[task.status] ||
169
+ 'bg-gray-500/10 text-gray-400 border-gray-500/20'
170
+ }`}
171
+ >
172
+ {task.status}
173
+ </span>
174
+ )}
175
+ </div>
176
+ {onClose && (
177
+ <button
178
+ type="button"
179
+ onClick={onClose}
180
+ aria-label="Close detail (返回 tasks 列表)"
181
+ data-testid="task-detail-close"
182
+ className="shrink-0 rounded-md border border-[#26262b] px-2 py-1 text-xs text-gray-400 hover:text-white hover:border-[#3a3a41]"
183
+ >
184
+
185
+ </button>
186
+ )}
187
+ </div>
188
+
189
+ <div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
190
+ {isLoading && !task && (
191
+ <div className="animate-pulse space-y-3">
192
+ <div className="h-6 w-1/3 bg-gray-800/40 rounded" />
193
+ <div className="h-24 bg-gray-800/40 rounded" />
194
+ <div className="h-24 bg-gray-800/40 rounded" />
195
+ </div>
196
+ )}
197
+ {error && (
198
+ <div className="bg-red-900/20 border border-red-800/40 text-red-300 px-3 py-2 rounded text-sm">
199
+ {error instanceof Error ? error.message : 'fetch failed'}
200
+ </div>
201
+ )}
202
+ {!isLoading && !error && !task && (
203
+ <div className="text-gray-400 text-sm">Task not found</div>
204
+ )}
205
+
206
+ {task && (
207
+ <>
208
+ {/* Block 1: Timeline card (parity #1) */}
209
+ <section
210
+ data-testid="task-detail-timeline"
211
+ data-block="timeline"
212
+ className="bg-[#161618] border border-[#26262b] rounded-xl p-4"
213
+ >
214
+ <h2 className="text-sm font-semibold text-gray-300 mb-4">Timeline</h2>
215
+ <div className="relative">
216
+ {timelineSteps.map((step, i) => {
217
+ const done = !!step.time;
218
+ const isLast = i === timelineSteps.length - 1;
219
+ return (
220
+ <div
221
+ key={step.label}
222
+ data-testid={`timeline-step-${step.label.toLowerCase()}`}
223
+ data-timeline-step={step.label.toLowerCase()}
224
+ data-timeline-done={done ? 'true' : 'false'}
225
+ className="flex gap-3 pb-4 last:pb-0"
226
+ >
227
+ <div className="flex flex-col items-center">
228
+ <div
229
+ className={`w-3 h-3 rounded-full border-2 shrink-0 ${
230
+ done ? `${step.color} border-current` : 'border-gray-700 bg-transparent'
231
+ }`}
232
+ />
233
+ {!isLast && (
234
+ <div
235
+ className={`w-0.5 flex-1 mt-1 ${
236
+ done && timelineSteps[i + 1]?.time
237
+ ? 'bg-cyan-500/40'
238
+ : 'bg-gray-800'
239
+ }`}
240
+ />
241
+ )}
242
+ </div>
243
+ <div className="min-w-0 -mt-0.5">
244
+ <div className={`text-sm font-medium ${done ? 'text-gray-200' : 'text-gray-600'}`}>
245
+ {step.label}
246
+ </div>
247
+ <div
248
+ className="text-xs text-gray-500"
249
+ title={step.time || undefined}
250
+ >
251
+ {step.time ? `${timeAgo(step.time)} — ${step.time}` : 'Pending'}
252
+ </div>
253
+ </div>
254
+ </div>
255
+ );
256
+ })}
257
+ </div>
258
+ </section>
259
+
260
+ {/* Block 2: Info card (parity #2) */}
261
+ <section
262
+ data-testid="task-detail-info"
263
+ data-block="info"
264
+ className="bg-[#161618] border border-[#26262b] rounded-xl p-4"
265
+ >
266
+ <h2 className="text-sm font-semibold text-gray-300 mb-3">Info</h2>
267
+ <div className="space-y-2 text-xs">
268
+ {[
269
+ ['Task ID', task.task_id],
270
+ ['From', task.from_name],
271
+ ['To', task.to_name],
272
+ ['Priority', task.priority],
273
+ ['Status', task.status],
274
+ ['Expires', task.expires_at],
275
+ ].map(([label, val]) => (
276
+ <div key={label as string} className="flex justify-between gap-4">
277
+ <span className="text-gray-500 shrink-0">{label}</span>
278
+ <span
279
+ data-info-field={String(label).toLowerCase().replace(/\s+/g, '-')}
280
+ className="text-gray-300 truncate max-w-[240px] text-right"
281
+ title={String(val || '')}
282
+ >
283
+ {String(val || '--')}
284
+ </span>
285
+ </div>
286
+ ))}
287
+ </div>
288
+ <div
289
+ className="mt-4 pt-3 border-t border-[#1c1c1f] flex items-center gap-2 text-sm text-gray-300"
290
+ data-testid="task-detail-participants"
291
+ >
292
+ {task.from_name && <AliasAvatar alias={task.from_name} size={20} />}
293
+ <span className="truncate">{task.from_name || '--'}</span>
294
+ <span className="text-gray-600 shrink-0">→</span>
295
+ {task.to_name && <AliasAvatar alias={task.to_name} size={20} />}
296
+ <span className="truncate">{task.to_name || '--'}</span>
297
+ </div>
298
+ <div className="mt-2 text-[10px]">
299
+ <span
300
+ className={priorityClass(task.priority)}
301
+ data-testid="task-detail-priority"
302
+ >
303
+ Priority: {task.priority || 'normal'}
304
+ </span>
305
+ </div>
306
+ </section>
307
+
308
+ {/* Block 3: Content (parity #3) */}
309
+ <section
310
+ data-testid="task-detail-content"
311
+ data-block="content"
312
+ className="bg-[#161618] border border-[#26262b] rounded-xl p-4"
313
+ >
314
+ <h2 className="text-sm font-semibold text-gray-300 mb-2">Content</h2>
315
+ <pre
316
+ data-testid="task-detail-content-body"
317
+ className="text-xs text-gray-200 whitespace-pre-wrap break-words bg-[#0e0e10] rounded-lg px-3 py-2 border border-[#1c1c1f] max-h-64 overflow-y-auto font-sans"
318
+ >
319
+ {task.content || '--'}
320
+ </pre>
321
+ </section>
322
+
323
+ {/* Block 4: Result (parity #4) */}
324
+ {task.result && (
325
+ <section
326
+ data-testid="task-detail-result"
327
+ data-block="result"
328
+ className="bg-[#161618] border border-[#26262b] rounded-xl p-4"
329
+ >
330
+ <h2 className="text-sm font-semibold text-gray-300 mb-2">Result</h2>
331
+ <pre
332
+ data-testid="task-detail-result-body"
333
+ className="text-xs text-gray-200 whitespace-pre-wrap break-words bg-[#0e0e10] rounded-lg px-3 py-2 border border-[#1c1c1f] max-h-64 overflow-y-auto font-sans"
334
+ >
335
+ {task.result}
336
+ </pre>
337
+ </section>
338
+ )}
339
+
340
+ {/* Block 5: Events feed (parity #5) — 10s poll, always rendered
341
+ even when empty so "no events" state is visible; a silently
342
+ missing block would hide the difference between "feed
343
+ gone" and "task has no events yet". */}
344
+ <section
345
+ data-testid="task-detail-events"
346
+ data-block="events"
347
+ data-events-count={events.length}
348
+ className="bg-[#161618] border border-[#26262b] rounded-xl p-4"
349
+ >
350
+ <h2 className="text-sm font-semibold text-gray-300 mb-3">
351
+ Events {events.length > 0 && <span className="text-gray-500">({events.length})</span>}
352
+ </h2>
353
+ {events.length === 0 ? (
354
+ <div className="text-xs text-gray-600 italic">No events yet.</div>
355
+ ) : (
356
+ <div className="space-y-2 max-h-64 overflow-y-auto">
357
+ {events.map((e, i) => (
358
+ <div
359
+ key={e.id ?? i}
360
+ data-testid="task-detail-event-row"
361
+ className="flex items-center gap-2 text-xs"
362
+ >
363
+ <span
364
+ aria-hidden
365
+ className={`w-1.5 h-1.5 rounded-full shrink-0 ${
366
+ e.to_status === 'running'
367
+ ? 'bg-green-400'
368
+ : e.to_status === 'replied'
369
+ ? 'bg-purple-400'
370
+ : e.to_status === 'failed'
371
+ ? 'bg-red-400'
372
+ : 'bg-blue-400'
373
+ }`}
374
+ />
375
+ <span className="text-gray-400">{e.event_type}</span>
376
+ {e.from_status && (
377
+ <span className="text-gray-600">
378
+ {e.from_status} → {e.to_status}
379
+ </span>
380
+ )}
381
+ <span className="text-gray-600 ml-auto shrink-0" title={e.created_at}>
382
+ {timeAgo(e.created_at)}
383
+ </span>
384
+ </div>
385
+ ))}
386
+ </div>
387
+ )}
388
+ </section>
389
+
390
+ {(task.status === 'failed' || task.status === 'expired') && (
391
+ <div>
392
+ <button
393
+ onClick={doRetry}
394
+ disabled={retrying}
395
+ data-testid="task-detail-retry"
396
+ className="text-xs text-orange-400 hover:text-orange-300 px-3 py-1.5 rounded-md border border-orange-500/30 hover:bg-orange-500/10 disabled:opacity-50 disabled:cursor-wait"
397
+ >
398
+ {retrying ? 'Retrying…' : 'Retry task'}
399
+ </button>
400
+ </div>
401
+ )}
402
+ </>
403
+ )}
404
+ </div>
405
+ </div>
406
+ );
407
+ }