botmux 2.107.0 → 2.108.0

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 (515) hide show
  1. package/README.en.md +17 -14
  2. package/README.md +19 -14
  3. package/dist/adapters/backend/sandbox.d.ts +3 -1
  4. package/dist/adapters/backend/sandbox.d.ts.map +1 -1
  5. package/dist/adapters/backend/sandbox.js +19 -3
  6. package/dist/adapters/backend/sandbox.js.map +1 -1
  7. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  8. package/dist/adapters/backend/tmux-backend.js +16 -1
  9. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  10. package/dist/adapters/backend/tmux-pipe-backend.d.ts +24 -15
  11. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  12. package/dist/adapters/backend/tmux-pipe-backend.js +75 -43
  13. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  14. package/dist/adapters/cli/genius.d.ts.map +1 -1
  15. package/dist/adapters/cli/genius.js +9 -15
  16. package/dist/adapters/cli/genius.js.map +1 -1
  17. package/dist/adapters/cli/grok.d.ts +73 -0
  18. package/dist/adapters/cli/grok.d.ts.map +1 -0
  19. package/dist/adapters/cli/grok.js +298 -0
  20. package/dist/adapters/cli/grok.js.map +1 -0
  21. package/dist/adapters/cli/oh-my-pi.d.ts.map +1 -1
  22. package/dist/adapters/cli/oh-my-pi.js +133 -12
  23. package/dist/adapters/cli/oh-my-pi.js.map +1 -1
  24. package/dist/adapters/cli/registry.d.ts +2 -1
  25. package/dist/adapters/cli/registry.d.ts.map +1 -1
  26. package/dist/adapters/cli/registry.js +3 -1
  27. package/dist/adapters/cli/registry.js.map +1 -1
  28. package/dist/adapters/cli/shared-hints.d.ts +8 -4
  29. package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
  30. package/dist/adapters/cli/shared-hints.js +8 -4
  31. package/dist/adapters/cli/shared-hints.js.map +1 -1
  32. package/dist/adapters/cli/types.d.ts +18 -16
  33. package/dist/adapters/cli/types.d.ts.map +1 -1
  34. package/dist/adapters/hook-installer.d.ts +5 -4
  35. package/dist/adapters/hook-installer.d.ts.map +1 -1
  36. package/dist/adapters/hook-installer.js +42 -0
  37. package/dist/adapters/hook-installer.js.map +1 -1
  38. package/dist/cli/send-dispatch.d.ts +21 -0
  39. package/dist/cli/send-dispatch.d.ts.map +1 -1
  40. package/dist/cli/send-dispatch.js +159 -0
  41. package/dist/cli/send-dispatch.js.map +1 -1
  42. package/dist/cli/session-list-liveness.d.ts +22 -0
  43. package/dist/cli/session-list-liveness.d.ts.map +1 -0
  44. package/dist/cli/session-list-liveness.js +22 -0
  45. package/dist/cli/session-list-liveness.js.map +1 -0
  46. package/dist/cli.d.ts.map +1 -1
  47. package/dist/cli.js +182 -33
  48. package/dist/cli.js.map +1 -1
  49. package/dist/core/ask-types.d.ts +4 -0
  50. package/dist/core/ask-types.d.ts.map +1 -1
  51. package/dist/core/ask-types.js +10 -0
  52. package/dist/core/ask-types.js.map +1 -1
  53. package/dist/core/cost-calculator.d.ts.map +1 -1
  54. package/dist/core/cost-calculator.js +35 -13
  55. package/dist/core/cost-calculator.js.map +1 -1
  56. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  57. package/dist/core/dashboard-ipc-server.js +89 -16
  58. package/dist/core/dashboard-ipc-server.js.map +1 -1
  59. package/dist/core/dashboard-rows.d.ts.map +1 -1
  60. package/dist/core/dashboard-rows.js +6 -2
  61. package/dist/core/dashboard-rows.js.map +1 -1
  62. package/dist/core/dashboard-url.d.ts +6 -5
  63. package/dist/core/dashboard-url.d.ts.map +1 -1
  64. package/dist/core/dashboard-url.js +11 -8
  65. package/dist/core/dashboard-url.js.map +1 -1
  66. package/dist/core/maintenance.d.ts +11 -8
  67. package/dist/core/maintenance.d.ts.map +1 -1
  68. package/dist/core/maintenance.js +45 -20
  69. package/dist/core/maintenance.js.map +1 -1
  70. package/dist/core/per-bot-env.d.ts.map +1 -1
  71. package/dist/core/per-bot-env.js +4 -0
  72. package/dist/core/per-bot-env.js.map +1 -1
  73. package/dist/core/session-discovery.d.ts.map +1 -1
  74. package/dist/core/session-discovery.js +4 -0
  75. package/dist/core/session-discovery.js.map +1 -1
  76. package/dist/core/skills/cli-admin-command.d.ts.map +1 -1
  77. package/dist/core/skills/cli-admin-command.js +8 -2
  78. package/dist/core/skills/cli-admin-command.js.map +1 -1
  79. package/dist/core/skills/im-command.d.ts.map +1 -1
  80. package/dist/core/skills/im-command.js +3 -1
  81. package/dist/core/skills/im-command.js.map +1 -1
  82. package/dist/core/skills/sources.d.ts +33 -1
  83. package/dist/core/skills/sources.d.ts.map +1 -1
  84. package/dist/core/skills/sources.js +97 -0
  85. package/dist/core/skills/sources.js.map +1 -1
  86. package/dist/core/skills/types.d.ts +8 -0
  87. package/dist/core/skills/types.d.ts.map +1 -1
  88. package/dist/core/terminal-url.d.ts.map +1 -1
  89. package/dist/core/terminal-url.js +12 -2
  90. package/dist/core/terminal-url.js.map +1 -1
  91. package/dist/core/terminal-write-auth.d.ts +34 -0
  92. package/dist/core/terminal-write-auth.d.ts.map +1 -0
  93. package/dist/core/terminal-write-auth.js +76 -0
  94. package/dist/core/terminal-write-auth.js.map +1 -0
  95. package/dist/core/tmux-recovery.d.ts +11 -0
  96. package/dist/core/tmux-recovery.d.ts.map +1 -0
  97. package/dist/core/tmux-recovery.js +19 -0
  98. package/dist/core/tmux-recovery.js.map +1 -0
  99. package/dist/core/types.d.ts +10 -4
  100. package/dist/core/types.d.ts.map +1 -1
  101. package/dist/core/types.js.map +1 -1
  102. package/dist/core/update-check.d.ts +2 -2
  103. package/dist/core/update-check.js +2 -2
  104. package/dist/core/worker-pool.d.ts +3 -0
  105. package/dist/core/worker-pool.d.ts.map +1 -1
  106. package/dist/core/worker-pool.js +70 -3
  107. package/dist/core/worker-pool.js.map +1 -1
  108. package/dist/daemon.d.ts.map +1 -1
  109. package/dist/daemon.js +15 -8
  110. package/dist/daemon.js.map +1 -1
  111. package/dist/dashboard/auth.d.ts +1 -1
  112. package/dist/dashboard/auth.d.ts.map +1 -1
  113. package/dist/dashboard/auth.js +7 -2
  114. package/dist/dashboard/auth.js.map +1 -1
  115. package/dist/dashboard/bot-payload.d.ts +3 -0
  116. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  117. package/dist/dashboard/bot-payload.js +3 -0
  118. package/dist/dashboard/bot-payload.js.map +1 -1
  119. package/dist/dashboard/connector-api.d.ts.map +1 -1
  120. package/dist/dashboard/connector-api.js +39 -13
  121. package/dist/dashboard/connector-api.js.map +1 -1
  122. package/dist/dashboard/history-senders.d.ts +37 -0
  123. package/dist/dashboard/history-senders.d.ts.map +1 -0
  124. package/dist/dashboard/history-senders.js +60 -0
  125. package/dist/dashboard/history-senders.js.map +1 -0
  126. package/dist/dashboard/roles-batch.d.ts +31 -0
  127. package/dist/dashboard/roles-batch.d.ts.map +1 -0
  128. package/dist/dashboard/roles-batch.js +87 -0
  129. package/dist/dashboard/roles-batch.js.map +1 -0
  130. package/dist/dashboard/settings-card-model.d.ts +4 -2
  131. package/dist/dashboard/settings-card-model.d.ts.map +1 -1
  132. package/dist/dashboard/settings-card-model.js +13 -5
  133. package/dist/dashboard/settings-card-model.js.map +1 -1
  134. package/dist/dashboard/settings-write-applier.d.ts +4 -1
  135. package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
  136. package/dist/dashboard/settings-write-applier.js +6 -1
  137. package/dist/dashboard/settings-write-applier.js.map +1 -1
  138. package/dist/dashboard/skill-install-request.d.ts +4 -0
  139. package/dist/dashboard/skill-install-request.d.ts.map +1 -1
  140. package/dist/dashboard/skill-install-request.js +11 -1
  141. package/dist/dashboard/skill-install-request.js.map +1 -1
  142. package/dist/dashboard/trigger-api.d.ts +9 -1
  143. package/dist/dashboard/trigger-api.d.ts.map +1 -1
  144. package/dist/dashboard/trigger-api.js +48 -10
  145. package/dist/dashboard/trigger-api.js.map +1 -1
  146. package/dist/dashboard/web/app.d.ts.map +1 -1
  147. package/dist/dashboard/web/app.js +286 -271
  148. package/dist/dashboard/web/app.js.map +1 -1
  149. package/dist/dashboard/web/bot-defaults-page.d.ts +26 -0
  150. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  151. package/dist/dashboard/web/bot-defaults-page.js +1408 -12
  152. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  153. package/dist/dashboard/web/bot-defaults.d.ts +91 -4
  154. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  155. package/dist/dashboard/web/bot-defaults.js +43 -2141
  156. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  157. package/dist/dashboard/web/bot-onboarding.d.ts +6 -1
  158. package/dist/dashboard/web/bot-onboarding.d.ts.map +1 -1
  159. package/dist/dashboard/web/bot-onboarding.js +261 -306
  160. package/dist/dashboard/web/bot-onboarding.js.map +1 -1
  161. package/dist/dashboard/web/connectors-page.d.ts +4 -0
  162. package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
  163. package/dist/dashboard/web/connectors-page.js +228 -48
  164. package/dist/dashboard/web/connectors-page.js.map +1 -1
  165. package/dist/dashboard/web/dashboard-api.d.ts +9 -0
  166. package/dist/dashboard/web/dashboard-api.d.ts.map +1 -0
  167. package/dist/dashboard/web/dashboard-api.js +18 -0
  168. package/dist/dashboard/web/dashboard-api.js.map +1 -0
  169. package/dist/dashboard/web/dashboard-components.d.ts +92 -0
  170. package/dist/dashboard/web/dashboard-components.d.ts.map +1 -0
  171. package/dist/dashboard/web/dashboard-components.js +250 -0
  172. package/dist/dashboard/web/dashboard-components.js.map +1 -0
  173. package/dist/dashboard/web/dashboard-routes.d.ts.map +1 -1
  174. package/dist/dashboard/web/dashboard-routes.js +30 -183
  175. package/dist/dashboard/web/dashboard-routes.js.map +1 -1
  176. package/dist/dashboard/web/floating-scrollbars.d.ts +2 -0
  177. package/dist/dashboard/web/floating-scrollbars.d.ts.map +1 -0
  178. package/dist/dashboard/web/floating-scrollbars.js +264 -0
  179. package/dist/dashboard/web/floating-scrollbars.js.map +1 -0
  180. package/dist/dashboard/web/groups-api.d.ts +36 -0
  181. package/dist/dashboard/web/groups-api.d.ts.map +1 -0
  182. package/dist/dashboard/web/groups-api.js +44 -0
  183. package/dist/dashboard/web/groups-api.js.map +1 -0
  184. package/dist/dashboard/web/groups-page.d.ts.map +1 -1
  185. package/dist/dashboard/web/groups-page.js +746 -15
  186. package/dist/dashboard/web/groups-page.js.map +1 -1
  187. package/dist/dashboard/web/groups.d.ts +51 -18
  188. package/dist/dashboard/web/groups.d.ts.map +1 -1
  189. package/dist/dashboard/web/groups.js +145 -1096
  190. package/dist/dashboard/web/groups.js.map +1 -1
  191. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  192. package/dist/dashboard/web/i18n.js +545 -255
  193. package/dist/dashboard/web/i18n.js.map +1 -1
  194. package/dist/dashboard/web/insights-page.d.ts.map +1 -1
  195. package/dist/dashboard/web/insights-page.js +1047 -14
  196. package/dist/dashboard/web/insights-page.js.map +1 -1
  197. package/dist/dashboard/web/insights.d.ts +177 -7
  198. package/dist/dashboard/web/insights.d.ts.map +1 -1
  199. package/dist/dashboard/web/insights.js +187 -1926
  200. package/dist/dashboard/web/insights.js.map +1 -1
  201. package/dist/dashboard/web/legacy-workflow-page.d.ts +3 -0
  202. package/dist/dashboard/web/legacy-workflow-page.d.ts.map +1 -0
  203. package/dist/dashboard/web/legacy-workflow-page.js +717 -0
  204. package/dist/dashboard/web/legacy-workflow-page.js.map +1 -0
  205. package/dist/dashboard/web/monitor-room.d.ts.map +1 -1
  206. package/dist/dashboard/web/monitor-room.js +10 -8
  207. package/dist/dashboard/web/monitor-room.js.map +1 -1
  208. package/dist/dashboard/web/monitoring-page.d.ts.map +1 -1
  209. package/dist/dashboard/web/monitoring-page.js +188 -14
  210. package/dist/dashboard/web/monitoring-page.js.map +1 -1
  211. package/dist/dashboard/web/office-page.d.ts.map +1 -1
  212. package/dist/dashboard/web/office-page.js +11 -23
  213. package/dist/dashboard/web/office-page.js.map +1 -1
  214. package/dist/dashboard/web/overview-page.d.ts.map +1 -1
  215. package/dist/dashboard/web/overview-page.js +141 -147
  216. package/dist/dashboard/web/overview-page.js.map +1 -1
  217. package/dist/dashboard/web/overview.d.ts +0 -1
  218. package/dist/dashboard/web/overview.d.ts.map +1 -1
  219. package/dist/dashboard/web/overview.js +3 -275
  220. package/dist/dashboard/web/overview.js.map +1 -1
  221. package/dist/dashboard/web/preferences.d.ts +1 -6
  222. package/dist/dashboard/web/preferences.d.ts.map +1 -1
  223. package/dist/dashboard/web/preferences.js +0 -21
  224. package/dist/dashboard/web/preferences.js.map +1 -1
  225. package/dist/dashboard/web/role-batch.d.ts +9 -0
  226. package/dist/dashboard/web/role-batch.d.ts.map +1 -0
  227. package/dist/dashboard/web/role-batch.js +42 -0
  228. package/dist/dashboard/web/role-batch.js.map +1 -0
  229. package/dist/dashboard/web/roles-page.d.ts.map +1 -1
  230. package/dist/dashboard/web/roles-page.js +538 -13
  231. package/dist/dashboard/web/roles-page.js.map +1 -1
  232. package/dist/dashboard/web/roles.d.ts +98 -1
  233. package/dist/dashboard/web/roles.d.ts.map +1 -1
  234. package/dist/dashboard/web/roles.js +117 -903
  235. package/dist/dashboard/web/roles.js.map +1 -1
  236. package/dist/dashboard/web/schedules-page.d.ts.map +1 -1
  237. package/dist/dashboard/web/schedules-page.js +82 -24
  238. package/dist/dashboard/web/schedules-page.js.map +1 -1
  239. package/dist/dashboard/web/sessions-kanban.d.ts +67 -0
  240. package/dist/dashboard/web/sessions-kanban.d.ts.map +1 -0
  241. package/dist/dashboard/web/sessions-kanban.js +481 -0
  242. package/dist/dashboard/web/sessions-kanban.js.map +1 -0
  243. package/dist/dashboard/web/sessions-page.d.ts +4 -0
  244. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  245. package/dist/dashboard/web/sessions-page.js +1633 -18
  246. package/dist/dashboard/web/sessions-page.js.map +1 -1
  247. package/dist/dashboard/web/sessions.d.ts +45 -4
  248. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  249. package/dist/dashboard/web/sessions.js +53 -2535
  250. package/dist/dashboard/web/sessions.js.map +1 -1
  251. package/dist/dashboard/web/settings-page.d.ts +0 -2
  252. package/dist/dashboard/web/settings-page.d.ts.map +1 -1
  253. package/dist/dashboard/web/settings-page.js +127 -117
  254. package/dist/dashboard/web/settings-page.js.map +1 -1
  255. package/dist/dashboard/web/skills-page.d.ts +10 -1
  256. package/dist/dashboard/web/skills-page.d.ts.map +1 -1
  257. package/dist/dashboard/web/skills-page.js +235 -93
  258. package/dist/dashboard/web/skills-page.js.map +1 -1
  259. package/dist/dashboard/web/team-federation-page.d.ts.map +1 -1
  260. package/dist/dashboard/web/team-federation-page.js +446 -28
  261. package/dist/dashboard/web/team-federation-page.js.map +1 -1
  262. package/dist/dashboard/web/team-federation.d.ts +91 -2
  263. package/dist/dashboard/web/team-federation.d.ts.map +1 -1
  264. package/dist/dashboard/web/team-federation.js +87 -474
  265. package/dist/dashboard/web/team-federation.js.map +1 -1
  266. package/dist/dashboard/web/theme-menu.d.ts +1 -0
  267. package/dist/dashboard/web/theme-menu.d.ts.map +1 -1
  268. package/dist/dashboard/web/theme-menu.js +79 -20
  269. package/dist/dashboard/web/theme-menu.js.map +1 -1
  270. package/dist/dashboard/web/ui.d.ts +1 -2
  271. package/dist/dashboard/web/ui.d.ts.map +1 -1
  272. package/dist/dashboard/web/ui.js +8 -20
  273. package/dist/dashboard/web/ui.js.map +1 -1
  274. package/dist/dashboard/web/v3-api.d.ts +16 -0
  275. package/dist/dashboard/web/v3-api.d.ts.map +1 -0
  276. package/dist/dashboard/web/v3-api.js +21 -0
  277. package/dist/dashboard/web/v3-api.js.map +1 -0
  278. package/dist/dashboard/web/v3-components.d.ts +2 -0
  279. package/dist/dashboard/web/v3-components.d.ts.map +1 -0
  280. package/dist/dashboard/web/v3-components.js +290 -0
  281. package/dist/dashboard/web/v3-components.js.map +1 -0
  282. package/dist/dashboard/web/v3-model.d.ts +80 -0
  283. package/dist/dashboard/web/v3-model.d.ts.map +1 -0
  284. package/dist/dashboard/web/v3-model.js +240 -0
  285. package/dist/dashboard/web/v3-model.js.map +1 -0
  286. package/dist/dashboard/web/v3-page.d.ts.map +1 -1
  287. package/dist/dashboard/web/v3-page.js +2 -22
  288. package/dist/dashboard/web/v3-page.js.map +1 -1
  289. package/dist/dashboard/web/v3-terminal.d.ts +14 -0
  290. package/dist/dashboard/web/v3-terminal.d.ts.map +1 -1
  291. package/dist/dashboard/web/v3-terminal.js +31 -19
  292. package/dist/dashboard/web/v3-terminal.js.map +1 -1
  293. package/dist/dashboard/web/webhook-logs-page.d.ts +16 -0
  294. package/dist/dashboard/web/webhook-logs-page.d.ts.map +1 -0
  295. package/dist/dashboard/web/webhook-logs-page.js +195 -0
  296. package/dist/dashboard/web/webhook-logs-page.js.map +1 -0
  297. package/dist/dashboard/web/whiteboards-page.d.ts.map +1 -1
  298. package/dist/dashboard/web/whiteboards-page.js +18 -23
  299. package/dist/dashboard/web/whiteboards-page.js.map +1 -1
  300. package/dist/dashboard/web/workflow-version-switch.d.ts +8 -0
  301. package/dist/dashboard/web/workflow-version-switch.d.ts.map +1 -0
  302. package/dist/dashboard/web/workflow-version-switch.js +9 -0
  303. package/dist/dashboard/web/workflow-version-switch.js.map +1 -0
  304. package/dist/dashboard/web/workflows.d.ts +272 -1
  305. package/dist/dashboard/web/workflows.d.ts.map +1 -1
  306. package/dist/dashboard/web/workflows.js +211 -1397
  307. package/dist/dashboard/web/workflows.js.map +1 -1
  308. package/dist/dashboard/webhook-routes.d.ts.map +1 -1
  309. package/dist/dashboard/webhook-routes.js +92 -39
  310. package/dist/dashboard/webhook-routes.js.map +1 -1
  311. package/dist/dashboard-web/app.js +1 -65
  312. package/dist/dashboard-web/brand-logo.png +0 -0
  313. package/dist/dashboard-web/chunks/bot-defaults-page-HSQ6HR3R.js +1 -0
  314. package/dist/dashboard-web/chunks/chunk-6TPODDDC.js +1 -0
  315. package/dist/dashboard-web/chunks/chunk-73EJP2MO.js +1 -0
  316. package/dist/dashboard-web/chunks/chunk-BRTZFELS.js +1 -0
  317. package/dist/dashboard-web/chunks/chunk-EVK77ASG.js +1 -0
  318. package/dist/dashboard-web/chunks/chunk-R3FXBFQH.js +1 -0
  319. package/dist/dashboard-web/chunks/chunk-VRLL7SNU.js +1 -0
  320. package/dist/dashboard-web/chunks/chunk-XCPSHD3G.js +37 -0
  321. package/dist/dashboard-web/chunks/chunk-XJ7BGPJH.js +1 -0
  322. package/dist/dashboard-web/chunks/chunk-YVRWYGXR.js +1 -0
  323. package/dist/dashboard-web/chunks/chunk-ZJING6FY.js +66 -0
  324. package/dist/dashboard-web/chunks/connectors-page-ODUA5UCW.js +1 -0
  325. package/dist/dashboard-web/chunks/groups-page-OYX5M6NK.js +8 -0
  326. package/dist/dashboard-web/chunks/insights-page-YCQO6OCR.js +22 -0
  327. package/dist/dashboard-web/chunks/legacy-workflow-page-XJAUNONP.js +2 -0
  328. package/dist/dashboard-web/chunks/{monitor-room-RFX4HPS5.js → monitor-room-WTZNYAFV.js} +17 -15
  329. package/dist/dashboard-web/chunks/monitoring-page-R6LJUK7G.js +2 -0
  330. package/dist/dashboard-web/chunks/office-page-4OXK3GNR.js +1 -0
  331. package/dist/dashboard-web/chunks/overview-page-4MVEJY6U.js +1 -0
  332. package/dist/dashboard-web/chunks/roles-page-NQDW27VV.js +1 -0
  333. package/dist/dashboard-web/chunks/schedules-page-JRNKYHOX.js +1 -0
  334. package/dist/dashboard-web/chunks/sessions-page-LF7UVI7B.js +3 -0
  335. package/dist/dashboard-web/chunks/settings-page-WGW7KQQS.js +2 -0
  336. package/dist/dashboard-web/chunks/skills-page-AMD4XP3T.js +3 -0
  337. package/dist/dashboard-web/chunks/team-federation-page-SYG3K4GG.js +1 -0
  338. package/dist/dashboard-web/chunks/v3-page-6NFFWNUK.js +94 -0
  339. package/dist/dashboard-web/chunks/whiteboards-page-FBRUK2AS.js +1 -0
  340. package/dist/dashboard-web/favicon.png +0 -0
  341. package/dist/dashboard-web/index.html +5 -69
  342. package/dist/dashboard-web/style.css +18835 -7211
  343. package/dist/dashboard.js +150 -32
  344. package/dist/dashboard.js.map +1 -1
  345. package/dist/global-config.d.ts +2 -2
  346. package/dist/global-config.d.ts.map +1 -1
  347. package/dist/i18n/en.d.ts.map +1 -1
  348. package/dist/i18n/en.js +3 -0
  349. package/dist/i18n/en.js.map +1 -1
  350. package/dist/i18n/zh.d.ts.map +1 -1
  351. package/dist/i18n/zh.js +3 -0
  352. package/dist/i18n/zh.js.map +1 -1
  353. package/dist/im/lark/card-builder.d.ts +7 -5
  354. package/dist/im/lark/card-builder.d.ts.map +1 -1
  355. package/dist/im/lark/card-builder.js +10 -7
  356. package/dist/im/lark/card-builder.js.map +1 -1
  357. package/dist/im/lark/card-handler.d.ts.map +1 -1
  358. package/dist/im/lark/card-handler.js +25 -1
  359. package/dist/im/lark/card-handler.js.map +1 -1
  360. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  361. package/dist/im/lark/event-dispatcher.js +84 -0
  362. package/dist/im/lark/event-dispatcher.js.map +1 -1
  363. package/dist/platform/binding.d.ts +9 -0
  364. package/dist/platform/binding.d.ts.map +1 -1
  365. package/dist/platform/binding.js +14 -0
  366. package/dist/platform/binding.js.map +1 -1
  367. package/dist/platform/tunnel-client.d.ts.map +1 -1
  368. package/dist/platform/tunnel-client.js +9 -0
  369. package/dist/platform/tunnel-client.js.map +1 -1
  370. package/dist/services/bot-config-store.d.ts +1 -1
  371. package/dist/services/bot-config-store.d.ts.map +1 -1
  372. package/dist/services/bot-config-store.js +11 -5
  373. package/dist/services/bot-config-store.js.map +1 -1
  374. package/dist/services/file-bridge-path.d.ts +8 -0
  375. package/dist/services/file-bridge-path.d.ts.map +1 -0
  376. package/dist/services/file-bridge-path.js +75 -0
  377. package/dist/services/file-bridge-path.js.map +1 -0
  378. package/dist/services/grok-paths.d.ts +27 -0
  379. package/dist/services/grok-paths.d.ts.map +1 -0
  380. package/dist/services/grok-paths.js +106 -0
  381. package/dist/services/grok-paths.js.map +1 -0
  382. package/dist/services/grok-transcript.d.ts +67 -0
  383. package/dist/services/grok-transcript.d.ts.map +1 -0
  384. package/dist/services/grok-transcript.js +630 -0
  385. package/dist/services/grok-transcript.js.map +1 -0
  386. package/dist/services/hermes-session-filter.d.ts +28 -0
  387. package/dist/services/hermes-session-filter.d.ts.map +1 -0
  388. package/dist/services/hermes-session-filter.js +53 -0
  389. package/dist/services/hermes-session-filter.js.map +1 -0
  390. package/dist/services/hermes-transcript.d.ts +3 -0
  391. package/dist/services/hermes-transcript.d.ts.map +1 -1
  392. package/dist/services/hermes-transcript.js +20 -2
  393. package/dist/services/hermes-transcript.js.map +1 -1
  394. package/dist/services/resumable-session-discovery.d.ts +3 -0
  395. package/dist/services/resumable-session-discovery.d.ts.map +1 -1
  396. package/dist/services/resumable-session-discovery.js +6 -1
  397. package/dist/services/resumable-session-discovery.js.map +1 -1
  398. package/dist/services/skill-registry-store.d.ts +9 -0
  399. package/dist/services/skill-registry-store.d.ts.map +1 -1
  400. package/dist/services/skill-registry-store.js +304 -1
  401. package/dist/services/skill-registry-store.js.map +1 -1
  402. package/dist/services/structured-bridge-clis.d.ts +29 -0
  403. package/dist/services/structured-bridge-clis.d.ts.map +1 -0
  404. package/dist/services/structured-bridge-clis.js +76 -0
  405. package/dist/services/structured-bridge-clis.js.map +1 -0
  406. package/dist/services/trigger-log-store.d.ts +59 -0
  407. package/dist/services/trigger-log-store.d.ts.map +1 -1
  408. package/dist/services/trigger-log-store.js +81 -10
  409. package/dist/services/trigger-log-store.js.map +1 -1
  410. package/dist/services/usage-ledger.d.ts +4 -1
  411. package/dist/services/usage-ledger.d.ts.map +1 -1
  412. package/dist/services/usage-ledger.js +48 -10
  413. package/dist/services/usage-ledger.js.map +1 -1
  414. package/dist/services/webhook-audit.d.ts +10 -0
  415. package/dist/services/webhook-audit.d.ts.map +1 -0
  416. package/dist/services/webhook-audit.js +116 -0
  417. package/dist/services/webhook-audit.js.map +1 -0
  418. package/dist/setup/bot-config-editor.d.ts.map +1 -1
  419. package/dist/setup/bot-config-editor.js +2 -0
  420. package/dist/setup/bot-config-editor.js.map +1 -1
  421. package/dist/setup/ensure-tmux.d.ts +26 -12
  422. package/dist/setup/ensure-tmux.d.ts.map +1 -1
  423. package/dist/setup/ensure-tmux.js +85 -18
  424. package/dist/setup/ensure-tmux.js.map +1 -1
  425. package/dist/skills/definitions.d.ts.map +1 -1
  426. package/dist/skills/definitions.js +14 -1
  427. package/dist/skills/definitions.js.map +1 -1
  428. package/dist/skills/injection-mode.d.ts +16 -0
  429. package/dist/skills/injection-mode.d.ts.map +1 -1
  430. package/dist/skills/injection-mode.js +25 -0
  431. package/dist/skills/injection-mode.js.map +1 -1
  432. package/dist/types.d.ts +9 -0
  433. package/dist/types.d.ts.map +1 -1
  434. package/dist/utils/global-install.d.ts +22 -0
  435. package/dist/utils/global-install.d.ts.map +1 -0
  436. package/dist/utils/global-install.js +106 -0
  437. package/dist/utils/global-install.js.map +1 -0
  438. package/dist/utils/install-diagnostics.d.ts +1 -1
  439. package/dist/utils/install-diagnostics.d.ts.map +1 -1
  440. package/dist/utils/install-diagnostics.js +6 -4
  441. package/dist/utils/install-diagnostics.js.map +1 -1
  442. package/dist/utils/install-info.d.ts +2 -0
  443. package/dist/utils/install-info.d.ts.map +1 -1
  444. package/dist/utils/install-info.js +10 -4
  445. package/dist/utils/install-info.js.map +1 -1
  446. package/dist/worker.js +293 -172
  447. package/dist/worker.js.map +1 -1
  448. package/dist/workflows/events/payloads.d.ts +10 -10
  449. package/dist/workflows/events/schema.d.ts +28 -28
  450. package/package.json +8 -7
  451. package/dist/dashboard/web/connectors.d.ts +0 -2
  452. package/dist/dashboard/web/connectors.d.ts.map +0 -1
  453. package/dist/dashboard/web/connectors.js +0 -2
  454. package/dist/dashboard/web/connectors.js.map +0 -1
  455. package/dist/dashboard/web/office.d.ts +0 -2
  456. package/dist/dashboard/web/office.d.ts.map +0 -1
  457. package/dist/dashboard/web/office.js +0 -2
  458. package/dist/dashboard/web/office.js.map +0 -1
  459. package/dist/dashboard/web/schedules.d.ts +0 -2
  460. package/dist/dashboard/web/schedules.d.ts.map +0 -1
  461. package/dist/dashboard/web/schedules.js +0 -2
  462. package/dist/dashboard/web/schedules.js.map +0 -1
  463. package/dist/dashboard/web/settings.d.ts +0 -2
  464. package/dist/dashboard/web/settings.d.ts.map +0 -1
  465. package/dist/dashboard/web/settings.js +0 -2
  466. package/dist/dashboard/web/settings.js.map +0 -1
  467. package/dist/dashboard/web/skills.d.ts +0 -2
  468. package/dist/dashboard/web/skills.d.ts.map +0 -1
  469. package/dist/dashboard/web/skills.js +0 -2
  470. package/dist/dashboard/web/skills.js.map +0 -1
  471. package/dist/dashboard/web/skin-intro.d.ts +0 -4
  472. package/dist/dashboard/web/skin-intro.d.ts.map +0 -1
  473. package/dist/dashboard/web/skin-intro.js +0 -49
  474. package/dist/dashboard/web/skin-intro.js.map +0 -1
  475. package/dist/dashboard/web/v3.d.ts +0 -3
  476. package/dist/dashboard/web/v3.d.ts.map +0 -1
  477. package/dist/dashboard/web/v3.js +0 -539
  478. package/dist/dashboard/web/v3.js.map +0 -1
  479. package/dist/dashboard/web/whiteboards.d.ts +0 -2
  480. package/dist/dashboard/web/whiteboards.d.ts.map +0 -1
  481. package/dist/dashboard/web/whiteboards.js +0 -2
  482. package/dist/dashboard/web/whiteboards.js.map +0 -1
  483. package/dist/dashboard-web/chunks/bot-defaults-page-OCIHPGEU.js +0 -438
  484. package/dist/dashboard-web/chunks/chunk-3N2PPQVD.js +0 -1
  485. package/dist/dashboard-web/chunks/chunk-3UJYRR2Q.js +0 -37
  486. package/dist/dashboard-web/chunks/chunk-5E3MJJL4.js +0 -1
  487. package/dist/dashboard-web/chunks/chunk-7SZRL6FY.js +0 -1
  488. package/dist/dashboard-web/chunks/chunk-JTSE5VP6.js +0 -54
  489. package/dist/dashboard-web/chunks/chunk-LXIK7OUJ.js +0 -1
  490. package/dist/dashboard-web/chunks/chunk-PE6W3C2C.js +0 -1
  491. package/dist/dashboard-web/chunks/chunk-QW54DWU3.js +0 -325
  492. package/dist/dashboard-web/chunks/connectors-IGETYWCP.js +0 -1
  493. package/dist/dashboard-web/chunks/groups-page-MYBS4ZYJ.js +0 -207
  494. package/dist/dashboard-web/chunks/insights-page-UPA32V2S.js +0 -309
  495. package/dist/dashboard-web/chunks/monitoring-page-7T3BESZ2.js +0 -1
  496. package/dist/dashboard-web/chunks/office-page-6DXYOG55.js +0 -1
  497. package/dist/dashboard-web/chunks/overview-page-LJSBCQW4.js +0 -1
  498. package/dist/dashboard-web/chunks/roles-page-ESURTVQH.js +0 -98
  499. package/dist/dashboard-web/chunks/schedules-LML2ISOT.js +0 -1
  500. package/dist/dashboard-web/chunks/sessions-YB73XOTU.js +0 -1
  501. package/dist/dashboard-web/chunks/sessions-page-MCO5QR7Z.js +0 -1
  502. package/dist/dashboard-web/chunks/settings-page-OSDKNVS3.js +0 -2
  503. package/dist/dashboard-web/chunks/skills-page-PXBZJ6P6.js +0 -2
  504. package/dist/dashboard-web/chunks/team-federation-page-TTPVHFPX.js +0 -10
  505. package/dist/dashboard-web/chunks/v3-page-FVPUL665.js +0 -149
  506. package/dist/dashboard-web/chunks/whiteboards-page-HLCTYSSV.js +0 -1
  507. package/dist/dashboard-web/chunks/workflows-FCPM4S4D.js +0 -184
  508. package/dist/dashboard-web/skins/bluearchive-hero.webp +0 -0
  509. package/dist/dashboard-web/skins/dragonball-goku.webp +0 -0
  510. package/dist/dashboard-web/skins/dragonball-wukong.webp +0 -0
  511. package/dist/dashboard-web/skins/genshin-breeze.webp +0 -0
  512. package/dist/dashboard-web/skins/ikun-hero.webp +0 -0
  513. package/dist/dashboard-web/skins/prts-priestess.webp +0 -0
  514. package/dist/dashboard-web/skins/zzz-hero.webp +0 -0
  515. package/dist/dashboard-web/skins/zzz-pattern.svg +0 -25
@@ -1,10 +1,54 @@
1
- import { botDisplayName, escapeHtml, loadNameMaps, relTime, t } from './ui.js';
1
+ // Insight dashboard model/helpers. This file intentionally contains no page
2
+ // controller code; React owns rendering and interactions in insights-page.tsx.
2
3
  import MarkdownIt from 'markdown-it';
3
- const SEVERITY_RANK = { bad: 0, warn: 1, info: 2 };
4
- function fmtInt(n) {
4
+ import { botDisplayName, escapeHtml, relTime, t } from './ui.js';
5
+ export const SEVERITY_RANK = { bad: 0, warn: 1, info: 2 };
6
+ export const INSIGHT_PHASES = ['research', 'edit', 'run', 'delegate', 'discuss'];
7
+ export const FLOW_PHASES = ['research', 'edit', 'run', 'delegate', 'discuss'];
8
+ export const INSIGHT_TABS = [
9
+ { key: 'overview', label: 'insights.tabOverview' },
10
+ { key: 'sessions', label: 'insights.tabSessions' },
11
+ { key: 'flow', label: 'insights.tabFlow' },
12
+ { key: 'dist', label: 'insights.tabDist' },
13
+ { key: 'hot', label: 'insights.tabHot' },
14
+ ];
15
+ export const TIME_WINDOWS = [
16
+ { key: 'all', label: 'insights.timeAll', days: 0 },
17
+ { key: '1d', label: 'insights.time1d', days: 1 },
18
+ { key: '7d', label: 'insights.time7d', days: 7 },
19
+ { key: '30d', label: 'insights.time30d', days: 30 },
20
+ ];
21
+ export const INSIGHT_FILTERS = ['all', 'review', 'failed', 'slow'];
22
+ export const INSIGHT_TAB_KEYS = ['overview', 'sessions', 'flow', 'dist', 'hot'];
23
+ export const SESS_SORTS = [
24
+ { key: 'recent', label: 'insights.sortRecent' },
25
+ { key: 'review', label: 'insights.sortReview' },
26
+ { key: 'spans', label: 'insights.sortSpans' },
27
+ { key: 'fails', label: 'insights.sortFails' },
28
+ { key: 'slow', label: 'insights.sortSlow' },
29
+ { key: 'agent', label: 'insights.sortAgent' },
30
+ ];
31
+ export const SESS_SORT_KEYS = ['recent', 'review', 'spans', 'fails', 'slow', 'agent'];
32
+ export const SPAN_TAGS = ['failure', 'slow', 'retry', 'read_write_imbalance'];
33
+ export const CONVO_ROLES = [
34
+ { key: 'all', label: 'spanAll' },
35
+ { key: 'user', label: 'senderHuman' },
36
+ { key: 'a2a_agent', label: 'senderA2A' },
37
+ { key: 'system', label: 'senderSystem' },
38
+ ];
39
+ export const CONVO_TAGS = [
40
+ { key: 'all', label: 'spanAll' },
41
+ { key: 'failure', label: 'tag.failure' },
42
+ { key: 'slow', label: 'tag.slow' },
43
+ ];
44
+ export const BAD_RESULTS = new Set(['tool_error', 'test_failed', 'typecheck_failed', 'lint_failed', 'command_failed', 'timeout', 'no_output']);
45
+ export function newConvoState() {
46
+ return { messages: [], total: 0, hasMore: false, nextOffset: 0, loading: false, q: '', role: 'all', tag: 'all', openOps: new Set() };
47
+ }
48
+ export function fmtInt(n) {
5
49
  return Number.isFinite(n) ? Math.round(n).toLocaleString('en-US') : '-';
6
50
  }
7
- function fmtMs(ms) {
51
+ export function fmtMs(ms) {
8
52
  if (ms === undefined || ms === null || !Number.isFinite(ms))
9
53
  return '-';
10
54
  if (ms < 1000)
@@ -13,14 +57,14 @@ function fmtMs(ms) {
13
57
  return `${(ms / 1000).toFixed(1)}s`;
14
58
  return `${Math.round(ms / 60_000)}m`;
15
59
  }
16
- function statusIcon(status) {
60
+ export function statusIcon(status) {
17
61
  if (status === 'error')
18
62
  return '!';
19
63
  if (status === 'running')
20
64
  return '~';
21
65
  return 'OK';
22
66
  }
23
- function safeStatus(report, error) {
67
+ export function safeStatus(report, error) {
24
68
  if (error)
25
69
  return error;
26
70
  if (!report)
@@ -33,28 +77,24 @@ function safeStatus(report, error) {
33
77
  return t('insights.parseError');
34
78
  return report.status;
35
79
  }
36
- function sessionTitle(s) {
37
- // Strip the leading @mention(s), then fall back to the id when nothing
38
- // readable remains — e.g. a title of just "@Gpt" or a blank/whitespace one.
39
- // (stripMentionPrefix returns the raw string when stripping empties it, so we
40
- // inline the strip here to detect the truly-empty case and fall back.)
80
+ export function sessionTitle(s) {
41
81
  const stripped = String(s.title ?? '').replace(/^(?:@\S+\s*)+/, '').trim();
42
82
  return stripped || String(s.sessionId ?? '');
43
83
  }
44
- function severityLabel(sev) {
84
+ export function severityLabel(sev) {
45
85
  return sev === 'bad' ? t('insights.sevBad') : sev === 'warn' ? t('insights.sevWarn') : t('insights.sevInfo');
46
86
  }
47
- function translatedOrFallback(key, fallback) {
87
+ export function translatedOrFallback(key, fallback) {
48
88
  const out = t(key);
49
89
  return out === key ? fallback : out;
50
90
  }
51
- function suggestionTitle(s) {
91
+ export function suggestionTitle(s) {
52
92
  return translatedOrFallback(`insights.suggestion.${s.id}.title`, s.title);
53
93
  }
54
- function suggestionAction(s) {
94
+ export function suggestionAction(s) {
55
95
  return translatedOrFallback(`insights.suggestion.${s.id}.action`, s.action);
56
96
  }
57
- function localizeEvidence(text) {
97
+ export function localizeEvidence(text) {
58
98
  let m = text.match(/^(\d+) failed spans$/);
59
99
  if (m)
60
100
  return t('insights.evidence.failedSpans', { count: m[1] });
@@ -77,10 +117,7 @@ function localizeEvidence(text) {
77
117
  return t('insights.evidence.spansAnalyzed', { count: m[1] });
78
118
  return text;
79
119
  }
80
- // Diagnostic reason is backend free-text (safe-projected: numbers/tools/enums). Reuse the
81
- // evidence localizer on each ';'-separated clause so the zh UI doesn't surface English here;
82
- // unmatched clauses fall through unchanged.
83
- function localizeReason(reason) {
120
+ export function localizeReason(reason) {
84
121
  return reason
85
122
  .split(/\s*[;;]\s*/)
86
123
  .map(c => c.trim().replace(/[.。]\s*$/, ''))
@@ -88,25 +125,23 @@ function localizeReason(reason) {
88
125
  .map(localizeEvidence)
89
126
  .join(';');
90
127
  }
91
- function phaseLabel(phase) {
128
+ export function phaseLabel(phase) {
92
129
  const key = `insights.phase.${phase}`;
93
130
  const out = t(key);
94
131
  return out === key ? phase : out;
95
132
  }
96
- function phaseSlug(phase) {
133
+ export function phaseSlug(phase) {
97
134
  return String(phase || 'unknown').replace(/[^a-z0-9_-]/gi, '-');
98
135
  }
99
- function phaseClass(phase) {
136
+ export function phaseClass(phase) {
100
137
  return `phase-${phaseSlug(phase)}`;
101
138
  }
102
- function reportNeedsReview(report) {
139
+ export function reportNeedsReview(report) {
103
140
  if (!report || report.status !== 'ok')
104
141
  return false;
105
142
  return report.agg.failedSpans > 0 || report.agg.slowSpans > 0 || report.suggestions.some(x => x.severity !== 'info');
106
143
  }
107
- // Project a server overview-session row into the list's record shape so the
108
- // existing filter/sort/render helpers keep working unchanged.
109
- function toRecord(s) {
144
+ export function toRecord(s) {
110
145
  return {
111
146
  session: {
112
147
  sessionId: s.sessionId,
@@ -122,7 +157,38 @@ function toRecord(s) {
122
157
  report: s.report,
123
158
  };
124
159
  }
125
- function filterRecords(records, filter, q, cliSel = new Set(), scope = {}) {
160
+ export function cliIdOf(rec) {
161
+ return String(rec.session.cliId ?? 'unknown');
162
+ }
163
+ const CLI_FILTER_ORDER = ['claude-code', 'seed', 'relay', 'aiden', 'codex', 'traex', 'antigravity'];
164
+ export function cliCounts(records) {
165
+ const m = new Map();
166
+ for (const rec of records) {
167
+ const id = cliIdOf(rec);
168
+ m.set(id, (m.get(id) ?? 0) + 1);
169
+ }
170
+ return [...m.entries()].map(([id, count]) => ({ id, count })).sort((a, b) => {
171
+ const ai = CLI_FILTER_ORDER.indexOf(a.id);
172
+ const bi = CLI_FILTER_ORDER.indexOf(b.id);
173
+ return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi) || a.id.localeCompare(b.id);
174
+ });
175
+ }
176
+ export function projectOf(rec) {
177
+ const wd = String(rec.session.workingDir ?? '').replace(/\/+$/, '');
178
+ if (!wd)
179
+ return '';
180
+ return wd.split('/').pop() || wd;
181
+ }
182
+ export function projectOptions(records) {
183
+ const m = new Map();
184
+ for (const rec of records) {
185
+ const p = projectOf(rec);
186
+ if (p)
187
+ m.set(p, (m.get(p) ?? 0) + 1);
188
+ }
189
+ return [...m.entries()].map(([id, count]) => ({ id, count })).sort((a, b) => b.count - a.count || a.id.localeCompare(b.id));
190
+ }
191
+ export function filterRecords(records, filter, q, cliSel = new Set(), scope = {}) {
126
192
  const query = q.trim().toLowerCase();
127
193
  return records.filter(rec => {
128
194
  const s = rec.session;
@@ -146,46 +212,7 @@ function filterRecords(records, filter, q, cliSel = new Set(), scope = {}) {
146
212
  return `${sessionTitle(s)} ${botDisplayName(s)} ${s.cliId ?? ''} ${s.workingDir ?? ''} ${s.sessionId ?? ''}`.toLowerCase().includes(query);
147
213
  });
148
214
  }
149
- function cliIdOf(rec) {
150
- return String(rec.session.cliId ?? 'unknown');
151
- }
152
- // Session-dimension CLI facet. Supported CLIs lead in a fixed order so the chip
153
- // row doesn't reshuffle as sessions come and go; unknown CLIs trail alphabetically.
154
- const CLI_FILTER_ORDER = ['claude-code', 'seed', 'relay', 'aiden', 'codex', 'traex', 'antigravity'];
155
- function cliCounts(records) {
156
- const m = new Map();
157
- for (const rec of records) {
158
- const id = cliIdOf(rec);
159
- m.set(id, (m.get(id) ?? 0) + 1);
160
- }
161
- return [...m.entries()].map(([id, count]) => ({ id, count })).sort((a, b) => {
162
- const ai = CLI_FILTER_ORDER.indexOf(a.id);
163
- const bi = CLI_FILTER_ORDER.indexOf(b.id);
164
- return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi) || a.id.localeCompare(b.id);
165
- });
166
- }
167
- // CLI facet chip row for the session list. Multi-select: an empty selection means
168
- // "all". Counts reflect the severity+search filtered set (the CLI picks are NOT
169
- // applied), so the per-CLI distribution stays visible while you drill in. Hidden
170
- // when only one CLI is present.
171
- function renderCliChips(records, active) {
172
- const counts = cliCounts(records);
173
- if (counts.length <= 1)
174
- return '';
175
- const chip = (key, label, n, on) => `<button type="button" class="spanchip${on ? ' on' : ''}" data-clifilter="${escapeHtml(key)}">${escapeHtml(label)} <b>${n}</b></button>`;
176
- return [chip('all', t('common.all'), records.length, active.size === 0),
177
- ...counts.map(c => chip(c.id, c.id, c.count, active.has(c.id)))].join('');
178
- }
179
- function renderMetric(label, value, sub = '') {
180
- return `<div class="card"><div class="cv">${escapeHtml(value)}</div><div class="cl">${escapeHtml(label)}</div>${sub ? `<div class="cs">${escapeHtml(sub)}</div>` : ''}</div>`;
181
- }
182
- const INSIGHT_PHASES = ['research', 'edit', 'run', 'delegate', 'discuss'];
183
- // The overview reflects the CURRENT filter/search: aggregate the visible records
184
- // client-side so the metric cards, top-failed-tools and recommendations all move
185
- // when you filter. Otherwise only the session list narrows while the prominent
186
- // numbers stay frozen, and filtering feels dead. Non-ok
187
- // reports contribute nothing, so the unfiltered view ≈ the server aggregate.
188
- function aggregateRecords(records) {
215
+ export function aggregateRecords(records) {
189
216
  const agg = {
190
217
  totalSpans: 0,
191
218
  failedSpans: 0,
@@ -232,9 +259,6 @@ function aggregateRecords(records) {
232
259
  suggMap.set(s.id, { id: s.id, title: s.title, severity: s.severity, count: 1, evidence: s.evidence ?? [], action: s.action });
233
260
  }
234
261
  }
235
- // Pooled read:write (Σreads / Σwrites), consistent with the pooled totals on the
236
- // same metric row (spans/failed/slow). A mean of per-session ratios would let a
237
- // tiny 2-read/1-write session skew the headline as much as a 300/100 one.
238
262
  agg.readWriteRatio = agg.phase.edit.count > 0
239
263
  ? Number((agg.phase.research.count / agg.phase.edit.count).toFixed(2))
240
264
  : null;
@@ -244,418 +268,23 @@ function aggregateRecords(records) {
244
268
  .slice(0, 5);
245
269
  return { totalCount: records.length, analyzedCount: analyzed, agg, topFailedTools, suggestions: [...suggMap.values()] };
246
270
  }
247
- // Overview metrics + recommendations for the CURRENTLY VISIBLE (filtered) records,
248
- // so the summary tracks the active filter rather than staying global.
249
- // Cross-tool measurement caveat (Codex reads via shell aren't counted as 'read', etc.) —
250
- // shown under the overview & distribution so ratios/distributions aren't misread.
251
- function disclosureNote() {
252
- return `<p class="insight-disclosure">⚖︎ ${escapeHtml(t('insights.disclosure'))}</p>`;
253
- }
254
- function renderOverview(d) {
255
- const a = d.agg;
256
- const rw = a.readWriteRatio === null ? '-' : a.readWriteRatio.toFixed(1);
257
- const topTools = d.topFailedTools.slice(0, 5);
258
- const topSuggestions = [...d.suggestions]
259
- .sort((x, y) => SEVERITY_RANK[x.severity] - SEVERITY_RANK[y.severity] || y.count - x.count)
260
- .slice(0, 6);
261
- return `
262
- <div class="cards insights-metrics">
263
- ${renderMetric(t('insights.metricSessions'), fmtInt(d.totalCount), t('insights.metricAnalyzed', { count: d.analyzedCount }))}
264
- ${renderMetric(t('insights.metricSpans'), fmtInt(a.totalSpans), t('insights.metricSafe'))}
265
- ${renderMetric(t('insights.metricFailed'), fmtInt(a.failedSpans), topTools[0] ? `${topTools[0].tool} ×${topTools[0].count}` : '')}
266
- ${renderMetric(t('insights.metricSlow'), fmtInt(a.slowSpans))}
267
- ${renderMetric(t('insights.metricRw'), rw, t('insights.metricCompactions', { count: a.compactions }))}
268
- </div>
269
- <div class="insights-overview-grid">
270
- <section class="block recblock">
271
- <h3>${escapeHtml(t('insights.recommendations'))}</h3>
272
- <div class="reclist">
273
- ${topSuggestions.length ? topSuggestions.map(item => `
274
- <div class="rec ${item.severity}">
275
- <div class="rectop"><b>${escapeHtml(suggestionTitle(item))}</b><span>${escapeHtml(severityLabel(item.severity))}</span></div>
276
- <div class="recev">${escapeHtml(t('insights.seenInSessions', { count: item.count }))}</div>
277
- </div>`).join('') : `<p class="mut">${escapeHtml(t('insights.noRecommendations'))}</p>`}
278
- </div>
279
- </section>
280
- <section class="block">
281
- <h3>${escapeHtml(t('insights.toolFailures'))}</h3>
282
- <div class="hbars">
283
- ${topTools.length ? topTools.map(tt => {
284
- const pct = Math.max(4, Math.round((tt.count / Math.max(1, topTools[0].count)) * 100));
285
- return `<div class="hbrow"><div class="hblabel">${escapeHtml(tt.tool)}</div><div class="hbtrack"><div class="hbfill" style="width:${pct}%"></div></div><div class="hbval">${fmtInt(tt.count)}</div></div>`;
286
- }).join('') : `<p class="mut">${escapeHtml(t('insights.noFailures'))}</p>`}
287
- </div>
288
- </section>
289
- </div>
290
- ${disclosureNote()}`;
291
- }
292
- function phaseMixBar(phase) {
293
- const entries = Object.entries(phase ?? {}).filter(([, v]) => v.count > 0 || v.ms > 0);
294
- if (!entries.length)
295
- return '';
296
- return `<div class="mph">${entries.map(([ph, v]) => {
297
- const weight = Math.max(1, v.ms || v.count);
298
- return `<i class="${phaseClass(ph)}" style="flex:${weight}" title="${escapeHtml(`${phaseLabel(ph)} · ${v.count} · ${fmtMs(v.ms)}`)}"></i>`;
299
- }).join('')}</div>`;
300
- }
301
- function renderPhaseMix(report) {
302
- return phaseMixBar(report.agg.phase);
303
- }
304
- // Delegated sub-agents (Claude Task/Agent) as swim-lanes: each shows its type,
305
- // task, what it did (phase mix) and how long it ran. Renders nothing when none.
306
- function renderSubagents(report) {
307
- const lanes = report.subagents ?? [];
308
- if (!lanes.length)
309
- return '';
310
- const totMs = lanes.reduce((s, l) => s + l.durationMs, 0);
311
- const rows = lanes.map(l => `<div class="sublane${l.failures ? ' bad' : ''}">
312
- <div class="sublane-head">
313
- <span class="sublane-type">${escapeHtml(l.agentType)}</span>
314
- <span class="sublane-task">${escapeHtml(l.task?.text ?? '')}${l.task?.truncated ? '…' : ''}</span>
315
- </div>
316
- ${phaseMixBar(l.phase)}
317
- <div class="sublane-stats">
318
- <span>${fmtInt(l.spans)} ${escapeHtml(t('insights.spansShort'))}</span>
319
- <span>${escapeHtml(fmtMs(l.durationMs))}</span>
320
- ${l.failures ? `<span class="bad">${fmtInt(l.failures)} ${escapeHtml(t('insights.failedShort'))}</span>` : ''}
321
- </div>
322
- </div>`).join('');
323
- return `<section class="block subagent-block">
324
- <h3>${escapeHtml(t('insights.subagents'))} <span class="mut">· ${lanes.length} · ${escapeHtml(fmtMs(totMs))}</span></h3>
325
- <p class="mut ins-hint">${escapeHtml(t('insights.subagentsHint'))}</p>
326
- <div class="sublanes">${rows}</div>
327
- </section>`;
328
- }
329
- const INSIGHT_TABS = [
330
- { key: 'overview', label: 'insights.tabOverview' },
331
- { key: 'sessions', label: 'insights.tabSessions' },
332
- { key: 'flow', label: 'insights.tabFlow' },
333
- { key: 'dist', label: 'insights.tabDist' },
334
- { key: 'hot', label: 'insights.tabHot' },
335
- ];
336
- export function renderTabBar(active) {
337
- return `<div class="insight-tabs" role="tablist">${INSIGHT_TABS.map(tb => `<button type="button" class="itab${tb.key === active ? ' on' : ''}" data-itab="${tb.key}" role="tab" aria-selected="${tb.key === active}">${escapeHtml(t(tb.label))}</button>`).join('')}</div>`;
338
- }
339
- function projectOf(rec) {
340
- const wd = String(rec.session.workingDir ?? '').replace(/\/+$/, '');
341
- if (!wd)
342
- return '';
343
- return wd.split('/').pop() || wd;
344
- }
345
- function projectOptions(records) {
346
- const m = new Map();
347
- for (const rec of records) {
348
- const p = projectOf(rec);
349
- if (p)
350
- m.set(p, (m.get(p) ?? 0) + 1);
351
- }
352
- return [...m.entries()].map(([id, count]) => ({ id, count })).sort((a, b) => b.count - a.count || a.id.localeCompare(b.id));
353
- }
354
- export const TIME_WINDOWS = [
355
- { key: 'all', label: 'insights.timeAll', days: 0 },
356
- { key: '1d', label: 'insights.time1d', days: 1 },
357
- { key: '7d', label: 'insights.time7d', days: 7 },
358
- { key: '30d', label: 'insights.time30d', days: 30 },
359
- ];
360
- function agentMsOf(r) {
271
+ export function agentMsOf(r) {
361
272
  return INSIGHT_PHASES.reduce((sum, ph) => sum + (r.agg.phase?.[ph]?.ms ?? 0), 0);
362
273
  }
363
- function okReports(records) {
274
+ export function okReports(records) {
364
275
  return records.map(r => r.report).filter((r) => !!r && r.status === 'ok');
365
276
  }
366
- function median(values) {
277
+ export function median(values) {
367
278
  if (!values.length)
368
279
  return 0;
369
280
  const s = [...values].sort((a, b) => a - b);
370
281
  const mid = Math.floor(s.length / 2);
371
282
  return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
372
283
  }
373
- // One histogram card. bins is an ordered list of {label, test}; each session value
374
- // falls into the first matching bin. Reuses the .hbars bar styling.
375
- function renderHistogram(title, values, bins, fmtMedian = fmtInt, sortKey) {
376
- const counts = bins.map(b => ({ label: b.label, count: values.filter(b.test).length }));
377
- const max = Math.max(1, ...counts.map(c => c.count));
378
- const total = Math.max(1, values.length);
379
- const rows = counts.map(c => {
380
- const pct = Math.max(c.count ? 4 : 0, Math.round((c.count / max) * 100));
381
- const share = Math.round((c.count / total) * 100);
382
- return `<div class="hbrow"><div class="hblabel">${escapeHtml(c.label)}</div><div class="hbtrack"><div class="hbfill" style="width:${pct}%"></div></div><div class="hbval">${fmtInt(c.count)}<small>${share}%</small></div></div>`;
383
- }).join('');
384
- const jump = sortKey ? `<button type="button" class="ihist-jump" data-distsort="${sortKey}">${escapeHtml(t('insights.viewSessions'))} ›</button>` : '';
385
- return `<section class="block ihist">
386
- <div class="ihist-head"><h3>${escapeHtml(title)}</h3><span class="mut">${escapeHtml(t('insights.distMedian', { v: fmtMedian(median(values)) }))}</span>${jump}</div>
387
- <div class="hbars">${rows}</div>
388
- </section>`;
389
- }
390
- // Sessions-per-day over the last 4 weeks (from each record's lastMessageAt) — a quick
391
- // "how busy lately" trend. Pure client-side, no new engine data.
392
- function renderTrend(records) {
393
- const DAYS = 28;
394
- const dayMs = 86400000;
395
- const now = Date.now();
396
- const counts = new Array(DAYS).fill(0);
397
- for (const rec of records) {
398
- const ts = Number(rec.session.lastMessageAt ?? 0);
399
- if (!ts)
400
- continue;
401
- const age = Math.floor((now - ts) / dayMs);
402
- if (age >= 0 && age < DAYS)
403
- counts[DAYS - 1 - age]++;
404
- }
405
- const total = counts.reduce((a, b) => a + b, 0);
406
- if (!total)
407
- return '';
408
- const max = Math.max(1, ...counts);
409
- // Native title tooltip (date · count). The old data-tip was never wired to a
410
- // tooltip host outside the detail body, so it surfaced nothing on hover.
411
- const bars = counts.map((c, j) => {
412
- const d = new Date(now - (DAYS - 1 - j) * dayMs);
413
- const label = `${d.getMonth() + 1}-${d.getDate()} · ${c}`;
414
- return `<i class="trendbar" style="height:${c ? Math.max(8, Math.round((c / max) * 100)) : 2}%" title="${escapeHtml(label)}"></i>`;
415
- }).join('');
416
- return `<section class="block trend-block">
417
- <div class="ihist-head"><h3>${escapeHtml(t('insights.distTrend'))}</h3><span class="mut">${escapeHtml(t('insights.distTrendSub'))} · ${total}</span></div>
418
- <div class="trend">${bars}</div>
419
- </section>`;
420
- }
421
- function renderDistribution(records) {
422
- const reports = okReports(records);
423
- if (!reports.length)
424
- return `<div class="insight-empty">${escapeHtml(t('insights.distEmpty'))}</div>`;
425
- const churn = reports.map(r => (r.hot?.files ?? []).reduce((s, f) => s + (f.added ?? 0) + (f.removed ?? 0), 0));
426
- const spans = reports.map(r => r.agg.totalSpans);
427
- const failed = reports.map(r => r.agg.failedSpans);
428
- const slow = reports.map(r => r.agg.slowSpans);
429
- const agentMin = reports.map(r => agentMsOf(r) / 60000);
430
- const rw = reports.map(r => r.agg.readWriteRatio).filter((v) => v !== null && Number.isFinite(v));
431
- return `<p class="mut ins-hint">${escapeHtml(t('insights.distHint'))}</p>
432
- <div class="ihist-grid">
433
- ${renderTrend(records)}
434
- ${renderHistogram(t('insights.distSpans'), spans, [
435
- { label: '0–10', test: v => v <= 10 },
436
- { label: '11–50', test: v => v > 10 && v <= 50 },
437
- { label: '51–200', test: v => v > 50 && v <= 200 },
438
- { label: '201–500', test: v => v > 200 && v <= 500 },
439
- { label: '500+', test: v => v > 500 },
440
- ], fmtInt, 'spans')}
441
- ${renderHistogram(t('insights.distFailed'), failed, [
442
- { label: '0', test: v => v === 0 },
443
- { label: '1–2', test: v => v >= 1 && v <= 2 },
444
- { label: '3–5', test: v => v >= 3 && v <= 5 },
445
- { label: '6–10', test: v => v >= 6 && v <= 10 },
446
- { label: '10+', test: v => v > 10 },
447
- ], fmtInt, 'fails')}
448
- ${renderHistogram(t('insights.distSlow'), slow, [
449
- { label: '0', test: v => v === 0 },
450
- { label: '1–2', test: v => v >= 1 && v <= 2 },
451
- { label: '3–5', test: v => v >= 3 && v <= 5 },
452
- { label: '5+', test: v => v > 5 },
453
- ], fmtInt, 'slow')}
454
- ${renderHistogram(t('insights.distAgentTime'), agentMin, [
455
- { label: '<1m', test: v => v < 1 },
456
- { label: '1–5m', test: v => v >= 1 && v < 5 },
457
- { label: '5–30m', test: v => v >= 5 && v < 30 },
458
- { label: '30m–2h', test: v => v >= 30 && v < 120 },
459
- { label: '2h+', test: v => v >= 120 },
460
- ], n => `${Math.round(n)}m`, 'agent')}
461
- ${rw.length ? renderHistogram(t('insights.distRw'), rw, [
462
- { label: '0', test: v => v === 0 },
463
- { label: '0–1', test: v => v > 0 && v < 1 },
464
- { label: '1–3', test: v => v >= 1 && v < 3 },
465
- { label: '3+', test: v => v >= 3 },
466
- ], n => n.toFixed(1)) : ''}
467
- ${renderHistogram(t('insights.distChurn'), churn, [
468
- { label: '0', test: v => v === 0 },
469
- { label: '1–100', test: v => v > 0 && v <= 100 },
470
- { label: '100–1k', test: v => v > 100 && v <= 1000 },
471
- { label: '1k–10k', test: v => v > 1000 && v <= 10000 },
472
- { label: '10k+', test: v => v > 10000 },
473
- ])}
474
- </div>
475
- ${disclosureNote()}`;
476
- }
477
- const FLOW_PHASES = ['research', 'edit', 'run', 'delegate', 'discuss'];
478
- // "行为流": where the agent's actions & wall-time land across activity phases,
479
- // plus each session's phase rhythm. Built from agg.phase (present in summary mode),
480
- // so it works at the overview level without the (detail-only) turn timeline.
481
- function renderFlow(records) {
482
- const ok = records.filter(r => r.report && r.report.status === 'ok' && r.report.agg);
483
- if (!ok.length)
484
- return `<div class="insight-empty">${escapeHtml(t('insights.distEmpty'))}</div>`;
485
- const tot = {};
486
- for (const p of FLOW_PHASES)
487
- tot[p] = { count: 0, ms: 0 };
488
- for (const rec of ok)
489
- for (const p of FLOW_PHASES) {
490
- const v = rec.report.agg.phase?.[p];
491
- if (v) {
492
- tot[p].count += v.count;
493
- tot[p].ms += v.ms;
494
- }
495
- }
496
- const totCount = FLOW_PHASES.reduce((s, p) => s + tot[p].count, 0) || 1;
497
- const totMs = FLOW_PHASES.reduce((s, p) => s + tot[p].ms, 0) || 1;
498
- const nodes = FLOW_PHASES.map((p, i) => {
499
- const v = tot[p];
500
- const cPct = Math.round((v.count / totCount) * 100);
501
- const tPct = Math.round((v.ms / totMs) * 100);
502
- const arrow = i < FLOW_PHASES.length - 1 ? '<span class="flow-arrow">→</span>' : '';
503
- return `<div class="flow-node"><i class="flow-dot ${phaseClass(p)}"></i>
504
- <strong>${escapeHtml(phaseLabel(p))}</strong>
505
- <span class="flow-n">${fmtInt(v.count)}<em> · ${cPct}%</em></span>
506
- <span class="flow-t">${escapeHtml(fmtMs(v.ms))}<em> · ${tPct}%</em></span></div>${arrow}`;
507
- }).join('');
508
- const bars = FLOW_PHASES.map(p => {
509
- const v = tot[p];
510
- const cPct = (v.count / totCount) * 100;
511
- const tPct = (v.ms / totMs) * 100;
512
- return `<div class="flow-brow">
513
- <span class="flow-blabel"><i class="${phaseClass(p)}"></i>${escapeHtml(phaseLabel(p))}</span>
514
- <span class="flow-btrack"><span class="flow-bfill ${phaseClass(p)}" style="width:${Math.max(2, cPct).toFixed(1)}%"></span><em>${Math.round(cPct)}%</em></span>
515
- <span class="flow-btrack"><span class="flow-bfill ${phaseClass(p)}" style="width:${Math.max(2, tPct).toFixed(1)}%"></span><em>${Math.round(tPct)}%</em></span>
516
- </div>`;
517
- }).join('');
518
- const rhythm = [...ok]
519
- .sort((a, b) => agentMsOf(b.report) - agentMsOf(a.report))
520
- .slice(0, 40)
521
- .map(rec => `<button type="button" class="flow-sess" data-session-id="${escapeHtml(String(rec.session.sessionId))}">
522
- <span class="flow-stitle">${escapeHtml(sessionTitle(rec.session))}</span>
523
- ${renderPhaseMix(rec.report)}
524
- <span class="flow-stime">${escapeHtml(fmtMs(agentMsOf(rec.report)))}</span></button>`)
525
- .join('');
526
- return `<p class="mut ins-hint">${escapeHtml(t('insights.flowHint'))}</p>
527
- <section class="block flow-pipe-block">
528
- <h3>${escapeHtml(t('insights.flowPipeline'))}</h3>
529
- <p class="mut flow-sub">${escapeHtml(t('insights.flowPipeSub'))}</p>
530
- <div class="flow-pipe">${nodes}</div>
531
- </section>
532
- <div class="insights-overview-grid">
533
- <section class="block">
534
- <h3>${escapeHtml(t('insights.flowShares'))}</h3>
535
- <div class="flow-bhead"><span></span><span>${escapeHtml(t('insights.flowActShare'))}</span><span>${escapeHtml(t('insights.flowTimeShare'))}</span></div>
536
- <div class="flow-bars">${bars}</div>
537
- </section>
538
- <section class="block">
539
- <h3>${escapeHtml(t('insights.flowRhythm'))}</h3>
540
- <div class="flow-rhythm">${rhythm}</div>
541
- <div class="rl-legend">${FLOW_PHASES.map(p => `<span class="rl-item"><i class="${phaseClass(p)}"></i>${escapeHtml(phaseLabel(p))}</span>`).join('')}</div>
542
- </section>
543
- </div>
544
- ${disclosureNote()}`;
545
- }
546
- function renderHotspots(records, openHot) {
547
- const reports = records.filter(r => !!r.report && r.report.status === 'ok');
548
- if (!reports.length)
549
- return `<div class="insight-empty">${escapeHtml(t('insights.distEmpty'))}</div>`;
550
- // Cross-session recurrence from each session's compact report.hot, tracking which
551
- // sessions contributed so each row can drill down to them.
552
- const fileAgg = new Map();
553
- const cmdAgg = new Map();
554
- const errAgg = new Map();
555
- const bump = (m, key, label, sid, title) => {
556
- let h = m.get(key);
557
- if (!h) {
558
- h = { key, label, sessions: [], reads: 0, edits: 0, runs: 0, fails: 0, count: 0 };
559
- m.set(key, h);
560
- }
561
- if (!h.sessions.some(s => s.id === sid))
562
- h.sessions.push({ id: sid, title });
563
- return h;
564
- };
565
- for (const rec of reports) {
566
- const sid = String(rec.session.sessionId);
567
- const title = sessionTitle(rec.session);
568
- const hot = rec.report.hot;
569
- for (const f of hot?.files ?? []) {
570
- const h = bump(fileAgg, `file:${f.path}`, f.path, sid, title);
571
- h.reads += f.reads;
572
- h.edits += f.edits;
573
- }
574
- for (const c of hot?.cmds ?? []) {
575
- const h = bump(cmdAgg, `cmd:${c.cmd}`, c.cmd, sid, title);
576
- h.runs += c.runs;
577
- h.fails += c.fails;
578
- }
579
- for (const e of hot?.errs ?? []) {
580
- const h = bump(errAgg, `err:${e.tool} ${e.result}`, `${e.tool} · ${resultLabel(e.result)}`, sid, title);
581
- h.count += e.count;
582
- }
583
- }
584
- const recur = (a, b) => b.sessions.length - a.sessions.length;
585
- const files = [...fileAgg.values()].sort((a, b) => recur(a, b) || (b.edits + b.reads) - (a.edits + a.reads)).slice(0, 12);
586
- const cmds = [...cmdAgg.values()].sort((a, b) => recur(a, b) || b.fails - a.fails || b.runs - a.runs).slice(0, 12);
587
- const errs = [...errAgg.values()].sort((a, b) => recur(a, b) || b.count - a.count).slice(0, 10);
588
- const block = (title, rows, meta) => `
589
- <section class="block">
590
- <h3>${escapeHtml(title)}</h3>
591
- <div class="hotlist">${rows.length ? rows.map(h => {
592
- const open = openHot.has(h.key);
593
- const sess = open ? `<div class="hot-sess">${h.sessions.map(s => `<button type="button" class="hot-sesslink" data-session-id="${escapeHtml(s.id)}">${escapeHtml(s.title || s.id)}</button>`).join('')}</div>` : '';
594
- return `<div class="hotitem${open ? ' open' : ''}">
595
- <button type="button" class="hotrow" data-hotkey="${escapeHtml(h.key)}" aria-expanded="${open}">
596
- <span class="hotlabel" title="${escapeHtml(h.label)}">${escapeHtml(h.label)}</span>
597
- <span class="hotmeta">${meta(h)}</span>
598
- <span class="hotses">${h.sessions.length} ${escapeHtml(t('insights.hotSessionsCol'))}</span>
599
- </button>${sess}
600
- </div>`;
601
- }).join('') : `<p class="mut">-</p>`}</div>
602
- </section>`;
603
- // Projects (kept, with drill-down) + slowest sessions.
604
- const projMap = new Map();
605
- for (const rec of reports) {
606
- const p = projectOf(rec);
607
- if (!p)
608
- continue;
609
- const e = projMap.get(p) ?? { sessions: 0, fails: 0 };
610
- e.sessions += 1;
611
- e.fails += rec.report.agg.failedSpans;
612
- projMap.set(p, e);
613
- }
614
- const projects = [...projMap.entries()].map(([id, v]) => ({ id, ...v })).sort((a, b) => b.fails - a.fails || b.sessions - a.sessions).slice(0, 10);
615
- const projMax = Math.max(1, ...projects.map(x => x.sessions));
616
- const slowSessions = [...reports].filter(r => r.report.agg.slowSpans > 0).sort((a, b) => b.report.agg.slowSpans - a.report.agg.slowSpans).slice(0, 8);
617
- return `<p class="mut ins-hint">${escapeHtml(t('insights.hotHint'))}</p>
618
- <div class="hot-grid">
619
- ${block(t('insights.hotFiles'), files, h => `${escapeHtml(t('insights.readsShort'))}${h.reads} · ${escapeHtml(t('insights.editsShort'))}${h.edits}`)}
620
- ${block(t('insights.hotCommands'), cmds, h => `${h.runs}×${h.fails ? ` · <span class="bad">${h.fails} ${escapeHtml(t('insights.hotFailsCol'))}</span>` : ''}`)}
621
- ${block(t('insights.hotErrors'), errs, h => `${h.count}×`)}
622
- <section class="block">
623
- <h3>${escapeHtml(t('insights.hotProjects'))}</h3>
624
- <div class="hbars">${projects.length ? projects.map(x => {
625
- const pct = Math.max(4, Math.round((x.sessions / projMax) * 100));
626
- return `<button type="button" class="hbrow hbrow-click" data-hotproject="${escapeHtml(x.id)}"><div class="hblabel">${escapeHtml(x.id)}</div><div class="hbtrack"><div class="hbfill" style="width:${pct}%"></div></div><div class="hbval">${fmtInt(x.sessions)}<small>${x.fails} ${escapeHtml(t('insights.hotFailsCol'))}</small></div></button>`;
627
- }).join('') : `<p class="mut">-</p>`}</div>
628
- </section>
629
- <section class="block hot-sessions">
630
- <h3>${escapeHtml(t('insights.hotSlowSessions'))}</h3>
631
- <div class="slist">${slowSessions.length ? slowSessions.map(rec => {
632
- const a = rec.report.agg;
633
- return `<button type="button" class="srow" data-session-id="${escapeHtml(String(rec.session.sessionId))}">
634
- <div class="srmain"><strong>${escapeHtml(sessionTitle(rec.session))}</strong>
635
- <small>${escapeHtml(botDisplayName(rec.session))} · ${escapeHtml(String(rec.session.cliId ?? '-'))}</small></div>
636
- <div class="srstats"><b>${escapeHtml(t('insights.hotSlowCol'))}<em>${fmtInt(a.slowSpans)}</em></b><b>span<em>${fmtInt(a.totalSpans)}</em></b></div>
637
- </button>`;
638
- }).join('') : `<p class="mut">-</p>`}</div>
639
- </section>
640
- </div>`;
641
- }
642
- const SESS_SORTS = [
643
- { key: 'recent', label: 'insights.sortRecent' },
644
- { key: 'review', label: 'insights.sortReview' },
645
- { key: 'spans', label: 'insights.sortSpans' },
646
- { key: 'fails', label: 'insights.sortFails' },
647
- { key: 'slow', label: 'insights.sortSlow' },
648
- { key: 'agent', label: 'insights.sortAgent' },
649
- ];
650
- function renderSortBar(active, layout) {
651
- const sorts = `<span class="sesssort-label mut">${escapeHtml(t('insights.sortLabel'))}</span>` + SESS_SORTS.map(s => `<button type="button" class="spanchip${s.key === active ? ' on' : ''}" data-sesssort="${s.key}">${escapeHtml(t(s.label))}</button>`).join('');
652
- const layouts = `<span class="sesssort-sep"></span>` + [['card', 'insights.layoutCard'], ['table', 'insights.layoutTable']].map(([k, lbl]) => `<button type="button" class="spanchip${layout === k ? ' on' : ''}" data-sesslayout="${k}">${escapeHtml(t(lbl))}</button>`).join('');
653
- return sorts + layouts;
654
- }
655
- function reviewScore(r) {
284
+ export function reviewScore(r) {
656
285
  return r?.status === 'ok' ? r.agg.failedSpans * 6 + r.agg.slowSpans * 3 + r.suggestions.filter(s => s.severity === 'bad').length * 5 : 0;
657
286
  }
658
- function sortRecordsBy(records, key) {
287
+ export function sortRecordsBy(records, key) {
659
288
  const recency = (rec) => Number(rec.session.lastMessageAt ?? 0);
660
289
  const val = (rec) => {
661
290
  const r = rec.report;
@@ -671,93 +300,29 @@ function sortRecordsBy(records, key) {
671
300
  };
672
301
  return [...records].sort((a, b) => key === 'recent' ? recency(b) - recency(a) : (val(b) - val(a)) || recency(b) - recency(a));
673
302
  }
674
- function renderSessionRows(records, selectedId, wide = false) {
675
- if (!records.length)
676
- return `<div class="insight-empty">${escapeHtml(t('insights.empty'))}</div>`;
677
- const stat = (label, val, bad = false) => `<b${bad ? ' class="bad"' : ''}>${escapeHtml(label)}<em>${escapeHtml(val)}</em></b>`;
678
- return `<div class="slist${wide ? ' wide' : ''}">${records.map(rec => {
679
- const s = rec.session;
680
- const r = rec.report;
681
- const ok = r?.status === 'ok';
682
- const agg = r?.agg;
683
- const on = s.sessionId === selectedId ? ' on' : '';
684
- const review = reportNeedsReview(r) ? ' review' : '';
685
- const reads = agg?.phase?.research?.count ?? 0;
686
- const edits = agg?.phase?.edit?.count ?? 0;
687
- return `<button type="button" class="srow${on}${review}" data-session-id="${escapeHtml(String(s.sessionId))}">
688
- <div class="srmain">
689
- <strong>${escapeHtml(sessionTitle(s))}</strong>
690
- <small>${escapeHtml(botDisplayName(s))} · ${escapeHtml(String(s.cliId ?? '-'))}${s.workingDir ? ` · ${escapeHtml(projectOf(rec))}` : ''} · ${escapeHtml(relTime(s.lastMessageAt ?? s.spawnedAt ?? 0))}</small>
691
- ${ok ? renderPhaseMix(r) : ''}
692
- </div>
693
- ${ok ? `<div class="srstats">
694
- ${stat(t('insights.spansShort'), fmtInt(agg.totalSpans))}
695
- ${stat(t('insights.failedShort'), fmtInt(agg.failedSpans), agg.failedSpans > 0)}
696
- ${stat(t('insights.slowShort'), fmtInt(agg.slowSpans))}
697
- ${wide ? stat(t('insights.readsShort'), fmtInt(reads)) + stat(t('insights.editsShort'), fmtInt(edits)) + stat(t('insights.durShort'), fmtMs(agentMsOf(r))) : ''}
698
- ${stat(t('insights.rwShort'), agg.readWriteRatio !== null ? agg.readWriteRatio.toFixed(1) : '-')}
699
- </div>` : `<div class="srmsg">${escapeHtml(safeStatus(r, rec.error))}</div>`}
700
- </button>`;
701
- }).join('')}</div>`;
303
+ export function intentLabel(kind) {
304
+ const k = `insights.intent.${kind}`;
305
+ const o = t(k);
306
+ return o === k ? kind : o;
702
307
  }
703
- // Dense table layout for the session list (ASI 表格视图) — sticky header, tabular
704
- // columns; row click drills into the same full-width detail.
705
- function renderSessionTable(records, selectedId) {
706
- if (!records.length)
707
- return `<div class="insight-empty">${escapeHtml(t('insights.empty'))}</div>`;
708
- const head = `<div class="strow sthead">
709
- <span class="stc-title">${escapeHtml(t('insights.colTitle'))}</span>
710
- <span class="stc-proj">${escapeHtml(t('insights.colProject'))}</span>
711
- <span class="stc-num">${escapeHtml(t('insights.spansShort'))}</span>
712
- <span class="stc-num">${escapeHtml(t('insights.failedShort'))}</span>
713
- <span class="stc-num">${escapeHtml(t('insights.slowShort'))}</span>
714
- <span class="stc-num">${escapeHtml(t('insights.rwShort'))}</span>
715
- <span class="stc-num">${escapeHtml(t('insights.durShort'))}</span>
716
- <span class="stc-num">${escapeHtml(t('insights.colTime'))}</span>
717
- </div>`;
718
- const rows = records.map(rec => {
719
- const s = rec.session;
720
- const r = rec.report;
721
- const ok = r?.status === 'ok';
722
- const agg = r?.agg;
723
- const on = s.sessionId === selectedId ? ' on' : '';
724
- const review = reportNeedsReview(r) ? ' review' : '';
725
- const mid = ok
726
- ? `<span class="stc-num">${fmtInt(agg.totalSpans)}</span>
727
- <span class="stc-num${agg.failedSpans ? ' bad' : ''}">${fmtInt(agg.failedSpans)}</span>
728
- <span class="stc-num">${fmtInt(agg.slowSpans)}</span>
729
- <span class="stc-num">${agg.readWriteRatio !== null ? agg.readWriteRatio.toFixed(1) : '-'}</span>
730
- <span class="stc-num">${escapeHtml(fmtMs(agentMsOf(r)))}</span>`
731
- : `<span class="stc-msg">${escapeHtml(safeStatus(r, rec.error))}</span>`;
732
- return `<button type="button" class="strow${on}${review}${ok ? '' : ' nostat'}" data-session-id="${escapeHtml(String(s.sessionId))}">
733
- <span class="stc-title"><strong>${escapeHtml(sessionTitle(s))}</strong><small>${escapeHtml(botDisplayName(s))} · ${escapeHtml(String(s.cliId ?? '-'))}</small></span>
734
- <span class="stc-proj">${escapeHtml(s.workingDir ? projectOf(rec) : '-')}</span>
735
- ${mid}
736
- <span class="stc-num stc-time">${escapeHtml(relTime(s.lastMessageAt ?? s.spawnedAt ?? 0))}</span>
737
- </button>`;
738
- }).join('');
739
- return `<div class="stable">${head}${rows}</div>`;
308
+ export function resultLabel(category) {
309
+ const k = `insights.result.${category}`;
310
+ const o = t(k);
311
+ return o === k ? category : o;
312
+ }
313
+ export function tagLabel(tag) {
314
+ const k = `insights.tag.${tag}`;
315
+ const o = t(k);
316
+ return o === k ? tag : o;
740
317
  }
741
- // ── Diagnosis-driven detail view ────────────────────────────────────────────
742
- // codex's detail=spans report carries everything rendered here as fail-closed safe
743
- // projections (enums / tool names / numbers / basenames — never raw text):
744
- // • report.recommendations[] — actionable「影响·原因·下一步」, each with evidence
745
- // {spanIndexes,turnIndexes}; clicking one .hot-lights that evidence.
746
- // • report.spans[].detail — the per-row 详情 drawer (safe fields + adjacent intent).
747
- // • report.turnTimeline[] — ALL visible turns as an ordered event stream
748
- // (read→edit→run→result) + optional owner-only prompt, so 逐轮 reads as a timeline.
749
- // Localised labels for codex's safe enums + {id,params} headlines (i18n by key, fallback to key).
750
- function intentLabel(kind) { const k = `insights.intent.${kind}`; const o = t(k); return o === k ? kind : o; }
751
- function resultLabel(category) { const k = `insights.result.${category}`; const o = t(k); return o === k ? category : o; }
752
- function tagLabel(tag) { const k = `insights.tag.${tag}`; const o = t(k); return o === k ? tag : o; }
753
- function idText(ns, h) {
318
+ export function idText(ns, h) {
754
319
  const k = `insights.${ns}.${h.id}`;
755
320
  const o = t(k, h.params);
756
321
  return o === k ? h.id : o;
757
322
  }
758
- const turnHeadline = (h) => idText('turnHeadline', h);
759
- // inputSummary/outputSummary cross only as a fixed allow-list of structural labels (redact.ts);
760
- // localize the known ones, pass anything else through (it is already safe-projected).
323
+ export function turnHeadline(h) {
324
+ return idText('turnHeadline', h);
325
+ }
761
326
  const STRUCT_KEYS = {
762
327
  'shell command': 'insights.struct.shell',
763
328
  'file edit': 'insights.struct.fileEdit',
@@ -769,7 +334,7 @@ const STRUCT_KEYS = {
769
334
  'patch failed': 'insights.struct.patchFailed',
770
335
  'patch applied': 'insights.struct.patchApplied',
771
336
  };
772
- function structLabel(v) {
337
+ export function structLabel(v) {
773
338
  if (!v)
774
339
  return '';
775
340
  const m = v.match(/^exit (-?\d+)$/);
@@ -777,32 +342,32 @@ function structLabel(v) {
777
342
  return t('insights.struct.exit', { code: m[1] });
778
343
  return STRUCT_KEYS[v] ? translatedOrFallback(STRUCT_KEYS[v], v) : v;
779
344
  }
780
- const BAD_RESULTS = new Set(['tool_error', 'test_failed', 'typecheck_failed', 'lint_failed', 'command_failed', 'timeout', 'no_output']);
781
- function spanFailed(s) {
345
+ export function spanFailed(s) {
782
346
  return s.status === 'error' || (!!s.result && BAD_RESULTS.has(s.result.category));
783
347
  }
784
- function intentTextOf(intent, fallback) {
348
+ export function intentTextOf(intent, fallback) {
785
349
  return intent && intent.kind !== 'unknown' ? intentLabel(intent.kind) : fallback;
786
350
  }
787
- function intentText(s) { return intentTextOf(s.intent, s.tool); }
788
- function intentPhrase(intent) {
351
+ export function intentText(s) {
352
+ return intentTextOf(s.intent, s.tool);
353
+ }
354
+ export function intentPhrase(intent) {
789
355
  if (!intent)
790
356
  return '';
791
357
  return [intent.kind !== 'unknown' ? intentLabel(intent.kind) : '', intent.subject, intent.detail].filter(Boolean).join(' · ');
792
358
  }
793
- // Turn tag → one-line「怎么优化」(the page must say what to DO, not just what happened).
794
359
  const ADVICE_TAGS = ['failure', 'retry', 'read_write_imbalance', 'slow'];
795
- function turnAdvice(tags) {
796
- for (const tag of ADVICE_TAGS)
360
+ export function turnAdvice(tags) {
361
+ for (const tag of ADVICE_TAGS) {
797
362
  if (tags.includes(tag)) {
798
363
  const o = t(`insights.advice.${tag}`);
799
364
  if (o !== `insights.advice.${tag}`)
800
365
  return o;
801
366
  }
367
+ }
802
368
  return '';
803
369
  }
804
- // The spans/turns the focused recommendation points at — used to .hot-light evidence.
805
- function focusSets(report, activeId) {
370
+ export function focusSets(report, activeId) {
806
371
  const spans = report.spans ?? [];
807
372
  const rec = activeId ? (report.recommendations ?? []).find(r => r.id === activeId) ?? null : null;
808
373
  if (!rec)
@@ -810,179 +375,7 @@ function focusSets(report, activeId) {
810
375
  const span = (rec.evidence?.spanIndexes ?? []).filter(i => Number.isInteger(i) && i >= 0 && i < spans.length);
811
376
  return { rec, spanIdx: new Set(span), turnIdx: new Set(rec.evidence?.turnIndexes ?? []) };
812
377
  }
813
- // Top of the detail: codex's actionable recommendations. Each card leads with the fix
814
- // (下一步), backed by 影响 + 原因, and is a button that .hot-lights its evidence spans/turns.
815
- function renderRecommendations(report, activeId) {
816
- const recs = report.recommendations ?? [];
817
- if (!recs.length)
818
- return `<p class="mut">${escapeHtml(t('insights.noRecommendations'))}</p>`;
819
- const sorted = [...recs].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
820
- return `<div class="reclist">${sorted.map(r => {
821
- const active = r.id === activeId;
822
- const targeted = (r.evidence?.spanIndexes?.length ?? 0) > 0 || (r.evidence?.turnIndexes?.length ?? 0) > 0;
823
- const impact = idText('impact', r.impact);
824
- const why = idText('why', r.why);
825
- const actions = r.nextActions.map(a => `<li>${escapeHtml(idText('action', a))}</li>`).join('');
826
- const cta = targeted ? `<span class="rec-cta">${escapeHtml(active ? t('insights.diagActive') : t('insights.diagShow'))}</span>` : '';
827
- return `<button type="button" class="rec ${r.severity}${targeted ? ' rec-clickable' : ''}${active ? ' active' : ''}" data-rec="${escapeHtml(r.id)}">
828
- <div class="rectop"><b>${escapeHtml(idText('rec', { id: r.id, params: {} }))}</b><span>${escapeHtml(severityLabel(r.severity))}</span></div>
829
- ${impact ? `<div class="rec-impact">${escapeHtml(impact)}</div>` : ''}
830
- ${actions ? `<ul class="rec-actions">${actions}</ul>` : ''}
831
- ${why ? `<div class="rec-why">${escapeHtml(why)}</div>` : ''}
832
- ${cta}
833
- </button>`;
834
- }).join('')}</div>`;
835
- }
836
- function opGlyph(s, current) {
837
- const title = `${intentText(s)}${s.intent?.subject ? ` ${s.intent.subject}` : ''}${s.result ? ` → ${resultLabel(s.result.category)}` : ''} · ${fmtMs(s.durationMs)}`;
838
- return `<i class="op ph-${escapeHtml(phaseSlug(s.phase))}${spanFailed(s) ? ' bad' : ''}${current ? ' cur' : ''}" title="${escapeHtml(title)}"></i>`;
839
- }
840
- // One compact evidence row: turn · status · what→result · tags · duration · 详情 toggle.
841
- // Far denser than the old card; the drawer (span.detail) carries the rest on demand.
842
- // The whole header line is the toggle (用户: 点单个 span 就展开,不用专门点详情按钮); the pill is a
843
- // state indicator only. data-span-idx lives on the line so clicks in the open drawer don't toggle.
844
- function renderSpanRow(spans, idx, hot, open, detailable = true) {
845
- const s = spans[idx];
846
- const subject = s.intent?.subject ? `<code class="span-subj">${escapeHtml(s.intent.subject)}</code>` : '';
847
- const res = s.result;
848
- const resChip = res && BAD_RESULTS.has(res.category)
849
- ? `<span class="span-res rc-bad">${escapeHtml(resultLabel(res.category))}${res.exitCode !== undefined ? ` · exit ${res.exitCode}` : ''}</span>`
850
- : '';
851
- const tags = (s.tags ?? []).filter(tg => tg !== 'normal' && tg !== 'diagnostic');
852
- const tagChips = tags.map(tg => `<span class="span-tag tg-${escapeHtml(tg)}">${escapeHtml(tagLabel(tg))}</span>`).join('');
853
- const detailBtn = detailable
854
- ? `<span class="span-detail-btn" aria-hidden="true">${escapeHtml(open ? t('insights.dCollapse') : t('insights.dDetail'))}</span>`
855
- : '';
856
- const lineAttrs = detailable ? ` data-span-idx="${idx}" role="button" tabindex="0" aria-expanded="${open}"` : '';
857
- return `<div class="spanrow ph-${escapeHtml(phaseSlug(s.phase))}${s.status === 'error' ? ' error' : ''}${hot ? ' hot' : ''}${open ? ' open' : ''}">
858
- <div class="sprow-line${detailable ? ' clickable' : ''}"${lineAttrs}>
859
- <span class="span-turn" title="${escapeHtml(`${t('insights.dStart')} ${fmtMs(s.relStartMs)}`)}">#${escapeHtml(String(s.turnIndex ?? 0))}</span>
860
- <span class="spanst ${escapeHtml(s.status)}">${escapeHtml(statusIcon(s.status))}</span>
861
- <b class="span-what">${escapeHtml(intentText(s))}</b>${subject}
862
- ${resChip}
863
- <span class="span-tags">${tagChips}</span>
864
- <span class="span-dur">${escapeHtml(fmtMs(s.durationMs))}</span>
865
- ${detailBtn}
866
- </div>
867
- ${detailable && open ? renderSpanDetail(spans, idx) : ''}
868
- </div>`;
869
- }
870
- // Owner-only raw command/output (codex's evidence.command/output: secret-scrubbed,
871
- // capped 800/2000 chars, run-class spans only). detail=spans path only — absent ⇒ nothing renders.
872
- function renderTextPreview(label, p) {
873
- if (!p?.text)
874
- return '';
875
- return `<div class="span-io"><span class="span-io-label">${escapeHtml(label)}</span><pre class="span-io-text">${escapeHtml(p.text)}${p.truncated ? '\n…' : ''}</pre></div>`;
876
- }
877
- // 详情 drawer for one span: codex's span.detail safe fields + raw command/output + the same-turn
878
- // operation strip with this step highlighted ("what surrounded this step"). All safe projections.
879
- function renderSpanDetail(spans, idx) {
880
- const s = spans[idx];
881
- const d = s.detail;
882
- const ev = d?.evidence ?? s.evidence;
883
- const io = ev ? `${renderTextPreview(t('insights.dCommand'), ev.command)}${renderTextPreview(t('insights.dCmdOutput'), ev.output)}` : '';
884
- const kv = [
885
- [t('insights.dPhase'), phaseLabel(s.phase)],
886
- [t('insights.dStart'), fmtMs(s.relStartMs)],
887
- [t('insights.dDur'), fmtMs(s.durationMs)],
888
- ];
889
- const intent = intentPhrase(s.intent);
890
- if (intent)
891
- kv.push([t('insights.dIntent'), intent]);
892
- if (s.result)
893
- kv.push([t('insights.dResult'), `${resultLabel(s.result.category)}${s.result.exitCode !== undefined ? ` · exit ${s.result.exitCode}` : ''}`]);
894
- if (s.inputSummary)
895
- kv.push([t('insights.dIn'), structLabel(s.inputSummary)]);
896
- if (s.outputSummary)
897
- kv.push([t('insights.dOut'), structLabel(s.outputSummary)]);
898
- const tags = (s.tags ?? []).filter(tg => tg !== 'normal');
899
- if (tags.length)
900
- kv.push([t('insights.dTags'), tags.map(tagLabel).join('、')]);
901
- const prev = d?.context?.previousIntent ? intentPhrase(d.context.previousIntent) : '';
902
- const next = d?.context?.nextIntent ? intentPhrase(d.context.nextIntent) : '';
903
- const flank = (prev || next)
904
- ? `<div class="span-flank">${prev ? `<span class="sf-prev">↑ ${escapeHtml(prev)}</span>` : ''}${next ? `<span class="sf-next">↓ ${escapeHtml(next)}</span>` : ''}</div>`
905
- : '';
906
- const sibs = spans.map((sp, i) => ({ sp, i })).filter(x => x.sp.turnIndex === s.turnIndex).sort((a, b) => (a.sp.relStartMs ?? 0) - (b.sp.relStartMs ?? 0));
907
- const strip = sibs.map(x => opGlyph(x.sp, x.i === idx)).join('');
908
- return `<div class="spandetail">
909
- <dl class="span-kv">${kv.map(([k, v]) => `<div><dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v)}</dd></div>`).join('')}</dl>
910
- ${io}
911
- ${flank}
912
- <div class="span-ctx"><span class="span-ctx-label">${escapeHtml(t('insights.dTurnContext', { turn: s.turnIndex }))}</span><div class="opstrip">${strip}</div></div>
913
- </div>`;
914
- }
915
- const SPAN_TAGS = ['failure', 'slow', 'retry', 'read_write_imbalance'];
916
- // 文件改动 + 跑过的命令 — session-level work summary (codex's workSummary, detail=spans, owner-only).
917
- // Two panels at the top of 动作 span: which files were touched (read/edit counts + line churn) and
918
- // which commands ran (deduped + ×repeat + failures). Ported from the reference tool.
919
- function renderWorkSummary(report) {
920
- const ws = report.workSummary;
921
- if (!ws || (!ws.fileChanges?.length && !ws.commandsRun?.length))
922
- return '';
923
- const files = ws.fileChanges ?? [];
924
- const cmds = ws.commandsRun ?? [];
925
- const fileRows = files.length
926
- ? files.map(f => {
927
- const stat = (f.added || f.removed)
928
- ? `<span class="ws-stat"><span class="ws-add">+${f.added ?? 0}</span><span class="ws-del">−${f.removed ?? 0}</span></span>`
929
- : (f.edits ? `<span class="ws-stat ws-stat-edits">${escapeHtml(t('insights.wsEdits', { n: f.edits }))}</span>` : '');
930
- return `<div class="ws-row"><code class="ws-path" title="${escapeHtml(f.path)}">${escapeHtml(f.path)}</code><span class="ws-meta">${escapeHtml(t('insights.wsReads', { n: f.reads }))}</span>${stat}</div>`;
931
- }).join('')
932
- : `<p class="mut">${escapeHtml(t('insights.wsNoFiles'))}</p>`;
933
- const cmdRows = cmds.length
934
- ? cmds.map(c => {
935
- const bad = c.failures > 0;
936
- return `<div class="ws-row${bad ? ' bad' : ''}"><code class="ws-cmd" title="${escapeHtml(c.command.text)}">${escapeHtml(c.command.text)}${c.command.truncated ? '…' : ''}</code><span class="ws-meta">${c.count > 1 ? `<span class="ws-x">×${c.count}</span>` : ''}${bad ? `<span class="ws-fail">${escapeHtml(t('insights.wsFail', { n: c.failures }))}</span>` : ''}</span></div>`;
937
- }).join('')
938
- : `<p class="mut">${escapeHtml(t('insights.wsNoCmds'))}</p>`;
939
- return `<div class="worksum">
940
- <section class="ws-panel"><h4>${escapeHtml(t('insights.wsFiles', { n: files.length }))}</h4><div class="ws-list">${fileRows}</div></section>
941
- <section class="ws-panel"><h4>${escapeHtml(t('insights.wsCmds', { n: cmds.length }))}</h4><div class="ws-list">${cmdRows}</div></section>
942
- </div>`;
943
- }
944
- // 动作 span tab → compact evidence table. Tag chips filter (全部/失败/慢/…); each row expands
945
- // to its 详情 drawer. Replaces the old giant cards + duplicated full timeline.
946
- function renderEvidence(report, focus, spanFilter, openSpans) {
947
- const spans = report.spans ?? [];
948
- const work = renderWorkSummary(report);
949
- if (!spans.length)
950
- return `${work}<p class="mut">${escapeHtml(t('insights.noSpans'))}</p>`;
951
- const order = [...spans.keys()].sort((a, b) => (spans[a].relStartMs ?? 0) - (spans[b].relStartMs ?? 0));
952
- const counts = new Map();
953
- for (const i of order)
954
- for (const tg of spans[i].tags ?? [])
955
- if (SPAN_TAGS.includes(tg))
956
- counts.set(tg, (counts.get(tg) ?? 0) + 1);
957
- const chip = (key, label, n) => `<button type="button" class="spanchip${key === 'all' ? '' : ` tg-${escapeHtml(key)}`}${spanFilter === key ? ' on' : ''}" data-spanfilter="${escapeHtml(key)}">${escapeHtml(label)} <b>${n}</b></button>`;
958
- const chips = [chip('all', t('insights.spanAll'), order.length), ...SPAN_TAGS.filter(tg => counts.has(tg)).map(tg => chip(tg, tagLabel(tg), counts.get(tg)))].join('');
959
- const visible = spanFilter === 'all' ? order : order.filter(i => spans[i].tags?.includes(spanFilter));
960
- const reason = focus.rec ? `<div class="ev-reason">${escapeHtml(idText('why', focus.rec.why))}</div>` : '';
961
- const rows = visible.length
962
- ? visible.map(i => renderSpanRow(spans, i, focus.spanIdx.has(i), openSpans.has(i))).join('')
963
- : `<p class="mut">${escapeHtml(t('insights.evNoFlags'))}</p>`;
964
- return `<div class="evidence">${work}${reason}<div class="spanfilter">${chips}</div><div class="spantable">${rows}</div></div>`;
965
- }
966
- export function renderDetailShell(rec) {
967
- if (!rec)
968
- return `<section class="insight-detail"><p class="mut">${escapeHtml(t('insights.selectSession'))}</p></section>`;
969
- const s = rec.session;
970
- return `<section class="insight-detail">
971
- <div class="shead">
972
- <h2>${escapeHtml(sessionTitle(s))}</h2>
973
- <div class="smeta">${escapeHtml(botDisplayName(s))} · ${escapeHtml(String(s.cliId ?? '-'))} · <code>${escapeHtml(String(s.sessionId ?? ''))}</code></div>
974
- </div>
975
- <div id="insight-detail-body"><p class="mut">${escapeHtml(t('insights.detailLoading'))}</p></div>
976
- </section>`;
977
- }
978
- // 逐轮对账 tab → per-turn timeline. ALL visible turns (codex's report.turnTimeline), normal
979
- // turns collapsed to a one-line event strip, flagged turns severity-coloured with a「怎么优化」
980
- // line; expand for the full ordered event rows. Owner-only prompt 原文 (turnTimeline[].prompt,
981
- // detail=spans only) renders at the turn head. Covers all turns + a real timeline.
982
- // Display-only: strip botmux-injected scaffolding (sender/mentions/reminders/quote notices)
983
- // from the owner-only prompt so the actual user text shows. The report still carries the raw
984
- // (truncated, credential-scrubbed) text; this is a readability projection, not a security one.
985
- function cleanPromptText(raw) {
378
+ export function cleanPromptText(raw) {
986
379
  let s = raw
987
380
  .replace(/<(mentions|attachments|available_bots|system-reminder|quoted_messages|sender)\b[\s\S]*?<\/\1>/g, ' ')
988
381
  .replace(/<botmux_reminder>[\s\S]*?<\/botmux_reminder>/g, ' ')
@@ -992,28 +385,21 @@ function cleanPromptText(raw) {
992
385
  .replace(/\[用户引用了消息[\s\S]*?\]/g, ' ')
993
386
  .replace(/\[来自[^\]]*@mention\]/g, ' ')
994
387
  .replace(/\[(图片|文件)\s*\d+\][^\n]*/g, ' ');
995
- // Preserve newlines — markdown block structure (lists, fences, headings) depends on them.
996
- // Only collapse runs of spaces/tabs and trim spaces around newlines / cap blank-line runs.
997
388
  return s
998
389
  .replace(/[ \t]+/g, ' ')
999
390
  .replace(/ *\n */g, '\n')
1000
391
  .replace(/\n{3,}/g, '\n\n')
1001
392
  .trim();
1002
393
  }
1003
- // Owner-only prompt 原文 rendered as Markdown (用户: prompt 里可能有 markdown,想 prettier 展示).
1004
- // html:false → any literal <…> in the prompt is escaped, never raw HTML/script; validateLink
1005
- // whitelists http/https/mailto so a javascript:/data: link can't slip through; links open in a
1006
- // new tab with noopener. The output is markdown-it's own safe tag set, so innerHTML of it is safe.
1007
- // Parse failure ⇒ fall back to escaped plain text — never break 对账.
1008
394
  const promptMd = new MarkdownIt({ html: false, linkify: true, breaks: true });
1009
395
  promptMd.validateLink = (url) => /^(https?:|mailto:)/i.test(url.trim());
1010
- const _linkOpen = promptMd.renderer.rules.link_open;
396
+ const linkOpen = promptMd.renderer.rules.link_open;
1011
397
  promptMd.renderer.rules.link_open = (tokens, idx, options, env, self) => {
1012
398
  tokens[idx].attrSet('target', '_blank');
1013
399
  tokens[idx].attrSet('rel', 'noopener noreferrer nofollow');
1014
- return _linkOpen ? _linkOpen(tokens, idx, options, env, self) : self.renderToken(tokens, idx, options);
400
+ return linkOpen ? linkOpen(tokens, idx, options, env, self) : self.renderToken(tokens, idx, options);
1015
401
  };
1016
- function renderPromptMarkdown(text) {
402
+ export function promptMarkdownHtml(text) {
1017
403
  try {
1018
404
  const html = promptMd.render(text).trim();
1019
405
  return html || `<p>${escapeHtml(text)}</p>`;
@@ -1022,174 +408,10 @@ function renderPromptMarkdown(text) {
1022
408
  return `<p>${escapeHtml(text)}</p>`;
1023
409
  }
1024
410
  }
1025
- function turnEventGlyph(e) {
1026
- const bad = e.status === 'error' || (!!e.result && BAD_RESULTS.has(e.result.category));
1027
- const what = intentTextOf(e.intent, String(e.label.params.tool ?? e.kind));
1028
- const title = `${what}${e.intent?.subject ? ` ${e.intent.subject}` : ''}${e.result ? ` → ${resultLabel(e.result.category)}` : ''} · ${fmtMs(e.durationMs)}`;
1029
- return `<i class="op ph-${escapeHtml(phaseSlug(e.phase))}${bad ? ' bad' : ''}" title="${escapeHtml(title)}"></i>`;
1030
- }
1031
- // Prompt source attribution (codex's prompt.source: name/type/flags only, no open_id). Surfaces
1032
- // WHO actually sent the turn — user/other people (👤), a bot (🤖), or an a2a forward (🤝) —
1033
- // driven by codex's authoritative source.kind. a2a shows the
1034
- // specific sending agent (agentName ?? senderName); system = injected task-notification callbacks.
1035
- function promptSourceChip(src) {
1036
- if (!src?.kind)
1037
- return '';
1038
- if (src.kind === 'a2a_agent') {
1039
- const name = src.agentName || src.senderName;
1040
- return `<span class="tp-label tp-src tp-src-a2a">🤝 ${name ? `${escapeHtml(name)} · a2a` : 'a2a'}</span>`;
1041
- }
1042
- if (src.kind === 'system') {
1043
- return `<span class="tp-label tp-src tp-src-system">⚙️ ${escapeHtml(t('insights.senderSystem'))}</span>`;
1044
- }
1045
- const name = src.senderName ? escapeHtml(src.senderName) : '';
1046
- return `<span class="tp-label tp-src tp-src-user">👤 ${name || escapeHtml(t('insights.turnPrompt'))}</span>`;
1047
- }
1048
- function promptMentions(src) {
1049
- const ms = (src?.mentionedNames ?? []).filter(Boolean);
1050
- if (!ms.length)
1051
- return '';
1052
- return `<span class="tp-mentions">${escapeHtml(t('insights.srcMentions'))} ${ms.map(n => '@' + escapeHtml(n)).join(' ')}</span>`;
1053
- }
1054
- // Full-text prompt modal (用户: 做个弹窗看全文). Bigger centred reading surface for one turn's
1055
- // prompt — source badge + markdown (or 原文) + truncation note. Same safe markdown path as inline.
1056
- function renderPromptModalInner(turnIndex, prompt, raw) {
1057
- const src = prompt?.source;
1058
- const badge = promptSourceChip(src) || `<span class="tp-label">${escapeHtml(t('insights.turnPrompt'))}</span>`;
1059
- const mentions = promptMentions(src);
1060
- const cleaned = prompt?.text ? (cleanPromptText(prompt.text) || prompt.text) : '';
1061
- const trunc = prompt?.truncated;
1062
- const bodyHtml = raw
1063
- ? `<pre class="tp-raw modal-raw">${escapeHtml(cleaned)}${trunc ? '\n…' : ''}</pre>`
1064
- : `<div class="md-body modal-md">${renderPromptMarkdown(cleaned + (trunc ? ' …' : ''))}</div>`;
1065
- return `<div class="modal-backdrop" data-modal-close></div>
1066
- <div class="modal-panel" role="dialog" aria-modal="true" aria-label="${escapeHtml(t('insights.turnPrompt'))}">
1067
- <div class="modal-head">
1068
- <div class="modal-who">${badge}${mentions}<span class="modal-turnno">#${escapeHtml(String(turnIndex))}</span></div>
1069
- <div class="modal-acts">
1070
- <button type="button" class="tp-toggle" data-modal-raw>${escapeHtml(raw ? t('insights.turnPromptRendered') : t('insights.turnPromptRaw'))}</button>
1071
- <button type="button" class="modal-close" data-modal-close aria-label="${escapeHtml(t('insights.modalClose'))}">×</button>
1072
- </div>
1073
- </div>
1074
- <div class="modal-body">${bodyHtml}${trunc ? `<p class="modal-trunc mut">${escapeHtml(t('insights.promptTruncated'))}</p>` : ''}</div>
1075
- </div>`;
1076
- }
1077
- // One turn card: prompt 原文 + op-strip + 怎么优化 advice + expandable event rows. Each event row
1078
- // is detailable so its 详情 drawer carries the raw command/output too (用户: 对账 tab 下也要看到命令和结果).
1079
- function renderTurnCard(report, spans, tn, focus, openTurns, openSpans, openPrompts, rawPrompts) {
1080
- const open = openTurns.has(tn.turnIndex);
1081
- const hot = focus.turnIdx.has(tn.turnIndex) ? ' hot' : '';
1082
- const m = tn.metrics;
1083
- const advice = tn.severity !== 'info' ? turnAdvice(tn.tags) : '';
1084
- const strip = tn.events.map(turnEventGlyph).join('');
1085
- // Prompt 原文: render as Markdown by default, clamped so a long prompt can't
1086
- // blow the timeline; 展开 lifts the clamp (still scroll-capped), 原文 shows the raw text instead.
1087
- const ptext = tn.prompt?.text ? (cleanPromptText(tn.prompt.text) || tn.prompt.text) : '';
1088
- const promptExpanded = openPrompts.has(tn.turnIndex);
1089
- const promptRaw = rawPrompts.has(tn.turnIndex);
1090
- const ptail = tn.prompt?.truncated ? ' …' : '';
1091
- const promptBody = promptRaw
1092
- ? `<pre class="tp-raw">${escapeHtml(ptext)}${tn.prompt?.truncated ? '\n…' : ''}</pre>`
1093
- : `<div class="md-body">${renderPromptMarkdown(ptext + ptail)}</div>`;
1094
- const srcChip = promptSourceChip(tn.prompt?.source) || `<span class="tp-label">${escapeHtml(t('insights.turnPrompt'))}</span>`;
1095
- const mentionsHtml = promptMentions(tn.prompt?.source);
1096
- const promptHtml = ptext
1097
- ? `<div class="turn-prompt">
1098
- ${srcChip}
1099
- <div class="tp-body">
1100
- <div class="tp-md${promptExpanded ? ' expanded' : ''}">${promptBody}</div>
1101
- <div class="tp-actions">
1102
- ${mentionsHtml}
1103
- <button type="button" class="tp-toggle" data-prompt-expand="${escapeHtml(String(tn.turnIndex))}">${escapeHtml(promptExpanded ? t('insights.turnPromptCollapse') : t('insights.turnPromptExpand'))}</button>
1104
- <button type="button" class="tp-toggle" data-prompt-raw="${escapeHtml(String(tn.turnIndex))}">${escapeHtml(promptRaw ? t('insights.turnPromptRendered') : t('insights.turnPromptRaw'))}</button>
1105
- <button type="button" class="tp-toggle" data-prompt-full="${escapeHtml(String(tn.turnIndex))}">${escapeHtml(t('insights.turnPromptFull'))}</button>
1106
- </div>
1107
- </div>
1108
- </div>`
1109
- : '';
1110
- const pill = (label, val, bad = false) => `<span class="tm${bad ? ' bad' : ''}"><i>${escapeHtml(label)}</i><b>${escapeHtml(val)}</b></span>`;
1111
- const mini = `${t('insights.mEdits')}${m.edits} ${t('insights.mRuns')}${m.runs}${m.failures ? ` · ${t('insights.mFailures')}${m.failures}` : ''} · ${fmtMs(m.durationMs)}`;
1112
- const detail = open
1113
- ? `<div class="turn-detail">
1114
- <div class="turn-metrics">${pill(t('insights.mReads'), String(m.reads))}${pill(t('insights.mEdits'), String(m.edits))}${pill(t('insights.mRuns'), String(m.runs))}${m.failures ? pill(t('insights.mFailures'), String(m.failures), true) : ''}${pill(t('insights.mDur'), fmtMs(m.durationMs))}</div>
1115
- <div class="spantable">${tn.events.map(e => renderSpanRow(spans, e.spanIndex, false, openSpans.has(e.spanIndex), true)).join('')}</div>
1116
- </div>`
1117
- : '';
1118
- return `<div class="turnrow sev-${escapeHtml(tn.severity)}${hot}${tn.severity !== 'info' ? ' flagged' : ''}${open ? ' open' : ''}" data-turn-card="${escapeHtml(String(tn.turnIndex))}">
1119
- <div class="turnline">
1120
- <span class="turn-no">#${escapeHtml(String(tn.turnIndex))}</span>
1121
- <b class="turn-headline">${escapeHtml(turnHeadline(tn.headline))}</b>
1122
- <div class="opstrip turn-strip">${strip}</div>
1123
- <span class="turn-mini">${escapeHtml(mini)}</span>
1124
- <button type="button" class="turn-expand-btn" data-turn="${escapeHtml(String(tn.turnIndex))}" aria-expanded="${open}">${escapeHtml(open ? t('insights.turnCollapse') : t('insights.turnExpand', { count: tn.events.length }))}</button>
1125
- </div>
1126
- ${promptHtml}
1127
- ${advice ? `<div class="turn-advice">${escapeHtml(advice)}</div>` : ''}
1128
- ${detail}
1129
- </div>`;
1130
- }
1131
- function turnSenderKind(tn) {
411
+ export function turnSenderKind(tn) {
1132
412
  return tn.prompt?.source?.kind ?? 'user';
1133
413
  }
1134
- // 逐轮对账 tab → per-turn timeline. 发起人 filter narrows first (全部 = 对话 = user+a2a; 系统 is an
1135
- // opt-in, since task-notification callbacks aren't real conversation), then two orderings: 正常排序
1136
- // (by turnIndex) and 按建议分类 (turns grouped under the recommendation that cites them).
1137
- function renderTurnEfficiency(report, focus, openTurns, openSpans, ledgerSort, openPrompts, rawPrompts, ledgerSender) {
1138
- const spans = report.spans ?? [];
1139
- const allTurns = report.turnTimeline ?? [];
1140
- if (!allTurns.length)
1141
- return `<p class="mut">${escapeHtml(t('insights.noSpans'))}</p>`;
1142
- const recs = report.recommendations ?? [];
1143
- const canGroup = recs.some(r => (r.evidence?.turnIndexes?.length ?? 0) > 0);
1144
- // 发起人 filter chips — only show a kind that actually occurs; emoji mirror the prompt badges.
1145
- // 全部 = conversation (user + a2a), system excluded by default; 系统 chip is muted opt-in.
1146
- const senderCount = { user: 0, a2a_agent: 0, system: 0 };
1147
- for (const tn of allTurns)
1148
- senderCount[turnSenderKind(tn)]++;
1149
- const senderChip = (key, label, n, extra = '') => `<button type="button" class="spanchip${extra}${ledgerSender === key ? ' on' : ''}" data-ledgersender="${key}">${escapeHtml(label)} <b>${n}</b></button>`;
1150
- const senderChips = [
1151
- senderChip('all', t('insights.spanAll'), senderCount.user + senderCount.a2a_agent),
1152
- ...(senderCount.user ? [senderChip('user', `👤 ${t('insights.senderHuman')}`, senderCount.user)] : []),
1153
- ...(senderCount.a2a_agent ? [senderChip('a2a_agent', `🤝 ${t('insights.senderA2A')}`, senderCount.a2a_agent)] : []),
1154
- ...(senderCount.system ? [senderChip('system', `⚙️ ${t('insights.senderSystem')}`, senderCount.system, ' spanchip-sys')] : []),
1155
- ].join('');
1156
- const senderFilter = `<div class="spanfilter ledgersender">${senderChips}</div>`;
1157
- const turns = ledgerSender === 'all'
1158
- ? allTurns.filter(tn => turnSenderKind(tn) !== 'system')
1159
- : allTurns.filter(tn => turnSenderKind(tn) === ledgerSender);
1160
- const flagged = turns.filter(tn => tn.severity !== 'info').length;
1161
- const sortChip = (key, label) => `<button type="button" class="spanchip${ledgerSort === key ? ' on' : ''}" data-ledgersort="${key}">${escapeHtml(label)}</button>`;
1162
- const toggle = canGroup ? `<div class="spanfilter ledgersort">${sortChip('normal', t('insights.ledgerNormal'))}${sortChip('grouped', t('insights.ledgerGrouped'))}</div>` : '';
1163
- const controls = `${senderFilter}${toggle}`;
1164
- const summary = `<p class="turn-sum mut">${escapeHtml(t('insights.turnSummary', { total: turns.length, flagged }))}</p>`;
1165
- const note = report.meta?.capped ? `<p class="turn-hidden mut">${escapeHtml(t('insights.turnsCapped', { shown: String(report.meta.spansReturned ?? spans.length), total: String(report.meta.spansTotal ?? spans.length) }))}</p>` : '';
1166
- const card = (tn) => renderTurnCard(report, spans, tn, focus, openTurns, openSpans, openPrompts, rawPrompts);
1167
- if (!turns.length)
1168
- return `<div class="turnlist">${controls}<p class="mut">${escapeHtml(t('insights.evNoFlags'))}</p></div>`;
1169
- if (ledgerSort === 'grouped' && canGroup) {
1170
- const sortedRecs = [...recs].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
1171
- const byIndex = new Map(turns.map(tn => [tn.turnIndex, tn]));
1172
- const assigned = new Map();
1173
- for (const r of sortedRecs)
1174
- for (const ti of r.evidence?.turnIndexes ?? [])
1175
- if (byIndex.has(ti) && !assigned.has(ti))
1176
- assigned.set(ti, r.id);
1177
- const groupHead = (sev, title, n) => `<div class="turn-group-head sev-${escapeHtml(sev)}"><b>${escapeHtml(title)}</b><span>${escapeHtml(t('insights.ledgerGroupCount', { count: n }))}</span></div>`;
1178
- const blocks = [];
1179
- for (const r of sortedRecs) {
1180
- const ts = turns.filter(tn => assigned.get(tn.turnIndex) === r.id).sort((a, b) => a.turnIndex - b.turnIndex);
1181
- if (ts.length)
1182
- blocks.push(`<div class="turn-group">${groupHead(r.severity, idText('rec', { id: r.id, params: {} }), ts.length)}${ts.map(card).join('')}</div>`);
1183
- }
1184
- const other = turns.filter(tn => !assigned.has(tn.turnIndex)).sort((a, b) => a.turnIndex - b.turnIndex);
1185
- if (other.length)
1186
- blocks.push(`<div class="turn-group">${groupHead('info', t('insights.ledgerOther'), other.length)}${other.map(card).join('')}</div>`);
1187
- return `<div class="turnlist">${summary}${controls}${blocks.join('')}${note}</div>`;
1188
- }
1189
- const ordered = [...turns].sort((a, b) => a.turnIndex - b.turnIndex);
1190
- return `<div class="turnlist">${summary}${controls}${ordered.map(card).join('')}${note}</div>`;
1191
- }
1192
- function groupConvo(messages) {
414
+ export function groupConvo(messages) {
1193
415
  const units = [];
1194
416
  for (const m of messages) {
1195
417
  if (m.role === 'agent') {
@@ -1205,308 +427,28 @@ function groupConvo(messages) {
1205
427
  }
1206
428
  return units;
1207
429
  }
1208
- // Recommendation badge(s) for a turn — links a chat turn to the optimisation findings that cite it.
1209
- function convoRecBadges(turnIndex, recByTurn) {
1210
- const ids = recByTurn.get(turnIndex);
1211
- if (!ids?.length)
1212
- return '';
1213
- return ids.map(id => `<span class="cbub-rec" title="${escapeHtml(idText('rec', { id, params: {} }))}">💡 ${escapeHtml(idText('rec', { id, params: {} }))}</span>`).join('');
1214
- }
1215
- function renderConvoPrompt(m, recByTurn) {
1216
- const side = m.role === 'user' ? 'right' : 'left';
1217
- const badge = promptSourceChip(m.source) || `<span class="tp-label">${escapeHtml(t('insights.turnPrompt'))}</span>`;
1218
- const text = m.text ? (cleanPromptText(m.text) || m.text) : '';
1219
- const sevCls = m.severity && m.severity !== 'info' ? ` sev-${escapeHtml(m.severity)}` : '';
1220
- return `<div class="cbub cbub-${side} role-${escapeHtml(m.role)}${sevCls}">
1221
- <div class="cbub-head">${badge}${promptMentions(m.source)}<span class="cbub-turn">#${escapeHtml(String(m.turnIndex))}</span>${convoRecBadges(m.turnIndex, recByTurn)}</div>
1222
- <div class="cbub-body"><div class="md-body">${text ? renderPromptMarkdown(text + (m.truncated ? ' …' : '')) : `<p class="mut">${escapeHtml(t('insights.replayNoText'))}</p>`}</div></div>
1223
- ${m.truncated ? `<div class="cbub-foot"><button type="button" class="tp-toggle" data-convo-full="${escapeHtml(String(m.turnIndex))}">${escapeHtml(t('insights.turnPromptFull'))}</button></div>` : ''}
1224
- </div>`;
1225
- }
1226
- function renderConvoOpRow(m, open) {
1227
- const e = m.event;
1228
- if (!e)
1229
- return '';
1230
- const bad = e.status === 'error' || (!!e.result && BAD_RESULTS.has(e.result.category));
1231
- const subj = e.intent?.subject ? `<code class="span-subj">${escapeHtml(e.intent.subject)}</code>` : '';
1232
- const what = intentTextOf(e.intent, String(e.label?.params?.tool ?? e.kind));
1233
- const res = e.result && BAD_RESULTS.has(e.result.category)
1234
- ? `<span class="span-res rc-bad">${escapeHtml(resultLabel(e.result.category))}${e.result.exitCode !== undefined ? ` · exit ${e.result.exitCode}` : ''}</span>`
1235
- : '';
1236
- const ev = e.evidence;
1237
- const expandable = !!(ev?.command?.text || ev?.output?.text);
1238
- const io = open && ev ? `${renderTextPreview(t('insights.dCommand'), ev.command)}${renderTextPreview(t('insights.dCmdOutput'), ev.output)}` : '';
1239
- const tags = (m.tags ?? []).filter(tg => tg !== 'normal' && tg !== 'diagnostic');
1240
- const tagChips = tags.map(tg => `<span class="span-tag tg-${escapeHtml(tg)}">${escapeHtml(tagLabel(tg))}</span>`).join('');
1241
- return `<div class="cop${bad ? ' bad' : ''}${open ? ' open' : ''}">
1242
- <div class="cop-line${expandable ? ' clickable' : ''}"${expandable ? ` data-convo-op="${escapeHtml(m.id)}" role="button" tabindex="0"` : ''}>
1243
- <i class="op ph-${escapeHtml(phaseSlug(e.phase))}${bad ? ' bad' : ''}"></i>
1244
- <b class="span-what">${escapeHtml(what)}</b>${subj}${res}
1245
- <span class="span-tags">${tagChips}</span>
1246
- <span class="span-dur">${escapeHtml(fmtMs(e.durationMs))}</span>
1247
- ${expandable ? `<span class="span-detail-btn" aria-hidden="true">${escapeHtml(open ? t('insights.dCollapse') : t('insights.dDetail'))}</span>` : ''}
1248
- </div>
1249
- ${io ? `<div class="spandetail">${io}</div>` : ''}
1250
- </div>`;
1251
- }
1252
- function renderConvoOps(unit, openOps, recByTurn) {
1253
- // An agent turn carries narration ('say': text, no event) + operations (event).
1254
- const sayMsgs = unit.msgs.filter(m => m.text && !m.event);
1255
- const opMsgs = unit.msgs.filter(m => m.event);
1256
- const worst = unit.msgs.some(m => m.severity === 'bad') ? ' sev-bad' : unit.msgs.some(m => m.severity === 'warn') ? ' sev-warn' : '';
1257
- // m.text already carries a trailing '…' when truncated (safeScrubAndTruncate),
1258
- // so don't append another — that double-ellipsis'd the bubble.
1259
- const say = sayMsgs.map(m => `<div class="cbub-say"><div class="md-body">${renderPromptMarkdown(m.text)}</div></div>`).join('');
1260
- const rows = opMsgs.map(m => renderConvoOpRow(m, openOps.has(m.id))).join('');
1261
- return `<div class="cbub cbub-left role-agent cbub-ops${worst}">
1262
- <div class="cbub-head"><span class="tp-label tp-src tp-src-system">🤖 ${escapeHtml(t('insights.replayAgent'))}</span><span class="cbub-turn">#${escapeHtml(String(unit.turnIndex))}</span>${opMsgs.length ? `<span class="cbub-opcount">${escapeHtml(t('insights.replayOps', { count: opMsgs.length }))}</span>` : ''}${convoRecBadges(unit.turnIndex, recByTurn)}</div>
1263
- ${say ? `<div class="cbub-saywrap">${say}</div>` : ''}
1264
- ${rows ? `<div class="cbub-ops-list">${rows}</div>` : ''}
1265
- </div>`;
1266
- }
1267
- function renderConvoThread(convo, recByTurn) {
1268
- if (!convo.messages.length) {
1269
- return convo.loading ? `<p class="mut">${escapeHtml(t('insights.detailLoading'))}</p>` : `<p class="mut">${escapeHtml(t('insights.replayEmpty'))}</p>`;
1270
- }
1271
- const units = groupConvo(convo.messages);
1272
- const bubbles = units.map(u => u.kind === 'prompt' ? renderConvoPrompt(u.msg, recByTurn) : renderConvoOps(u, convo.openOps, recByTurn)).join('');
1273
- const more = convo.hasMore
1274
- ? `<div class="convo-more"><button type="button" class="primary convo-loadmore"${convo.loading ? ' disabled' : ''}>${escapeHtml(convo.loading ? t('insights.detailLoading') : t('insights.replayLoadMore', { shown: convo.messages.length, total: convo.total }))}</button></div>`
1275
- : `<p class="convo-more mut">${escapeHtml(t('insights.replayAllLoaded', { total: convo.total }))}</p>`;
1276
- return `${bubbles}${more}`;
1277
- }
1278
- const CONVO_ROLES = [
1279
- { key: 'all', label: 'spanAll' },
1280
- { key: 'user', label: 'senderHuman' },
1281
- { key: 'a2a_agent', label: 'senderA2A' },
1282
- { key: 'system', label: 'senderSystem' },
1283
- ];
1284
- const CONVO_TAGS = [
1285
- { key: 'all', label: 'spanAll' },
1286
- { key: 'failure', label: 'tag.failure' },
1287
- { key: 'slow', label: 'tag.slow' },
1288
- ];
1289
- function renderConvo(convo, recByTurn) {
1290
- const roleChips = CONVO_ROLES.map(r => `<button type="button" class="spanchip${convo.role === r.key ? ' on' : ''}" data-convo-role="${r.key}">${escapeHtml(r.label.includes('.') ? tagLabel(r.label.split('.')[1]) : t('insights.' + r.label))}</button>`).join('');
1291
- const tagChips = CONVO_TAGS.map(tg => `<button type="button" class="spanchip${convo.tag === tg.key ? ' on' : ''}" data-convo-tag="${tg.key}">${escapeHtml(tg.label.includes('.') ? tagLabel(tg.label.split('.')[1]) : t('insights.' + tg.label))}</button>`).join('');
1292
- return `<div class="convo">
1293
- <div class="convo-controls">
1294
- <input type="search" class="convo-search" placeholder="${escapeHtml(t('insights.replaySearch'))}" value="${escapeHtml(convo.q)}">
1295
- <div class="convo-filters">
1296
- <div class="spanfilter convo-rolefilter"><span class="convo-flabel">${escapeHtml(t('insights.replayBy'))}</span>${roleChips}</div>
1297
- <div class="spanfilter convo-tagfilter"><span class="convo-flabel">${escapeHtml(t('insights.replayState'))}</span>${tagChips}</div>
1298
- </div>
1299
- </div>
1300
- <div class="convothread">${renderConvoThread(convo, recByTurn)}</div>
1301
- </div>`;
1302
- }
1303
- // A turn has no single phase; pick the one its events spent the most time in (a +1 floor so
1304
- // zero-duration events still vote), so the rail node color reflects what the turn mostly did.
1305
- function turnMainPhase(tn) {
430
+ export function turnMainPhase(tn) {
1306
431
  const w = new Map();
1307
432
  for (const e of tn.events ?? []) {
1308
433
  if (!e.phase)
1309
434
  continue;
1310
435
  w.set(e.phase, (w.get(e.phase) ?? 0) + (e.durationMs ?? 0) + 1);
1311
436
  }
1312
- let best = 'discuss', max = -1;
1313
- for (const [p, d] of w)
437
+ let best = 'discuss';
438
+ let max = -1;
439
+ for (const [p, d] of w) {
1314
440
  if (d > max) {
1315
441
  max = d;
1316
442
  best = p;
1317
443
  }
1318
- return best;
1319
- }
1320
- // 会话轨迹 — session-trace mini-map. One clickable node per turn, colored by its dominant phase,
1321
- // badged for failures/slow/recommendation hits, dimmed when a recommendation focus is active (so the
1322
- // cited turns pop). Clicking a node jumps to that turn in the 逐轮对账 ledger. Per codex: no turn-level
1323
- // compaction marker (only a session-level count exists — don't fake per-turn compaction points).
1324
- function renderTurnRail(report, focus, recByTurn) {
1325
- // Serial main line — MUST be in turn order. turnTimeline arrives unsorted (the ledger sorts it too),
1326
- // so sort by turnIndex here or the rail reads as random (用户: 顺序乱).
1327
- const turns = [...(report.turnTimeline ?? [])].sort((a, b) => a.turnIndex - b.turnIndex);
1328
- if (turns.length < 2)
1329
- return '';
1330
- const focused = focus.turnIdx.size > 0;
1331
- const items = turns.map(tn => {
1332
- const m = tn.metrics;
1333
- const phase = turnMainPhase(tn);
1334
- const fail = (tn.tags ?? []).includes('failure') || (tn.events ?? []).some(e => e.status === 'error');
1335
- const slow = (tn.tags ?? []).includes('slow');
1336
- const recHit = recByTurn.has(tn.turnIndex);
1337
- const hot = focus.turnIdx.has(tn.turnIndex);
1338
- const cls = ['railnode', phaseClass(phase), hot ? 'hot' : '', (focused && !hot) ? 'dim' : ''].filter(Boolean).join(' ');
1339
- const tip = `#${tn.turnIndex} · ${phaseLabel(phase)} · ${t('insights.mReads')}${m.reads} ${t('insights.mEdits')}${m.edits} ${t('insights.mRuns')}${m.runs}${m.failures ? ` · ${t('insights.mFailures')}${m.failures}` : ''} · ${fmtMs(m.durationMs)}`;
1340
- const badges = `${fail ? '<i class="rb rb-fail"></i>' : ''}${slow ? '<i class="rb rb-slow"></i>' : ''}${recHit ? '<i class="rb rb-rec"></i>' : ''}`;
1341
- const node = `<button type="button" class="${cls}" data-rail-turn="${escapeHtml(String(tn.turnIndex))}" data-tip="${escapeHtml(tip)}"><span>${escapeHtml(String(tn.turnIndex))}</span>${badges}</button>`;
1342
- // 委派分支: one teal dot per subagent (delegate event) the turn spawned, so the serial main line
1343
- // shows where work branched off — mirrors the reference's 串行主线 + 委派分支.
1344
- const subs = (tn.events ?? []).filter(e => e.kind === 'delegate').length;
1345
- const branch = subs ? `<span class="railbranch" data-tip="${escapeHtml(`#${tn.turnIndex} · ${t('insights.railSubagents', { n: subs })}`)}">${'<i class="rbr-sub"></i>'.repeat(Math.min(subs, 4))}</span>` : '';
1346
- return node + branch;
1347
- }).join('');
1348
- const legend = [
1349
- ...['research', 'edit', 'run', 'delegate', 'discuss'].map(p => `<span class="rl-item"><i class="${phaseClass(p)}"></i>${escapeHtml(phaseLabel(p))}</span>`),
1350
- `<span class="rl-item rl-sep"><i class="rbr-sub"></i>${escapeHtml(t('insights.railSubagent'))}</span>`,
1351
- ].join('');
1352
- return `<section class="block turnrail-block">
1353
- <div class="turnrail-head"><h3>${escapeHtml(t('insights.turnRail'))}</h3><span class="turnrail-legend">${legend}</span></div>
1354
- <div class="turnrail">${items}</div>
1355
- </section>`;
1356
- }
1357
- // 工作时序 — work-timeline Gantt. Each span is a bar positioned by relStartMs and sized by
1358
- // durationMs over the session's real elapsed span, colored by phase, failures/slow highlighted.
1359
- // Idle gaps show as empty track, so it's clear where the wall-clock actually went. Clicking a bar
1360
- // opens that span in the 动作 span tab.
1361
- function renderWorkGantt(report) {
1362
- const timed = (report.spans ?? []).map((s, i) => ({ s, i, start: s.relStartMs ?? 0, dur: Math.max(s.durationMs ?? 0, 0) }))
1363
- .filter(x => Number.isFinite(x.start)).sort((a, b) => a.start - b.start);
1364
- if (timed.length < 2)
1365
- return '';
1366
- // Pack bars by time order, each width ∝ its duration — we deliberately drop real-time idle gaps
1367
- // (a session left open for days would otherwise squish all the active work to the left). This reads
1368
- // as "where the active wall-clock actually went": a 28s Bash is a fat bar, instant reads are slivers.
1369
- const active = Math.max(timed.reduce((sum, x) => sum + x.dur, 0), 1);
1370
- let cursor = 0;
1371
- const bars = timed.map(x => {
1372
- const left = cursor / active * 100;
1373
- const width = Math.max(x.dur / active * 100, 0.3);
1374
- cursor += x.dur;
1375
- const fail = x.s.status === 'error' || (x.s.tags ?? []).includes('failure');
1376
- const slow = (x.s.tags ?? []).includes('slow');
1377
- const cls = ['gbar', phaseClass(x.s.phase), fail ? 'gbar-fail' : '', slow ? 'gbar-slow' : ''].filter(Boolean).join(' ');
1378
- const st = x.s.status === 'error' ? ` · ${tagLabel('failure')}` : '';
1379
- const tip = `#${x.i} · ${x.s.tool} · ${phaseLabel(x.s.phase)} · ${fmtMs(x.s.durationMs)}${st}`;
1380
- return `<button type="button" class="${cls}" style="left:${left.toFixed(3)}%;width:${width.toFixed(3)}%" data-gantt-span="${x.i}" data-tip="${escapeHtml(tip)}"></button>`;
1381
- }).join('');
1382
- const realSpan = (timed[timed.length - 1].start + timed[timed.length - 1].dur) - timed[0].start;
1383
- return `<section class="block gantt-block">
1384
- <div class="turnrail-head"><h3>${escapeHtml(t('insights.gantt'))}</h3><span class="gantt-cap">${escapeHtml(t('insights.ganttCaption', { span: timed.length, dur: fmtMs(realSpan), active: fmtMs(active) }))}</span></div>
1385
- <div class="gantt"><div class="gtrack">${bars}</div></div>
1386
- </section>`;
1387
- }
1388
- // 上下文曲线 — context-pressure line. Plots codex's per-turn `context.contextTokens` (input + cache
1389
- // read + cache create = the size pushed into the model that turn) so the climb-then-drop at a
1390
- // compaction is visible from the curve shape. Backend-optional: only CLIs with usage carry context,
1391
- // and only on detail=spans — so this whole block hides when there are <2 points. No explicit
1392
- // turn-level compaction markers (codex: only a session-level count exists, don't fake per-turn ones).
1393
- function renderContextCurve(report) {
1394
- const pts = (report.turnTimeline ?? [])
1395
- .map(tn => (tn.context && Number.isFinite(tn.context.contextTokens)) ? { turn: tn.turnIndex, v: tn.context.contextTokens } : null)
1396
- .filter((p) => p !== null)
1397
- .sort((a, b) => a.turn - b.turn);
1398
- if (pts.length < 2)
1399
- return '';
1400
- const max = Math.max(...pts.map(p => p.v), 1);
1401
- const W = 100, H = 40, n = pts.length;
1402
- const xs = (i) => (i / (n - 1)) * W;
1403
- const ys = (v) => H - 1 - (v / max) * (H - 2);
1404
- const line = pts.map((p, i) => `${xs(i).toFixed(2)},${ys(p.v).toFixed(2)}`).join(' ');
1405
- const area = `0,${H} ${line} ${W},${H}`;
1406
- // Invisible full-height hover bands per point — hovering anywhere in a turn's x-slice shows that
1407
- // turn's exact context tokens via the shared tooltip (用户: hover 要有具体数值).
1408
- const band = W / n;
1409
- const hits = pts.map((p, i) => `<rect class="ctxhit" x="${Math.max(0, xs(i) - band / 2).toFixed(2)}" y="0" width="${band.toFixed(2)}" height="${H}" data-tip="${escapeHtml(`${t('insights.ctxTurn', { n: p.turn })} · ${fmtInt(p.v)} tok`)}"></rect>`).join('');
1410
- const mid = fmtInt(Math.round(max / 2));
1411
- return `<section class="block ctxcurve-block">
1412
- <div class="turnrail-head"><h3>${escapeHtml(t('insights.ctxCurve'))}</h3><span class="gantt-cap">${escapeHtml(t('insights.ctxCaption', { peak: fmtInt(max), turns: pts.length }))}</span></div>
1413
- <div class="ctxchart">
1414
- <div class="ctxyaxis"><span>${escapeHtml(fmtInt(max))}</span><span>${escapeHtml(mid)}</span><span>0 tok</span></div>
1415
- <div class="ctxplot">
1416
- <svg class="ctxcurve" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="${escapeHtml(t('insights.ctxCurve'))}">
1417
- <polygon class="ctxarea" points="${area}"/>
1418
- <polyline class="ctxline" points="${line}"/>
1419
- ${hits}
1420
- </svg>
1421
- <div class="ctxxaxis"><span>${escapeHtml(t('insights.ctxTurn', { n: pts[0].turn }))}</span><span>${escapeHtml(t('insights.ctxTurn', { n: pts[pts.length - 1].turn }))}</span></div>
1422
- </div>
1423
- </div>
1424
- </section>`;
1425
- }
1426
- // Shared hover tooltip: any [data-tip] element inside a bound host (rail / gantt / context curve)
1427
- // shows its text in the persistent #insight-tip box, tracking the cursor. Native `title` is too slow
1428
- // and unreliable on the thin bars (用户: 工作时序 hover 没提示).
1429
- function bindTip(host, tipEl) {
1430
- host.addEventListener('mousemove', e => {
1431
- const el = e.target.closest('[data-tip]');
1432
- if (!el) {
1433
- tipEl.hidden = true;
1434
- return;
1435
- }
1436
- tipEl.textContent = el.getAttribute('data-tip') || '';
1437
- tipEl.hidden = false;
1438
- const pad = 14;
1439
- const r = tipEl.getBoundingClientRect();
1440
- let x = e.clientX + pad, y = e.clientY + pad;
1441
- if (x + r.width > window.innerWidth)
1442
- x = e.clientX - r.width - pad;
1443
- if (y + r.height > window.innerHeight)
1444
- y = e.clientY - r.height - pad;
1445
- tipEl.style.left = `${Math.max(4, x)}px`;
1446
- tipEl.style.top = `${Math.max(4, y)}px`;
1447
- });
1448
- host.addEventListener('mouseleave', () => { tipEl.hidden = true; });
1449
- }
1450
- function renderDetailBody(report, view) {
1451
- if (report.status !== 'ok') {
1452
- return `<p class="mut">${escapeHtml(safeStatus(report))}</p>`;
1453
444
  }
1454
- const a = report.agg;
1455
- const focus = focusSets(report, view.activeId);
1456
- const recByTurn = new Map();
1457
- for (const r of report.recommendations ?? [])
1458
- for (const ti of r.evidence?.turnIndexes ?? []) {
1459
- const arr = recByTurn.get(ti) ?? [];
1460
- if (!arr.includes(r.id))
1461
- arr.push(r.id);
1462
- recByTurn.set(ti, arr);
1463
- }
1464
- const meta = [
1465
- report.meta?.asOf ? t('sessions.insightAsOf', { asOf: String(report.meta.asOf) }) : '',
1466
- report.meta?.partial ? t('sessions.insightPartial') : '',
1467
- report.meta?.capped ? t('sessions.insightCapped', { shown: String(report.meta.spansReturned ?? report.spans?.length ?? 0), total: String(report.meta.spansTotal ?? report.spans?.length ?? 0) }) : '',
1468
- ].filter(Boolean).join(' · ');
1469
- const spanCount = report.spans?.length ?? 0;
1470
- const turnTotal = report.turnTimeline?.length ?? 0;
1471
- return `
1472
- <div class="cards insight-detail-metrics">
1473
- ${renderMetric(t('insights.metricSpans'), fmtInt(a.totalSpans))}
1474
- ${renderMetric(t('insights.metricFailed'), fmtInt(a.failedSpans))}
1475
- ${renderMetric(t('insights.metricSlow'), fmtInt(a.slowSpans))}
1476
- ${renderMetric(t('insights.metricRw'), a.readWriteRatio === null ? '-' : a.readWriteRatio.toFixed(1))}
1477
- </div>
1478
- ${meta ? `<p class="insight-meta">${escapeHtml(meta)}</p>` : ''}
1479
- <section class="block recblock">
1480
- <h3>${escapeHtml(t('insights.recommendations'))}</h3>
1481
- ${renderRecommendations(report, view.activeId)}
1482
- </section>
1483
- ${renderTurnRail(report, focus, recByTurn)}
1484
- ${renderWorkGantt(report)}
1485
- ${renderSubagents(report)}
1486
- ${renderContextCurve(report)}
1487
- <div class="detailtabs">
1488
- <div class="detailtabbar" role="tablist" aria-label="${escapeHtml(t('insights.detailTabs'))}">
1489
- <button type="button" role="tab" data-tab="spans" class="${view.tab === 'spans' ? 'on' : ''}">${escapeHtml(t('insights.trace'))} <b>${spanCount}</b></button>
1490
- <button type="button" role="tab" data-tab="ledger" class="${view.tab === 'ledger' ? 'on' : ''}">${escapeHtml(t('insights.ledger'))} <b>${turnTotal}</b></button>
1491
- <button type="button" role="tab" data-tab="convo" class="${view.tab === 'convo' ? 'on' : ''}">${escapeHtml(t('insights.replay'))}</button>
1492
- </div>
1493
- <div class="detailtabbody">
1494
- <div class="insight-tab-panel" data-panel="spans"${view.tab === 'spans' ? '' : ' hidden'}>${renderEvidence(report, focus, view.spanFilter, view.openSpans)}</div>
1495
- <div class="insight-tab-panel" data-panel="ledger"${view.tab === 'ledger' ? '' : ' hidden'}>${renderTurnEfficiency(report, focus, view.openTurns, view.openSpans, view.ledgerSort, view.openPrompts, view.rawPrompts, view.ledgerSender)}</div>
1496
- <div class="insight-tab-panel" data-panel="convo"${view.tab === 'convo' ? '' : ' hidden'}>${renderConvo(view.convo, recByTurn)}</div>
1497
- </div>
1498
- </div>`;
445
+ return best;
1499
446
  }
1500
- async function fetchDetail(sessionId) {
1501
- const r = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/insight?detail=spans`, { cache: 'no-store' });
1502
- const d = await r.json().catch(() => ({}));
1503
- if (!r.ok || d?.ok === false)
1504
- throw new Error(String(d?.error ?? r.status));
1505
- return d.report;
447
+ export function buildInsightScope(timeWin, project, showNoise) {
448
+ const w = TIME_WINDOWS.find(x => x.key === timeWin);
449
+ const sinceMs = w && w.days > 0 ? Date.now() - w.days * 86400000 : undefined;
450
+ return { project: project || undefined, sinceMs, analyzableOnly: !showNoise };
1506
451
  }
1507
- // URL state (deep-link + refresh-stable): the insights view state lives in the hash
1508
- // query after #/insights (e.g. #/insights?tab=dist&project=botmux&sess=<id>). Written
1509
- // via history.replaceState (no router re-run), read back on (re)mount.
1510
452
  export function parseInsightsHash() {
1511
453
  const h = typeof location !== 'undefined' ? (location.hash || '') : '';
1512
454
  const qi = h.indexOf('?');
@@ -1517,7 +459,7 @@ export function parseInsightsHash() {
1517
459
  out[k] = v;
1518
460
  return out;
1519
461
  }
1520
- function buildInsightsHash(p) {
462
+ export function buildInsightsHash(p) {
1521
463
  const sp = new URLSearchParams();
1522
464
  for (const [k, v] of Object.entries(p))
1523
465
  if (v)
@@ -1525,734 +467,53 @@ function buildInsightsHash(p) {
1525
467
  const q = sp.toString();
1526
468
  return '#/insights' + (q ? `?${q}` : '');
1527
469
  }
1528
- const INSIGHT_FILTERS = ['all', 'review', 'failed', 'slow'];
1529
- const INSIGHT_TAB_KEYS = ['overview', 'sessions', 'flow', 'dist', 'hot'];
1530
- const SESS_SORT_KEYS = ['recent', 'review', 'spans', 'fails', 'slow', 'agent'];
1531
470
  export function initialInsightTab() {
1532
471
  const hp = parseInsightsHash();
1533
472
  return INSIGHT_TAB_KEYS.includes(hp.tab) ? hp.tab : 'overview';
1534
473
  }
1535
- export function wireInsightsPage(root) {
1536
- const hp = parseInsightsHash();
1537
- let overviewData = null;
1538
- let records = [];
1539
- let filter = INSIGHT_FILTERS.includes(hp.filter) ? hp.filter : 'all';
1540
- const cliFilter = new Set((hp.cli ?? '').split(',').filter(Boolean));
1541
- let q = hp.q ?? '';
1542
- let selectedId = null;
1543
- let initialSess = hp.sess ?? null;
1544
- let activeRec = null;
1545
- let detailReport = null;
1546
- let detailTab = 'spans';
1547
- let spanFilter = 'all';
1548
- let openSpans = new Set();
1549
- let openTurns = new Set();
1550
- let ledgerSort = 'normal';
1551
- let ledgerSender = 'all';
1552
- let openPrompts = new Set();
1553
- let rawPrompts = new Set();
1554
- const newConvo = () => ({ messages: [], total: 0, hasMore: false, nextOffset: 0, loading: false, q: '', role: 'all', tag: 'all', openOps: new Set() });
1555
- let convo = newConvo();
1556
- let modalTurn = null;
1557
- let modalRaw = false;
1558
- let modalPrompt = null;
1559
- let modalReq = 0;
1560
- let disposed = false;
1561
- let tab = INSIGHT_TAB_KEYS.includes(hp.tab) ? hp.tab : 'overview';
1562
- let project = hp.project ?? '';
1563
- let timeWin = hp.time ?? 'all';
1564
- let showNoise = hp.noise === '1';
1565
- let sessSort = SESS_SORT_KEYS.includes(hp.sort) ? hp.sort : 'recent';
1566
- let sessLayout = hp.layout === 'table' ? 'table' : 'card';
1567
- const openHot = new Set();
1568
- let paletteOpen = false;
1569
- let paletteQ = '';
1570
- let paletteIdx = 0;
1571
- const status = root.querySelector('#insight-status');
1572
- const overviewEl = root.querySelector('#insight-overview');
1573
- const list = root.querySelector('#insight-list');
1574
- const listSubtitle = root.querySelector('#insight-list-subtitle');
1575
- const detail = root.querySelector('#insight-detail');
1576
- const search = root.querySelector('input[name=q]');
1577
- search.value = q;
1578
- const refreshBtn = root.querySelector('#insight-refresh');
1579
- const filterButtons = [...root.querySelectorAll('[data-filter]')];
1580
- const cliFilterEl = root.querySelector('#insight-cli-filter');
1581
- const flowEl = root.querySelector('#insight-flow');
1582
- const distEl = root.querySelector('#insight-dist');
1583
- const hotEl = root.querySelector('#insight-hot');
1584
- const projectSel = root.querySelector('#insight-project');
1585
- const timeSel = root.querySelector('#insight-time');
1586
- const noiseToggle = root.querySelector('#insight-noise');
1587
- const clearBtn = root.querySelector('#insight-clear');
1588
- const tabbar = root.querySelector('#insight-tabbar');
1589
- const panels = [...root.querySelectorAll('.insight-panel')];
1590
- const paletteEl = root.querySelector('#insight-palette');
1591
- const paletteOpenBtn = root.querySelector('#insight-palette-open');
1592
- const listView = root.querySelector('#insight-list-view');
1593
- const detailView = root.querySelector('#insight-detail-view');
1594
- const backBtn = root.querySelector('#insight-back');
1595
- const sortBar = root.querySelector('#insight-sort');
1596
- // Sessions tab is list ⇆ full-width detail (not a cramped side panel): selecting
1597
- // a session swaps to the detail view; 返回 clears it back to the list.
1598
- function showSessionsView() {
1599
- const detailMode = selectedId !== null;
1600
- listView.hidden = detailMode;
1601
- detailView.hidden = !detailMode;
1602
- }
1603
- function scopeOpts() {
1604
- const w = TIME_WINDOWS.find(x => x.key === timeWin);
1605
- const sinceMs = w && w.days > 0 ? Date.now() - w.days * 86400000 : undefined;
1606
- return { project: project || undefined, sinceMs, analyzableOnly: !showNoise };
1607
- }
1608
- // Mirror current view state into the URL hash (deep-link + refresh-stable) without
1609
- // triggering the router (replaceState fires no hashchange).
1610
- function syncHash() {
1611
- const p = {};
1612
- if (tab !== 'overview')
1613
- p.tab = tab;
1614
- if (filter !== 'all')
1615
- p.filter = filter;
1616
- if (q.trim())
1617
- p.q = q.trim();
1618
- if (project)
1619
- p.project = project;
1620
- if (timeWin !== 'all')
1621
- p.time = timeWin;
1622
- if (cliFilter.size)
1623
- p.cli = [...cliFilter].join(',');
1624
- if (sessSort !== 'recent')
1625
- p.sort = sessSort;
1626
- if (sessLayout !== 'card')
1627
- p.layout = sessLayout;
1628
- if (showNoise)
1629
- p.noise = '1';
1630
- if (selectedId)
1631
- p.sess = selectedId;
1632
- try {
1633
- history.replaceState(null, '', buildInsightsHash(p));
1634
- }
1635
- catch { /* ignore */ }
1636
- }
1637
- function paletteItems() {
1638
- const ql = paletteQ.trim().toLowerCase();
1639
- const tabs = INSIGHT_TABS
1640
- .map(tb => ({ type: 'tab', key: tb.key, label: t(tb.label), sub: t('insights.paletteTabs') }))
1641
- .filter(it => !ql || it.label.toLowerCase().includes(ql));
1642
- const sess = records
1643
- .filter(r => { const s = r.session; return !ql || `${sessionTitle(s)} ${botDisplayName(s)} ${s.cliId ?? ''}`.toLowerCase().includes(ql); })
1644
- .slice(0, 20)
1645
- .map(r => ({ type: 'session', key: String(r.session.sessionId), label: sessionTitle(r.session), sub: `${botDisplayName(r.session)} · ${r.session.cliId ?? '-'}` }));
1646
- return [...tabs, ...sess];
1647
- }
1648
- function choosePalette(type, key) {
1649
- closePalette();
1650
- if (type === 'tab') {
1651
- tab = key;
1652
- selectedId = null;
1653
- paint();
1654
- }
1655
- else {
1656
- tab = 'sessions';
1657
- showTab();
1658
- void selectSession(key);
1659
- }
1660
- }
1661
- // Re-render ONLY the results list (not the <input>), so typing never rebuilds
1662
- // or re-focuses the input — that would reset the caret and break IME (CJK)
1663
- // composition on every keystroke.
1664
- function paintPaletteList() {
1665
- const list = paletteEl.querySelector('.palette-list');
1666
- if (!list)
1667
- return;
1668
- const items = paletteItems();
1669
- if (paletteIdx >= items.length)
1670
- paletteIdx = Math.max(0, items.length - 1);
1671
- list.innerHTML = items.length ? items.map((it, i) => `<button type="button" class="palette-item${i === paletteIdx ? ' on' : ''}" data-pal-type="${it.type}" data-pal-key="${escapeHtml(it.key)}" data-pal-i="${i}">
1672
- <span class="pal-label">${escapeHtml(it.label)}</span><span class="pal-sub">${escapeHtml(it.sub)}</span>
1673
- </button>`).join('') : `<p class="mut palette-empty">${escapeHtml(t('insights.paletteEmpty'))}</p>`;
1674
- list.querySelectorAll('.palette-item').forEach(btn => btn.addEventListener('click', () => choosePalette(btn.dataset.palType ?? 'tab', btn.dataset.palKey ?? '')));
1675
- list.querySelector('.palette-item.on')?.scrollIntoView({ block: 'nearest' });
1676
- }
1677
- function paintPalette() {
1678
- if (!paletteOpen) {
1679
- paletteEl.hidden = true;
1680
- paletteEl.innerHTML = '';
1681
- document.body.classList.remove('insight-modal-open');
1682
- return;
1683
- }
1684
- paletteEl.hidden = false;
1685
- document.body.classList.add('insight-modal-open');
1686
- // Build the shell once per open. The <input> stays stable across keystrokes;
1687
- // only paintPaletteList() touches the DOM on input/arrow navigation.
1688
- paletteEl.innerHTML = `<div class="modal-backdrop" data-pal-close></div>
1689
- <div class="palette-panel" role="dialog" aria-modal="true">
1690
- <input type="search" class="palette-input" placeholder="${escapeHtml(t('insights.palettePlaceholder'))}" value="${escapeHtml(paletteQ)}">
1691
- <div class="palette-list"></div>
1692
- </div>`;
1693
- const input = paletteEl.querySelector('.palette-input');
1694
- if (input) {
1695
- input.focus();
1696
- input.oninput = () => { paletteQ = input.value; paletteIdx = 0; paintPaletteList(); };
1697
- }
1698
- paletteEl.querySelectorAll('[data-pal-close]').forEach(el => el.addEventListener('click', closePalette));
1699
- paintPaletteList();
1700
- }
1701
- function openPalette() { paletteOpen = true; paletteQ = ''; paletteIdx = 0; paintPalette(); }
1702
- function closePalette() { paletteOpen = false; paintPalette(); }
1703
- // Project <select> options reflect the severity+search+time scoped set (project
1704
- // pick itself NOT applied), so the dropdown always shows reachable projects.
1705
- function paintProjectOptions() {
1706
- const base = filterRecords(records, filter, q, cliFilter, { ...scopeOpts(), project: undefined });
1707
- const opts = projectOptions(base);
1708
- const cur = project;
1709
- projectSel.innerHTML = `<option value="">${escapeHtml(t('insights.projectAll'))}</option>` +
1710
- opts.map(o => `<option value="${escapeHtml(o.id)}"${o.id === cur ? ' selected' : ''}>${escapeHtml(o.id)} (${o.count})</option>`).join('');
1711
- if (cur && !opts.some(o => o.id === cur)) {
1712
- project = '';
1713
- projectSel.value = '';
1714
- }
1715
- }
1716
- function showTab() {
1717
- for (const p of panels)
1718
- p.hidden = p.dataset.tabpanel !== tab;
1719
- for (const b of tabbar.querySelectorAll('[data-itab]')) {
1720
- const on = b.dataset.itab === tab;
1721
- b.classList.toggle('on', on);
1722
- b.setAttribute('aria-selected', String(on));
1723
- }
1724
- }
1725
- function wireSessionButtons(host, jumpToSessions = false) {
1726
- for (const btn of host.querySelectorAll('[data-session-id]')) {
1727
- btn.onclick = () => {
1728
- if (jumpToSessions && tab !== 'sessions') {
1729
- tab = 'sessions';
1730
- showTab();
1731
- paint();
1732
- }
1733
- void selectSession(btn.dataset.sessionId ?? '');
1734
- };
1735
- }
1736
- }
1737
- function currentRows() {
1738
- return sortRecordsBy(filterRecords(records, filter, q, cliFilter, scopeOpts()), sessSort);
1739
- }
1740
- function paint() {
1741
- if (disposed)
1742
- return;
1743
- // Faceted CLI chips reflect the severity+search+scope set (CLI pick itself NOT
1744
- // applied); drop stale CLI picks the other filters emptied out so the list
1745
- // never gets stuck on nothing.
1746
- const cliBase = filterRecords(records, filter, q, new Set(), scopeOpts());
1747
- const present = new Set(cliBase.map(cliIdOf));
1748
- for (const id of [...cliFilter])
1749
- if (!present.has(id))
1750
- cliFilter.delete(id);
1751
- cliFilterEl.innerHTML = renderCliChips(cliBase, cliFilter);
1752
- paintProjectOptions();
1753
- noiseToggle.checked = showNoise;
1754
- timeSel.value = timeWin;
1755
- const rows = currentRows();
1756
- filterButtons.forEach(btn => btn.classList.toggle('active', btn.dataset.filter === filter));
1757
- overviewEl.innerHTML = overviewData ? renderOverview(aggregateRecords(rows)) : '';
1758
- listSubtitle.textContent = t('insights.listCount', { shown: rows.length, total: records.length });
1759
- sortBar.innerHTML = renderSortBar(sessSort, sessLayout);
1760
- // When the analyzable-only default empties the list, point at the toggle so
1761
- // the user isn't stranded on a blank page wondering where their sessions went.
1762
- list.innerHTML = (!rows.length && !showNoise)
1763
- ? `<div class="insight-empty">${escapeHtml(t('insights.empty'))}<br><span class="mut">${escapeHtml(t('insights.emptyAnalyzableHint'))}</span></div>`
1764
- : sessLayout === 'table' ? renderSessionTable(rows, selectedId) : renderSessionRows(rows, selectedId, true);
1765
- const selected = rows.find(r => r.session.sessionId === selectedId) ?? records.find(r => r.session.sessionId === selectedId);
1766
- if (!selectedId || !selected)
1767
- detail.innerHTML = renderDetailShell(undefined);
1768
- wireSessionButtons(list);
1769
- showSessionsView();
1770
- flowEl.innerHTML = overviewData ? renderFlow(rows) : '';
1771
- wireSessionButtons(flowEl, true);
1772
- distEl.innerHTML = overviewData ? renderDistribution(rows) : '';
1773
- hotEl.innerHTML = overviewData ? renderHotspots(rows, openHot) : '';
1774
- wireSessionButtons(hotEl, true);
1775
- showTab();
1776
- syncHash();
1777
- }
1778
- // Re-render the detail body in place (no refetch) after a focus/filter change.
1779
- function paintDetailBody() {
1780
- const body = detail.querySelector('#insight-detail-body');
1781
- if (!body || !detailReport)
1782
- return;
1783
- // A full innerHTML replace recreates the scroll container, snapping the in-panel list back to
1784
- // the top — which read as a page refresh (用户). Preserve the active tab's list scroll (and
1785
- // window scroll) across the re-render so toggling a 详情 drawer feels in-place.
1786
- const sel = `.insight-tab-panel[data-panel="${detailTab}"] .spantable, .insight-tab-panel[data-panel="${detailTab}"] .turnlist, .insight-tab-panel[data-panel="${detailTab}"] .convothread`;
1787
- const prevTop = body.querySelector(sel)?.scrollTop ?? 0;
1788
- const winY = window.scrollY;
1789
- body.innerHTML = renderDetailBody(detailReport, { activeId: activeRec, tab: detailTab, spanFilter, openSpans, openTurns, ledgerSort, openPrompts, rawPrompts, ledgerSender, convo });
1790
- wireDetailBody(body);
1791
- const next = body.querySelector(sel);
1792
- if (next)
1793
- next.scrollTop = prevTop;
1794
- if (window.scrollY !== winY)
1795
- window.scrollTo({ top: winY });
1796
- }
1797
- // 对话回放: fetch one page of the paginated conversation. reset=true replaces (filter/search
1798
- // change), false appends (load-more). Filters/search go through codex's q/role/tag params.
1799
- async function loadConvo(reset) {
1800
- if (!selectedId || convo.loading)
1801
- return;
1802
- if (reset) {
1803
- convo.messages = [];
1804
- convo.nextOffset = 0;
1805
- convo.hasMore = false;
1806
- }
1807
- convo.loading = true;
1808
- if (detailTab === 'convo')
1809
- paintDetailBody();
1810
- const params = new URLSearchParams({ detail: 'conversation', offset: String(convo.nextOffset), limit: '40' });
1811
- if (convo.q)
1812
- params.set('q', convo.q);
1813
- if (convo.role !== 'all')
1814
- params.set('role', convo.role);
1815
- if (convo.tag !== 'all')
1816
- params.set('tag', convo.tag);
1817
- const sid = selectedId;
1818
- try {
1819
- const r = await fetch(`/api/sessions/${encodeURIComponent(sid)}/insight?${params.toString()}`, { cache: 'no-store' });
1820
- if (disposed || sid !== selectedId)
1821
- return;
1822
- const d = await r.json().catch(() => ({}));
1823
- if (disposed || sid !== selectedId)
1824
- return;
1825
- const c = d?.conversation;
1826
- if (c) {
1827
- convo.messages = reset ? (c.messages ?? []) : [...convo.messages, ...(c.messages ?? [])];
1828
- convo.total = c.total ?? convo.messages.length;
1829
- convo.hasMore = !!c.hasMore;
1830
- convo.nextOffset = c.nextOffset ?? (convo.nextOffset + (c.messages?.length ?? 0));
1831
- }
1832
- }
1833
- catch { /* leave what we have */ }
1834
- if (disposed || sid !== selectedId)
1835
- return;
1836
- convo.loading = false;
1837
- if (detailTab === 'convo')
1838
- paintDetailBody();
1839
- }
1840
- // Full-text prompt modal — lives in the persistent page shell (#insight-modal), not in the
1841
- // re-rendered detail body. Prompt text is fetched full on demand (用户: 弹窗别截断) via the
1842
- // per-turn endpoint, since the bulk timeline only carries a 400-char preview.
1843
- function paintModal() {
1844
- const m = root.querySelector('#insight-modal');
1845
- if (!m)
1846
- return;
1847
- if (modalTurn === null) {
1848
- m.hidden = true;
1849
- m.innerHTML = '';
1850
- document.body.classList.remove('insight-modal-open');
1851
- return;
1852
- }
1853
- m.hidden = false;
1854
- document.body.classList.add('insight-modal-open');
1855
- m.innerHTML = modalPrompt
1856
- ? renderPromptModalInner(modalTurn, modalPrompt, modalRaw)
1857
- : `<div class="modal-backdrop" data-modal-close></div><div class="modal-panel"><div class="modal-body"><p class="mut">${escapeHtml(t('insights.detailLoading'))}</p></div></div>`;
1858
- m.querySelectorAll('[data-modal-close]').forEach(el => el.addEventListener('click', closeModal));
1859
- m.querySelector('[data-modal-raw]')?.addEventListener('click', () => { modalRaw = !modalRaw; paintModal(); });
1860
- }
1861
- function closeModal() { modalTurn = null; modalRaw = false; modalPrompt = null; paintModal(); }
1862
- async function openModal(turnIndex) {
1863
- if (!selectedId)
1864
- return;
1865
- modalTurn = turnIndex;
1866
- modalRaw = false;
1867
- modalPrompt = null;
1868
- const req = ++modalReq;
1869
- const sid = selectedId;
1870
- paintModal();
1871
- try {
1872
- const r = await fetch(`/api/sessions/${encodeURIComponent(sid)}/insight/turn/${turnIndex}?offset=0&limit=40000`, { cache: 'no-store' });
1873
- if (req !== modalReq || disposed || sid !== selectedId)
1874
- return;
1875
- const d = await r.json().catch(() => ({}));
1876
- if (req !== modalReq || disposed || sid !== selectedId)
1877
- return;
1878
- modalPrompt = d?.turn?.prompt ?? { text: '', truncated: false };
1879
- }
1880
- catch {
1881
- if (req !== modalReq || disposed || sid !== selectedId)
1882
- return;
1883
- modalPrompt = { text: t('insights.unavailable'), truncated: false };
1884
- }
1885
- paintModal();
1886
- }
1887
- function wireDetailBody(body) {
1888
- const bar = body.querySelector('.detailtabbar');
1889
- bar?.addEventListener('click', e => {
1890
- const btn = e.target.closest('button[data-tab]');
1891
- if (!btn)
1892
- return;
1893
- detailTab = btn.dataset.tab || 'spans';
1894
- bar.querySelectorAll('button[data-tab]').forEach(b => b.classList.toggle('on', b.dataset.tab === detailTab));
1895
- body.querySelectorAll('.insight-tab-panel').forEach(p => { p.hidden = p.dataset.panel !== detailTab; });
1896
- // 对话回放: load the first page lazily on first open.
1897
- if (detailTab === 'convo' && !convo.messages.length && !convo.loading)
1898
- void loadConvo(true);
1899
- });
1900
- // Recommendation cards .hot-light their evidence; clicking the active one clears.
1901
- for (const btn of body.querySelectorAll('.reclist [data-rec]')) {
1902
- btn.addEventListener('click', () => {
1903
- const id = btn.dataset.rec || null;
1904
- activeRec = activeRec === id ? null : id;
1905
- paintDetailBody();
1906
- });
1907
- }
1908
- // 会话轨迹 nodes jump to that turn in the ledger: switch tab, expand it, scroll it into view.
1909
- // Reset to linear sort + a sender filter that keeps the target turn visible (system turns are
1910
- // hidden under the default 全部) so the jump never lands on a turn the ledger filtered away.
1911
- for (const btn of body.querySelectorAll('.turnrail [data-rail-turn]')) {
1912
- btn.addEventListener('click', () => {
1913
- const i = Number(btn.dataset.railTurn);
1914
- const tn = detailReport?.turnTimeline?.find(t => t.turnIndex === i);
1915
- detailTab = 'ledger';
1916
- ledgerSort = 'normal';
1917
- if (tn && turnSenderKind(tn) === 'system')
1918
- ledgerSender = 'system';
1919
- openTurns.add(i);
1920
- paintDetailBody();
1921
- detail.querySelector(`.insight-tab-panel[data-panel="ledger"] [data-turn-card="${i}"]`)
1922
- ?.scrollIntoView({ behavior: 'smooth', block: 'center' });
1923
- });
1924
- }
1925
- // 工作时序 bars open the span in the 动作 span tab (clearing any span filter that would hide it).
1926
- for (const btn of body.querySelectorAll('.gantt [data-gantt-span]')) {
1927
- btn.addEventListener('click', () => {
1928
- const i = Number(btn.dataset.ganttSpan);
1929
- detailTab = 'spans';
1930
- spanFilter = 'all';
1931
- openSpans.add(i);
1932
- paintDetailBody();
1933
- detail.querySelector(`.insight-tab-panel[data-panel="spans"] .sprow-line[data-span-idx="${i}"]`)
1934
- ?.closest('.spanrow')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
1935
- });
1936
- }
1937
- // Shared hover tooltips for the session-trace rail / work-timeline / context curve.
1938
- const tipEl = root.querySelector('#insight-tip');
1939
- if (tipEl)
1940
- for (const host of body.querySelectorAll('.turnrail, .gtrack, .ctxcurve'))
1941
- bindTip(host, tipEl);
1942
- // span tab filter chips (全部/失败/慢/…).
1943
- for (const btn of body.querySelectorAll('.spanfilter [data-spanfilter]')) {
1944
- btn.addEventListener('click', () => { spanFilter = btn.dataset.spanfilter || 'all'; paintDetailBody(); });
1945
- }
1946
- // 详情 drawer toggles — the whole span header line is the target (用户: 点 span 行即展开).
1947
- for (const el of body.querySelectorAll('.sprow-line[data-span-idx]')) {
1948
- const toggle = () => {
1949
- const i = Number(el.dataset.spanIdx);
1950
- if (openSpans.has(i))
1951
- openSpans.delete(i);
1952
- else
1953
- openSpans.add(i);
1954
- paintDetailBody();
1955
- };
1956
- el.addEventListener('click', toggle);
1957
- el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') {
1958
- e.preventDefault();
1959
- toggle();
1960
- } });
1961
- }
1962
- // Per-turn expand/collapse in the timeline.
1963
- for (const btn of body.querySelectorAll('.turn-expand-btn[data-turn]')) {
1964
- btn.addEventListener('click', () => {
1965
- const i = Number(btn.dataset.turn);
1966
- if (openTurns.has(i))
1967
- openTurns.delete(i);
1968
- else
1969
- openTurns.add(i);
1970
- paintDetailBody();
1971
- });
1972
- }
1973
- // 逐轮对账 sort toggle (正常排序 / 按建议分类).
1974
- for (const btn of body.querySelectorAll('.ledgersort [data-ledgersort]')) {
1975
- btn.addEventListener('click', () => { ledgerSort = btn.dataset.ledgersort === 'grouped' ? 'grouped' : 'normal'; paintDetailBody(); });
1976
- }
1977
- // 逐轮对账 发起人 filter (全部 / 人类 / a2a / 其他).
1978
- for (const btn of body.querySelectorAll('.ledgersender [data-ledgersender]')) {
1979
- btn.addEventListener('click', () => { ledgerSender = btn.dataset.ledgersender || 'all'; paintDetailBody(); });
1980
- }
1981
- // Prompt 展开/收起 + 渲染/原文 toggles per turn.
1982
- for (const btn of body.querySelectorAll('.tp-toggle[data-prompt-expand]')) {
1983
- btn.addEventListener('click', () => {
1984
- const i = Number(btn.dataset.promptExpand);
1985
- if (openPrompts.has(i))
1986
- openPrompts.delete(i);
1987
- else
1988
- openPrompts.add(i);
1989
- paintDetailBody();
1990
- });
1991
- }
1992
- for (const btn of body.querySelectorAll('.tp-toggle[data-prompt-raw]')) {
1993
- btn.addEventListener('click', () => {
1994
- const i = Number(btn.dataset.promptRaw);
1995
- if (rawPrompts.has(i))
1996
- rawPrompts.delete(i);
1997
- else
1998
- rawPrompts.add(i);
1999
- paintDetailBody();
2000
- });
2001
- }
2002
- // Prompt 全文弹窗 (用户: 做个弹窗看全文) — fetches the full text on demand.
2003
- for (const btn of body.querySelectorAll('.tp-toggle[data-prompt-full]')) {
2004
- btn.addEventListener('click', () => void openModal(Number(btn.dataset.promptFull)));
2005
- }
2006
- // 对话回放 controls + bubbles.
2007
- const convoSearch = body.querySelector('.convo-search');
2008
- convoSearch?.addEventListener('keydown', e => {
2009
- if (e.key !== 'Enter')
2010
- return;
2011
- e.preventDefault();
2012
- const v = convoSearch.value.trim();
2013
- if (v === convo.q)
2014
- return;
2015
- convo.q = v;
2016
- void loadConvo(true);
2017
- });
2018
- for (const btn of body.querySelectorAll('.convo-rolefilter [data-convo-role]')) {
2019
- btn.addEventListener('click', () => { const k = btn.dataset.convoRole || 'all'; if (k === convo.role)
2020
- return; convo.role = k; void loadConvo(true); });
2021
- }
2022
- for (const btn of body.querySelectorAll('.convo-tagfilter [data-convo-tag]')) {
2023
- btn.addEventListener('click', () => { const k = btn.dataset.convoTag || 'all'; if (k === convo.tag)
2024
- return; convo.tag = k; void loadConvo(true); });
2025
- }
2026
- body.querySelector('.convo-loadmore')?.addEventListener('click', () => void loadConvo(false));
2027
- for (const btn of body.querySelectorAll('.cbub [data-convo-full]')) {
2028
- btn.addEventListener('click', () => void openModal(Number(btn.dataset.convoFull)));
2029
- }
2030
- for (const el of body.querySelectorAll('.cop-line[data-convo-op]')) {
2031
- const toggle = () => { const id = el.dataset.convoOp; if (convo.openOps.has(id))
2032
- convo.openOps.delete(id);
2033
- else
2034
- convo.openOps.add(id); paintDetailBody(); };
2035
- el.addEventListener('click', toggle);
2036
- el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') {
2037
- e.preventDefault();
2038
- toggle();
2039
- } });
2040
- }
2041
- }
2042
- async function selectSession(sessionId) {
2043
- selectedId = sessionId;
2044
- showSessionsView();
2045
- syncHash();
2046
- activeRec = null;
2047
- detailTab = 'spans';
2048
- spanFilter = 'all';
2049
- openSpans = new Set();
2050
- openTurns = new Set();
2051
- ledgerSort = 'normal';
2052
- ledgerSender = 'all';
2053
- openPrompts = new Set();
2054
- rawPrompts = new Set();
2055
- convo = newConvo();
2056
- modalTurn = null;
2057
- modalRaw = false;
2058
- modalPrompt = null;
2059
- paintModal();
2060
- detailReport = null;
2061
- const rec = records.find(r => r.session.sessionId === sessionId);
2062
- detail.innerHTML = renderDetailShell(rec);
2063
- for (const btn of list.querySelectorAll('[data-session-id]')) {
2064
- btn.classList.toggle('on', btn.dataset.sessionId === sessionId);
2065
- }
2066
- const body = detail.querySelector('#insight-detail-body');
2067
- if (!body)
2068
- return;
2069
- try {
2070
- const report = await fetchDetail(sessionId);
2071
- if (!disposed && selectedId === sessionId && report) {
2072
- detailReport = report;
2073
- paintDetailBody();
2074
- }
2075
- }
2076
- catch (e) {
2077
- if (!disposed && selectedId === sessionId)
2078
- body.innerHTML = `<p class="mut">${escapeHtml(String(e))}</p>`;
2079
- }
2080
- }
2081
- async function refresh() {
2082
- refreshBtn.disabled = true;
2083
- status.textContent = t('insights.loading');
2084
- try {
2085
- const r = await fetch('/api/insights/summary?limit=200', { cache: 'no-store' });
2086
- if (disposed)
2087
- return;
2088
- const d = await r.json().catch(() => ({}));
2089
- if (disposed)
2090
- return;
2091
- if (!r.ok || d?.ok === false || !d.overview) {
2092
- overviewData = null;
2093
- records = [];
2094
- status.textContent = `${t('insights.unavailable')}: ${String(d?.error ?? r.status)}`;
2095
- }
2096
- else {
2097
- overviewData = d.overview;
2098
- records = overviewData.sessions.map(toRecord);
2099
- status.textContent = t('insights.loaded', { count: overviewData.meta.analyzedSessions });
2100
- }
2101
- }
2102
- catch (e) {
2103
- if (disposed)
2104
- return;
2105
- overviewData = null;
2106
- records = [];
2107
- status.textContent = `${t('insights.unavailable')}: ${String(e)}`;
2108
- }
2109
- if (disposed)
2110
- return;
2111
- if (selectedId && !records.some(r => r.session.sessionId === selectedId))
2112
- selectedId = null;
2113
- paint();
2114
- refreshBtn.disabled = false;
2115
- // Restore a deep-linked session (load its detail) once records are in.
2116
- const want = initialSess;
2117
- initialSess = null;
2118
- if (want && !selectedId && records.some(r => r.session.sessionId === want))
2119
- void selectSession(want);
2120
- }
2121
- search.oninput = () => { q = search.value; paint(); };
2122
- refreshBtn.onclick = () => void refresh();
2123
- tabbar.addEventListener('click', e => {
2124
- const btn = e.target.closest('[data-itab]');
2125
- if (!btn)
2126
- return;
2127
- tab = btn.dataset.itab || 'overview';
2128
- paint();
2129
- });
2130
- backBtn.addEventListener('click', () => { selectedId = null; showSessionsView(); paint(); });
2131
- sortBar.addEventListener('click', e => {
2132
- const lay = e.target.closest('[data-sesslayout]');
2133
- if (lay) {
2134
- sessLayout = lay.dataset.sesslayout === 'table' ? 'table' : 'card';
2135
- paint();
2136
- return;
2137
- }
2138
- const btn = e.target.closest('[data-sesssort]');
2139
- if (!btn)
2140
- return;
2141
- sessSort = btn.dataset.sesssort || 'recent';
2142
- paint();
2143
- });
2144
- // 分布直方图 → 跳到会话 tab 按该指标排序。
2145
- distEl.addEventListener('click', e => {
2146
- const btn = e.target.closest('[data-distsort]');
2147
- if (!btn)
2148
- return;
2149
- sessSort = btn.dataset.distsort || 'recent';
2150
- tab = 'sessions';
2151
- selectedId = null;
2152
- paint();
2153
- });
2154
- // 项目热点 → 设项目筛选并跳到会话 tab(最慢会话行的 data-session-id 由 wireSessionButtons 处理)。
2155
- hotEl.addEventListener('click', e => {
2156
- const target = e.target;
2157
- // Expand/collapse a recurrence row → reveal its contributing sessions in place.
2158
- const hk = target.closest('[data-hotkey]');
2159
- if (hk) {
2160
- const k = hk.dataset.hotkey || '';
2161
- if (openHot.has(k))
2162
- openHot.delete(k);
2163
- else
2164
- openHot.add(k);
2165
- hotEl.innerHTML = renderHotspots(currentRows(), openHot);
2166
- wireSessionButtons(hotEl, true);
2167
- return;
2168
- }
2169
- const proj = target.closest('[data-hotproject]');
2170
- if (!proj)
2171
- return;
2172
- project = proj.dataset.hotproject || '';
2173
- tab = 'sessions';
2174
- selectedId = null;
2175
- paint();
2176
- });
2177
- projectSel.addEventListener('change', () => { project = projectSel.value; paint(); });
2178
- timeSel.addEventListener('change', () => { timeWin = timeSel.value; paint(); });
2179
- noiseToggle.addEventListener('change', () => { showNoise = noiseToggle.checked; paint(); });
2180
- clearBtn.addEventListener('click', () => {
2181
- q = '';
2182
- search.value = '';
2183
- filter = 'all';
2184
- cliFilter.clear();
2185
- project = '';
2186
- timeWin = 'all';
2187
- showNoise = false;
2188
- paint();
2189
- });
2190
- for (const btn of filterButtons) {
2191
- btn.onclick = () => {
2192
- filter = btn.dataset.filter || 'all';
2193
- paint();
2194
- };
2195
- }
2196
- cliFilterEl.addEventListener('click', e => {
2197
- const btn = e.target.closest('[data-clifilter]');
2198
- if (!btn)
2199
- return;
2200
- const key = btn.dataset.clifilter || 'all';
2201
- if (key === 'all')
2202
- cliFilter.clear();
2203
- else if (cliFilter.has(key))
2204
- cliFilter.delete(key);
2205
- else
2206
- cliFilter.add(key);
2207
- paint();
2208
- });
2209
- paletteOpenBtn.addEventListener('click', openPalette);
2210
- // ⌘K opens the palette; Esc closes palette/modal; arrows + Enter drive the palette.
2211
- const onKey = (e) => {
2212
- if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) {
2213
- e.preventDefault();
2214
- if (paletteOpen)
2215
- closePalette();
2216
- else
2217
- openPalette();
2218
- return;
2219
- }
2220
- if (paletteOpen) {
2221
- // While an IME (CJK) composition is active, Enter/Arrow confirm or move the
2222
- // candidate list — don't hijack them to select/close the palette. keyCode 229
2223
- // is the legacy "composition in progress" signal for browsers without isComposing.
2224
- if (e.isComposing || e.keyCode === 229)
2225
- return;
2226
- const items = paletteItems();
2227
- if (e.key === 'Escape') {
2228
- e.preventDefault();
2229
- closePalette();
2230
- }
2231
- else if (e.key === 'ArrowDown') {
2232
- e.preventDefault();
2233
- paletteIdx = Math.min(items.length - 1, paletteIdx + 1);
2234
- paintPaletteList();
2235
- }
2236
- else if (e.key === 'ArrowUp') {
2237
- e.preventDefault();
2238
- paletteIdx = Math.max(0, paletteIdx - 1);
2239
- paintPaletteList();
2240
- }
2241
- else if (e.key === 'Enter') {
2242
- e.preventDefault();
2243
- const it = items[paletteIdx];
2244
- if (it)
2245
- choosePalette(it.type, it.key);
2246
- }
2247
- return;
2248
- }
2249
- if (e.key === 'Escape' && modalTurn !== null)
2250
- closeModal();
474
+ export async function fetchInsightsSummary() {
475
+ const r = await fetch('/api/insights/summary?limit=200', { cache: 'no-store' });
476
+ const d = await r.json().catch(() => ({}));
477
+ if (!r.ok || d?.ok === false || !d.overview)
478
+ throw new Error(String(d?.error ?? r.status));
479
+ return d.overview;
480
+ }
481
+ export async function fetchInsightDetail(sessionId) {
482
+ const r = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/insight?detail=spans`, { cache: 'no-store' });
483
+ const d = await r.json().catch(() => ({}));
484
+ if (!r.ok || d?.ok === false)
485
+ throw new Error(String(d?.error ?? r.status));
486
+ return d.report;
487
+ }
488
+ export async function fetchInsightConversation(sessionId, opts) {
489
+ const params = new URLSearchParams({ detail: 'conversation', offset: String(opts.offset), limit: String(opts.limit) });
490
+ if (opts.q)
491
+ params.set('q', opts.q);
492
+ if (opts.role && opts.role !== 'all')
493
+ params.set('role', opts.role);
494
+ if (opts.tag && opts.tag !== 'all')
495
+ params.set('tag', opts.tag);
496
+ const r = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/insight?${params.toString()}`, { cache: 'no-store' });
497
+ const d = await r.json().catch(() => ({}));
498
+ if (!r.ok || d?.ok === false)
499
+ throw new Error(String(d?.error ?? r.status));
500
+ const c = d?.conversation;
501
+ return {
502
+ messages: c?.messages ?? [],
503
+ total: c?.total ?? 0,
504
+ hasMore: !!c?.hasMore,
505
+ nextOffset: c?.nextOffset ?? opts.offset + (c?.messages?.length ?? 0),
2251
506
  };
2252
- document.addEventListener('keydown', onKey);
2253
- void loadNameMaps().then(() => { if (!disposed)
2254
- paint(); });
2255
- void refresh();
2256
- return () => { disposed = true; document.removeEventListener('keydown', onKey); document.body.classList.remove('insight-modal-open'); };
507
+ }
508
+ export async function fetchTurnPrompt(sessionId, turnIndex) {
509
+ const r = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/insight/turn/${turnIndex}?offset=0&limit=40000`, { cache: 'no-store' });
510
+ const d = await r.json().catch(() => ({}));
511
+ if (!r.ok || d?.ok === false)
512
+ throw new Error(String(d?.error ?? r.status));
513
+ return d?.turn?.prompt ?? { text: '', truncated: false };
514
+ }
515
+ export function sessionMetaLine(rec) {
516
+ const s = rec.session;
517
+ return `${botDisplayName(s)} · ${String(s.cliId ?? '-')}${s.workingDir ? ` · ${projectOf(rec)}` : ''} · ${relTime(s.lastMessageAt ?? s.spawnedAt ?? 0)}`;
2257
518
  }
2258
519
  //# sourceMappingURL=insights.js.map