octo-agent 0.11.2

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 (319) hide show
  1. checksums.yaml +7 -0
  2. data/.clacky/skills/commit/SKILL.md +423 -0
  3. data/.clacky/skills/gem-release/SKILL.md +199 -0
  4. data/.clacky/skills/gem-release/scripts/release.sh +304 -0
  5. data/.clacky/skills/oss-upload/SKILL.md +47 -0
  6. data/.octorules +106 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +8 -0
  9. data/CHANGELOG.md +76 -0
  10. data/CODE_OF_CONDUCT.md +132 -0
  11. data/CONTRIBUTING.md +92 -0
  12. data/Dockerfile +28 -0
  13. data/LICENSE.txt +22 -0
  14. data/POSITIONING.md +46 -0
  15. data/README.md +134 -0
  16. data/README_CN.md +134 -0
  17. data/Rakefile +34 -0
  18. data/benchmark/fixtures/sample_project/Gemfile +3 -0
  19. data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
  20. data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
  21. data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
  22. data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
  23. data/benchmark/results/EVALUATION_REPORT.md +165 -0
  24. data/benchmark/results/baseline_20260511_174424.json +128 -0
  25. data/benchmark/results/report_20260511_175256.json +271 -0
  26. data/benchmark/results/report_20260511_175444.json +271 -0
  27. data/benchmark/results/treatment_20260511_175103.json +130 -0
  28. data/benchmark/runner.rb +441 -0
  29. data/bin/octo +7 -0
  30. data/docs/agent-first-ui-design.md +77 -0
  31. data/docs/billing-system.md +318 -0
  32. data/docs/channel-architecture.md +235 -0
  33. data/docs/engineering-article.md +343 -0
  34. data/docs/session-skill-invocation.md +69 -0
  35. data/docs/time_machine_design.md +247 -0
  36. data/docs/ui2-architecture.md +124 -0
  37. data/homebrew/README.md +96 -0
  38. data/homebrew/openocto.rb +24 -0
  39. data/lib/octo/agent/hook_manager.rb +61 -0
  40. data/lib/octo/agent/llm_caller.rb +800 -0
  41. data/lib/octo/agent/memory_updater.rb +246 -0
  42. data/lib/octo/agent/message_compressor.rb +225 -0
  43. data/lib/octo/agent/message_compressor_helper.rb +869 -0
  44. data/lib/octo/agent/next_message_suggester.rb +215 -0
  45. data/lib/octo/agent/session_serializer.rb +685 -0
  46. data/lib/octo/agent/skill_auto_creator.rb +114 -0
  47. data/lib/octo/agent/skill_evolution.rb +61 -0
  48. data/lib/octo/agent/skill_manager.rb +466 -0
  49. data/lib/octo/agent/skill_reflector.rb +89 -0
  50. data/lib/octo/agent/system_prompt_builder.rb +101 -0
  51. data/lib/octo/agent/time_machine.rb +214 -0
  52. data/lib/octo/agent/tool_executor.rb +454 -0
  53. data/lib/octo/agent/tool_registry.rb +150 -0
  54. data/lib/octo/agent.rb +2180 -0
  55. data/lib/octo/agent_config.rb +989 -0
  56. data/lib/octo/agent_profile.rb +112 -0
  57. data/lib/octo/anthropic_stream_aggregator.rb +137 -0
  58. data/lib/octo/background_task_registry.rb +324 -0
  59. data/lib/octo/banner.rb +34 -0
  60. data/lib/octo/bedrock_stream_aggregator.rb +137 -0
  61. data/lib/octo/block_font.rb +331 -0
  62. data/lib/octo/cli.rb +968 -0
  63. data/lib/octo/client.rb +623 -0
  64. data/lib/octo/default_agents/SOUL.md +3 -0
  65. data/lib/octo/default_agents/USER.md +1 -0
  66. data/lib/octo/default_agents/base_prompt.md +66 -0
  67. data/lib/octo/default_agents/coding/profile.yml +2 -0
  68. data/lib/octo/default_agents/coding/system_prompt.md +67 -0
  69. data/lib/octo/default_agents/general/profile.yml +2 -0
  70. data/lib/octo/default_agents/general/system_prompt.md +16 -0
  71. data/lib/octo/default_parsers/doc_parser.rb +69 -0
  72. data/lib/octo/default_parsers/docx_parser.rb +188 -0
  73. data/lib/octo/default_parsers/pdf_parser.rb +120 -0
  74. data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
  75. data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
  76. data/lib/octo/default_parsers/pptx_parser.rb +140 -0
  77. data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
  78. data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
  79. data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
  80. data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
  81. data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
  82. data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
  83. data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
  84. data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
  85. data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
  86. data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
  87. data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
  88. data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
  89. data/lib/octo/default_skills/onboard/SKILL.md +578 -0
  90. data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
  91. data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
  92. data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
  93. data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
  94. data/lib/octo/default_skills/personal-website/publish.rb +235 -0
  95. data/lib/octo/default_skills/product-help/SKILL.md +123 -0
  96. data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
  97. data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
  98. data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
  99. data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
  100. data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
  101. data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
  102. data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
  103. data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
  104. data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
  105. data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
  106. data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
  107. data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
  108. data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
  109. data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
  110. data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
  111. data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
  112. data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
  113. data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
  114. data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
  115. data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
  116. data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
  117. data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
  118. data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
  119. data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
  120. data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
  121. data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
  122. data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
  123. data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
  124. data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
  125. data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  126. data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
  127. data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
  128. data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
  129. data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
  130. data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
  131. data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
  132. data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
  133. data/lib/octo/idle_compression_timer.rb +115 -0
  134. data/lib/octo/json_ui_controller.rb +204 -0
  135. data/lib/octo/message_format/anthropic.rb +409 -0
  136. data/lib/octo/message_format/bedrock.rb +361 -0
  137. data/lib/octo/message_format/open_ai.rb +222 -0
  138. data/lib/octo/message_history.rb +373 -0
  139. data/lib/octo/openai_stream_aggregator.rb +130 -0
  140. data/lib/octo/plain_ui_controller.rb +166 -0
  141. data/lib/octo/providers.rb +534 -0
  142. data/lib/octo/server/browser_manager.rb +397 -0
  143. data/lib/octo/server/channel/adapters/base.rb +82 -0
  144. data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
  145. data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
  146. data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
  147. data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
  148. data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
  149. data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
  150. data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
  151. data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
  152. data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
  153. data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
  154. data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
  155. data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
  156. data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
  157. data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
  158. data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
  159. data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
  160. data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
  161. data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
  162. data/lib/octo/server/channel/channel_config.rb +178 -0
  163. data/lib/octo/server/channel/channel_manager.rb +468 -0
  164. data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
  165. data/lib/octo/server/channel.rb +33 -0
  166. data/lib/octo/server/discover.rb +77 -0
  167. data/lib/octo/server/epipe_safe_io.rb +105 -0
  168. data/lib/octo/server/http_server.rb +3554 -0
  169. data/lib/octo/server/scheduler.rb +317 -0
  170. data/lib/octo/server/server_master.rb +325 -0
  171. data/lib/octo/server/session_registry.rb +431 -0
  172. data/lib/octo/server/web_ui_controller.rb +487 -0
  173. data/lib/octo/session_manager.rb +385 -0
  174. data/lib/octo/skill.rb +466 -0
  175. data/lib/octo/skill_loader.rb +328 -0
  176. data/lib/octo/tools/base.rb +118 -0
  177. data/lib/octo/tools/browser.rb +625 -0
  178. data/lib/octo/tools/edit.rb +165 -0
  179. data/lib/octo/tools/file_reader.rb +549 -0
  180. data/lib/octo/tools/glob.rb +162 -0
  181. data/lib/octo/tools/grep.rb +356 -0
  182. data/lib/octo/tools/invoke_skill.rb +96 -0
  183. data/lib/octo/tools/list_tasks.rb +54 -0
  184. data/lib/octo/tools/redo_task.rb +41 -0
  185. data/lib/octo/tools/request_user_feedback.rb +84 -0
  186. data/lib/octo/tools/security.rb +333 -0
  187. data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
  188. data/lib/octo/tools/terminal/persistent_session.rb +268 -0
  189. data/lib/octo/tools/terminal/safe_rm.sh +106 -0
  190. data/lib/octo/tools/terminal/session_manager.rb +213 -0
  191. data/lib/octo/tools/terminal.rb +1828 -0
  192. data/lib/octo/tools/todo_manager.rb +374 -0
  193. data/lib/octo/tools/trash_manager.rb +388 -0
  194. data/lib/octo/tools/undo_task.rb +35 -0
  195. data/lib/octo/tools/web_fetch.rb +242 -0
  196. data/lib/octo/tools/web_search.rb +260 -0
  197. data/lib/octo/tools/write.rb +77 -0
  198. data/lib/octo/ui2/block_font.rb +10 -0
  199. data/lib/octo/ui2/components/base_component.rb +163 -0
  200. data/lib/octo/ui2/components/command_suggestions.rb +290 -0
  201. data/lib/octo/ui2/components/common_component.rb +96 -0
  202. data/lib/octo/ui2/components/inline_input.rb +226 -0
  203. data/lib/octo/ui2/components/input_area.rb +1338 -0
  204. data/lib/octo/ui2/components/message_component.rb +99 -0
  205. data/lib/octo/ui2/components/modal_component.rb +419 -0
  206. data/lib/octo/ui2/components/todo_area.rb +149 -0
  207. data/lib/octo/ui2/components/tool_component.rb +107 -0
  208. data/lib/octo/ui2/components/welcome_banner.rb +139 -0
  209. data/lib/octo/ui2/layout_manager.rb +807 -0
  210. data/lib/octo/ui2/line_editor.rb +363 -0
  211. data/lib/octo/ui2/markdown_renderer.rb +100 -0
  212. data/lib/octo/ui2/output_buffer.rb +370 -0
  213. data/lib/octo/ui2/progress_handle.rb +362 -0
  214. data/lib/octo/ui2/progress_indicator.rb +55 -0
  215. data/lib/octo/ui2/screen_buffer.rb +273 -0
  216. data/lib/octo/ui2/terminal_detector.rb +119 -0
  217. data/lib/octo/ui2/theme_manager.rb +85 -0
  218. data/lib/octo/ui2/themes/base_theme.rb +105 -0
  219. data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
  220. data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
  221. data/lib/octo/ui2/thinking_verbs.rb +26 -0
  222. data/lib/octo/ui2/ui_controller.rb +1625 -0
  223. data/lib/octo/ui2/view_renderer.rb +177 -0
  224. data/lib/octo/ui2.rb +40 -0
  225. data/lib/octo/ui_interface.rb +154 -0
  226. data/lib/octo/utils/arguments_parser.rb +191 -0
  227. data/lib/octo/utils/browser_detector.rb +195 -0
  228. data/lib/octo/utils/encoding.rb +92 -0
  229. data/lib/octo/utils/environment_detector.rb +140 -0
  230. data/lib/octo/utils/file_ignore_helper.rb +170 -0
  231. data/lib/octo/utils/file_processor.rb +601 -0
  232. data/lib/octo/utils/gitignore_parser.rb +154 -0
  233. data/lib/octo/utils/limit_stack.rb +152 -0
  234. data/lib/octo/utils/logger.rb +124 -0
  235. data/lib/octo/utils/login_shell.rb +72 -0
  236. data/lib/octo/utils/model_pricing.rb +646 -0
  237. data/lib/octo/utils/parser_manager.rb +165 -0
  238. data/lib/octo/utils/path_helper.rb +15 -0
  239. data/lib/octo/utils/scripts_manager.rb +59 -0
  240. data/lib/octo/utils/string_matcher.rb +158 -0
  241. data/lib/octo/utils/trash_directory.rb +112 -0
  242. data/lib/octo/utils/workspace_rules.rb +46 -0
  243. data/lib/octo/version.rb +5 -0
  244. data/lib/octo/web/app.css +7141 -0
  245. data/lib/octo/web/app.js +543 -0
  246. data/lib/octo/web/apple-touch-icon.png +0 -0
  247. data/lib/octo/web/auth.js +150 -0
  248. data/lib/octo/web/channels.js +276 -0
  249. data/lib/octo/web/datepicker.js +205 -0
  250. data/lib/octo/web/favicon.png +0 -0
  251. data/lib/octo/web/i18n.js +1073 -0
  252. data/lib/octo/web/icon-512.png +0 -0
  253. data/lib/octo/web/icon-dark.svg +25 -0
  254. data/lib/octo/web/icon.svg +29 -0
  255. data/lib/octo/web/index.html +871 -0
  256. data/lib/octo/web/marked.min.js +69 -0
  257. data/lib/octo/web/onboard.js +491 -0
  258. data/lib/octo/web/profile.js +442 -0
  259. data/lib/octo/web/sessions.js +4421 -0
  260. data/lib/octo/web/settings.js +913 -0
  261. data/lib/octo/web/sidebar.js +32 -0
  262. data/lib/octo/web/skills.js +885 -0
  263. data/lib/octo/web/tasks.js +297 -0
  264. data/lib/octo/web/theme.js +105 -0
  265. data/lib/octo/web/trash.js +343 -0
  266. data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
  267. data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
  268. data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
  269. data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  270. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  271. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  272. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  273. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  274. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  275. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  276. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  277. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  278. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  279. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  280. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  281. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  282. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  283. data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  284. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  285. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  286. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  287. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  288. data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  289. data/lib/octo/web/vendor/katex/katex.min.css +1 -0
  290. data/lib/octo/web/vendor/katex/katex.min.js +1 -0
  291. data/lib/octo/web/version.js +449 -0
  292. data/lib/octo/web/weixin-qr.html +209 -0
  293. data/lib/octo/web/ws-dispatcher.js +357 -0
  294. data/lib/octo/web/ws.js +128 -0
  295. data/lib/octo.rb +145 -0
  296. data/scripts/build/build.sh +329 -0
  297. data/scripts/build/lib/apt.sh +56 -0
  298. data/scripts/build/lib/brew.sh +89 -0
  299. data/scripts/build/lib/colors.sh +17 -0
  300. data/scripts/build/lib/gem.sh +95 -0
  301. data/scripts/build/lib/mise.sh +125 -0
  302. data/scripts/build/lib/network.sh +157 -0
  303. data/scripts/build/lib/os.sh +57 -0
  304. data/scripts/build/lib/shell.sh +37 -0
  305. data/scripts/build/src/install.sh.cc +174 -0
  306. data/scripts/build/src/install_browser.sh.cc +101 -0
  307. data/scripts/build/src/install_full.sh.cc +290 -0
  308. data/scripts/build/src/install_rails_deps.sh.cc +145 -0
  309. data/scripts/build/src/install_system_deps.sh.cc +123 -0
  310. data/scripts/build/src/uninstall.sh.cc +101 -0
  311. data/scripts/install.ps1 +532 -0
  312. data/scripts/install.sh +567 -0
  313. data/scripts/install_browser.sh +479 -0
  314. data/scripts/install_full.sh +838 -0
  315. data/scripts/install_rails_deps.sh +746 -0
  316. data/scripts/install_system_deps.sh +518 -0
  317. data/scripts/uninstall.sh +287 -0
  318. data/sig/octo.rbs +4 -0
  319. metadata +614 -0
@@ -0,0 +1,4421 @@
1
+ // ── Sessions — session state, rendering, message cache ────────────────────
2
+ //
3
+ // Responsibilities:
4
+ // - Maintain the canonical sessions list
5
+ // - session_list (WS) is used ONLY on initial connect to populate the list
6
+ // - After that, the list is maintained locally:
7
+ // add: from POST /api/sessions response
8
+ // update: from session_update WS event
9
+ // remove: from session_deleted WS event
10
+ // - Render the session sidebar list
11
+ // - Manage per-session message DOM cache (fast panel switch)
12
+ // - Select / deselect sessions — panel switching is delegated to Router
13
+ // - Load message history via GET /api/sessions/:id/messages (cursor pagination)
14
+ //
15
+ // Depends on: WS (ws.js), Router (app.js), global $ / escapeHtml helpers
16
+ // ─────────────────────────────────────────────────────────────────────────
17
+
18
+ const Sessions = (() => {
19
+ const _sessions = []; // [{ id, name, status, total_tasks }]
20
+ const _historyState = {}; // { [session_id]: { hasMore, oldestCreatedAt, loading, loaded } }
21
+ const _renderedCreatedAt = {}; // { [session_id]: Set<number> } — dedup by created_at
22
+ let _activeId = null;
23
+ let _hasMore = false; // unified pagination: are there older sessions to load?
24
+ let _loadingMore = false;
25
+ // Search state
26
+ const _filter = { q: "", date: "", type: "" }; // committed filter (applied to server)
27
+ let _searchOpen = false; // is the search panel visible?
28
+ let _cronView = false; // are we in the cron sub-view?
29
+ let _cronCount = 0; // total cron sessions from server
30
+ let _pendingRunTaskId = null; // session_id waiting to send "run_task" after subscribe
31
+ let _pendingMessage = null; // { session_id, content } — slash command to send after subscribe
32
+ // Buffer for tool_stdout lines that arrive before history has finished rendering.
33
+ // This happens on session switch: WS replay fires before the HTTP history fetch completes.
34
+ // Flushed in _fetchHistory after the fragment is appended to the DOM.
35
+ let _pendingStdoutLines = null; // string[] | null
36
+
37
+ // Buffer for a "diff" event that arrives BEFORE its owning tool_call event.
38
+ // The server emits diff during show_tool_preview (edit/write) which runs BEFORE
39
+ // show_tool_call, so when the diff arrives _liveLastToolItem is null (the previous
40
+ // tool_result cleared it). Falling back to the last DOM .tool-item would clobber an
41
+ // unrelated tool card (e.g. a preceding Read). Instead, hold the diff and flush it
42
+ // onto the next .tool-item created by appendToolCall.
43
+ let _pendingDiff = null; // { text: string, truncated: boolean } | null
44
+
45
+ // Ghost-text suggestion state. Populated by the "next_message_suggestion"
46
+ // WS event after each agent turn completes. Rendered as the textarea's
47
+ // placeholder; press Tab on an empty input to accept.
48
+ let _suggestionText = null;
49
+ let _defaultPlaceholder = null; // captured lazily so i18n changes still work
50
+
51
+ // ── Markdown renderer ──────────────────────────────────────────────────
52
+ //
53
+ // Renders assistant message text as Markdown HTML using the marked library.
54
+ // Thinking blocks (<think>...</think>) are extracted first, then the remaining
55
+ // text is parsed as Markdown, and the rendered segments are reassembled.
56
+
57
+ function _renderMarkdown(rawText) {
58
+ if (!rawText) return "";
59
+
60
+ const OPEN_TAG = "<think>";
61
+ const CLOSE_TAG = "</think>";
62
+
63
+ // Split the raw text into alternating [text, think, text, think, ...] segments.
64
+ // We extract <think> blocks BEFORE markdown parsing so they render verbatim,
65
+ // not as markdown.
66
+ const segments = []; // { type: "text"|"think", content: string }
67
+ let rest = rawText;
68
+
69
+ while (rest.includes(OPEN_TAG)) {
70
+ const openIdx = rest.indexOf(OPEN_TAG);
71
+ const closeIdx = rest.indexOf(CLOSE_TAG, openIdx + OPEN_TAG.length);
72
+
73
+ // Text before <think>
74
+ if (openIdx > 0) segments.push({ type: "text", content: rest.slice(0, openIdx) });
75
+
76
+ if (closeIdx === -1) {
77
+ // Unclosed <think> — treat remainder as plain text
78
+ segments.push({ type: "text", content: rest.slice(openIdx) });
79
+ rest = "";
80
+ break;
81
+ }
82
+
83
+ const thinkContent = rest.slice(openIdx + OPEN_TAG.length, closeIdx);
84
+ segments.push({ type: "think", content: thinkContent });
85
+ // Strip leading newlines immediately after </think>
86
+ rest = rest.slice(closeIdx + CLOSE_TAG.length).replace(/^\n+/, "");
87
+ }
88
+ if (rest) segments.push({ type: "text", content: rest });
89
+
90
+ // Render each segment and join
91
+ let html = "";
92
+ segments.forEach(seg => {
93
+ if (seg.type === "think") {
94
+ // Thinking content: render as markdown too (it may have code blocks etc.)
95
+ const thinkHtml = _markedParse(seg.content);
96
+ html += _buildThinkingBlock(thinkHtml);
97
+ } else {
98
+ html += _markedParse(seg.content);
99
+ }
100
+ });
101
+
102
+ return html;
103
+ }
104
+
105
+ // Run marked on a text string. Returns HTML. Falls back to escaped plain text
106
+ // if the marked library is unavailable.
107
+ function _markedParse(text) {
108
+ if (!text) return "";
109
+
110
+ // Extract math BEFORE marked so backslashes / underscores survive intact.
111
+ const math = [];
112
+ const PLACEHOLDER = (i) => `\u0000KTX${i}\u0000`;
113
+ let prepared = _extractMath(text, math, PLACEHOLDER);
114
+
115
+ let html;
116
+ if (typeof marked !== "undefined") {
117
+ const renderer = new marked.Renderer();
118
+ renderer.link = function({ href, title, text }) {
119
+ const titleAttr = title ? ` title="${title}"` : "";
120
+ return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
121
+ };
122
+ // Override code block rendering: apply syntax highlighting + header with
123
+ // language label and copy button.
124
+ renderer.code = function({ text: code, lang }) {
125
+ const language = (lang || "").split(/\s+/)[0]; // strip extra info after lang
126
+ const highlighted = _highlightCode(code, language);
127
+ const displayLang = language || "text";
128
+ return (
129
+ `<div class="code-block">` +
130
+ `<div class="code-block-header">` +
131
+ `<span class="code-block-lang">${escapeHtml(displayLang)}</span>` +
132
+ `<button type="button" class="code-block-copy" aria-label="${I18n.t("chat.copy")}" title="${I18n.t("chat.copy")}">` +
133
+ `<svg class="code-copy-icon" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
134
+ `<path fill="currentColor" d="M10 1H4a2 2 0 0 0-2 2v8h1.5V3a.5.5 0 0 1 .5-.5h6V1zm3 3H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm.5 10a.5.5 0 0 1-.5.5H6a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5v8z"/>` +
135
+ `</svg>` +
136
+ `<svg class="code-copy-icon-check" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
137
+ `<path fill="currentColor" d="M13.5 3.5 6 11 2.5 7.5 1 9l5 5 9-9z"/>` +
138
+ `</svg>` +
139
+ `</button>` +
140
+ `</div>` +
141
+ `<pre><code class="hljs${language ? ` language-${escapeHtml(language)}` : ""}">${highlighted}</code></pre>` +
142
+ `</div>`
143
+ );
144
+ };
145
+ html = marked.parse(prepared, { breaks: true, gfm: true, renderer });
146
+ } else {
147
+ html = escapeHtml(prepared).replace(/\n/g, "<br>");
148
+ }
149
+
150
+ if (math.length) {
151
+ html = html.replace(/\u0000KTX(\d+)\u0000/g, (_, i) => _renderMath(math[+i]));
152
+ }
153
+ return html;
154
+ }
155
+
156
+ // Apply highlight.js to a code string. Returns highlighted HTML (already escaped
157
+ // by hljs). Falls back to plain escaped text if hljs is unavailable.
158
+ function _highlightCode(code, language) {
159
+ if (typeof hljs === "undefined") return escapeHtml(code);
160
+ if (language && hljs.getLanguage(language)) {
161
+ try {
162
+ return hljs.highlight(code, { language, ignoreIllegals: true }).value;
163
+ } catch (_) { /* fall through */ }
164
+ }
165
+ // Auto-detect when no language specified or language not recognized
166
+ try {
167
+ return hljs.highlightAuto(code).value;
168
+ } catch (_) {
169
+ return escapeHtml(code);
170
+ }
171
+ }
172
+
173
+ // Pull $$...$$, \[...\], $...$, \(...\) out of `text` and replace each with a
174
+ // sentinel placeholder so marked won't mangle the LaTeX source. The matched
175
+ // segments are pushed (with display flag) onto `out` for later KaTeX rendering.
176
+ function _extractMath(text, out, placeholder) {
177
+ // Order matters: longest/most-specific delimiters first.
178
+ const patterns = [
179
+ { re: /\$\$([\s\S]+?)\$\$/g, display: true },
180
+ { re: /\\\[([\s\S]+?)\\\]/g, display: true },
181
+ { re: /\\\(([\s\S]+?)\\\)/g, display: false },
182
+ // Inline $...$: avoid $$, escaped \$, and prevent crossing newlines/blanks.
183
+ { re: /(^|[^\$])\$(?!\s)([^\$\n]+?)(?<!\s)\$(?!\d)/g, display: false, hasPrefix: true },
184
+ ];
185
+ let result = text;
186
+ for (const { re, display, hasPrefix } of patterns) {
187
+ result = result.replace(re, (m, a, b) => {
188
+ const body = hasPrefix ? b : a;
189
+ const idx = out.length;
190
+ out.push({ body, display });
191
+ return (hasPrefix ? a : "") + placeholder(idx);
192
+ });
193
+ }
194
+ return result;
195
+ }
196
+
197
+ function _renderMath({ body, display }) {
198
+ if (typeof katex === "undefined") {
199
+ return `<code>${escapeHtml((display ? "$$" : "$") + body + (display ? "$$" : "$"))}</code>`;
200
+ }
201
+ try {
202
+ return katex.renderToString(body, {
203
+ displayMode: display,
204
+ throwOnError: false,
205
+ output: "html",
206
+ });
207
+ } catch (e) {
208
+ return `<code class="katex-error">${escapeHtml(body)}</code>`;
209
+ }
210
+ }
211
+
212
+ // Build the collapsible thinking block HTML for a given rendered-HTML content string.
213
+ // Called by _renderMarkdown after the think-block content has been parsed by marked.
214
+ function _buildThinkingBlock(renderedHtml) {
215
+ return `<details class="thinking-block" open>` +
216
+ `<summary class="thinking-summary">` +
217
+ `<span class="thinking-chevron">›</span>` +
218
+ `<span class="thinking-label">Thoughts</span>` +
219
+ `</summary>` +
220
+ `<div class="thinking-body">${renderedHtml}</div>` +
221
+ `</details>`;
222
+ }
223
+
224
+ // ── Private helpers ────────────────────────────────────────────────────
225
+
226
+ function _cacheActiveMessages() {
227
+ // No-op: DOM is no longer cached. History is re-fetched from API on every switch.
228
+ }
229
+
230
+ function _restoreMessages(id) {
231
+ // Clear the pane and dedup state; history will be re-fetched from API.
232
+ $("messages").innerHTML = "";
233
+ delete _renderedCreatedAt[id];
234
+ if (_historyState[id]) {
235
+ _historyState[id].oldestCreatedAt = null;
236
+ _historyState[id].hasMore = true;
237
+ _historyState[id].loading = false; // reset so next fetch is not skipped
238
+ }
239
+ // Reset scroll tracking when switching sessions
240
+ _userScrolledUp = false;
241
+ }
242
+
243
+ // ── Auto-scroll helper ─────────────────────────────────────────────────
244
+ //
245
+ // Track whether user has manually scrolled up. If they haven't, always auto-scroll.
246
+ // If they have, only auto-scroll when they scroll back to bottom themselves.
247
+ //
248
+ // This solves the issue where rapid content streaming causes scrollHeight to grow
249
+ // faster than scrollTop can catch up, incorrectly triggering the "not at bottom" check.
250
+
251
+ let _userScrolledUp = false; // true if user manually scrolled away from bottom
252
+
253
+ function _isAtBottom(container) {
254
+ if (!container) return false;
255
+ const threshold = 150;
256
+ return container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
257
+ }
258
+
259
+ function _scrollToBottomIfNeeded(container) {
260
+ if (!container) return;
261
+ // Only auto-scroll if user hasn't manually scrolled up
262
+ // Once they scroll up, stop auto-scrolling until they scroll back to bottom themselves
263
+ if (!_userScrolledUp) {
264
+ container.scrollTop = container.scrollHeight;
265
+ _hideNewMessageBanner();
266
+ } else {
267
+ _showNewMessageBanner();
268
+ }
269
+ }
270
+
271
+ // ── New message notification banner ────────────────────────────────────
272
+ //
273
+ // Shows a floating "New messages ↓" banner when new messages arrive and
274
+ // user is not at the bottom of the message list. Clicking the banner
275
+ // scrolls to bottom and hides it.
276
+
277
+ function _showNewMessageBanner() {
278
+ const banner = $("new-message-banner");
279
+ if (!banner) return;
280
+ banner.style.display = "block";
281
+ }
282
+
283
+ function _hideNewMessageBanner() {
284
+ const banner = $("new-message-banner");
285
+ if (!banner) return;
286
+ banner.style.display = "none";
287
+ }
288
+
289
+ // ── Empty-state hint ──────────────────────────────────────────────────
290
+ //
291
+ // Shows a small centered hint inside #messages when the message list is
292
+ // empty (e.g. just-created session with no history). Uses a MutationObserver
293
+ // so we don't have to instrument every append/clear call site.
294
+
295
+ const _EMPTY_HINT_ID = "chat-empty-hint";
296
+
297
+ function _buildEmptyHintHtml() {
298
+ const title = I18n.t("chat.empty.title");
299
+ const subtitle = I18n.t("chat.empty.subtitle");
300
+ const tip1 = I18n.t("chat.empty.tip1");
301
+ const tip2 = I18n.t("chat.empty.tip2");
302
+ const tip3 = I18n.t("chat.empty.tip3");
303
+ return `
304
+ <div class="chat-empty-icon" aria-hidden="true">
305
+ <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
306
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
307
+ </svg>
308
+ </div>
309
+ <div class="chat-empty-title">${escapeHtml(title)}</div>
310
+ <div class="chat-empty-subtitle">${escapeHtml(subtitle)}</div>
311
+ <ul class="chat-empty-tips">
312
+ <li>${escapeHtml(tip1)}</li>
313
+ <li>${escapeHtml(tip2)}</li>
314
+ <li>${escapeHtml(tip3)}</li>
315
+ </ul>
316
+ `;
317
+ }
318
+
319
+ function _updateEmptyHint() {
320
+ const messages = $("messages");
321
+ if (!messages) return;
322
+ // Check if there's any real content besides the hint itself
323
+ const hasReal = Array.from(messages.children).some(
324
+ (el) => el.id !== _EMPTY_HINT_ID
325
+ );
326
+ const existing = document.getElementById(_EMPTY_HINT_ID);
327
+ // While history is still loading, don't flash the hint — wait until the
328
+ // first fetch completes so we know whether the session is actually empty.
329
+ const loading = !!(_activeId && _historyState[_activeId] && _historyState[_activeId].loading);
330
+ if (hasReal || loading) {
331
+ if (existing) existing.remove();
332
+ } else {
333
+ if (!existing) {
334
+ const el = document.createElement("div");
335
+ el.id = _EMPTY_HINT_ID;
336
+ el.className = "chat-empty-hint";
337
+ el.innerHTML = _buildEmptyHintHtml();
338
+ messages.appendChild(el);
339
+ }
340
+ }
341
+ }
342
+
343
+ function _initEmptyHint() {
344
+ const messages = $("messages");
345
+ if (!messages) return;
346
+ // Re-evaluate whenever children change (append/insertBefore/innerHTML="")
347
+ const observer = new MutationObserver(() => _updateEmptyHint());
348
+ observer.observe(messages, { childList: true });
349
+ // Re-render hint text on language change
350
+ document.addEventListener("langchange", () => {
351
+ const existing = document.getElementById(_EMPTY_HINT_ID);
352
+ if (existing) existing.innerHTML = _buildEmptyHintHtml();
353
+ });
354
+ // Initial paint
355
+ _updateEmptyHint();
356
+ }
357
+
358
+ function _initNewMessageBanner() {
359
+ const banner = $("new-message-banner");
360
+ const messages = $("messages");
361
+ if (!banner || !messages) return;
362
+
363
+ // Click to scroll to bottom
364
+ banner.addEventListener("click", () => {
365
+ messages.scrollTop = messages.scrollHeight;
366
+ _userScrolledUp = false;
367
+ _hideNewMessageBanner();
368
+ });
369
+
370
+ // Detect actual user scroll interactions (wheel, touch, keyboard)
371
+ // These fire BEFORE the scroll event, so we can set the flag reliably.
372
+ const detectUserScroll = (e) => {
373
+ // Only flag if user is scrolling up (negative deltaY = scroll up)
374
+ // For wheel events: deltaY < 0 means scroll up
375
+ // For touch/keyboard: check scroll position in the scroll event
376
+ const isWheelUp = e.type === "wheel" && e.deltaY < 0;
377
+ const isKeyboardUp = e.type === "keydown" && (e.key === "ArrowUp" || e.key === "PageUp" || e.key === "Home");
378
+
379
+ if (isWheelUp || isKeyboardUp) {
380
+ _userScrolledUp = true;
381
+ }
382
+ };
383
+
384
+ messages.addEventListener("wheel", detectUserScroll, { passive: true });
385
+ messages.addEventListener("keydown", detectUserScroll);
386
+
387
+ // For touch devices: touchmove doesn't tell us direction, so check in scroll event
388
+ let touchStartY = 0;
389
+ messages.addEventListener("touchstart", (e) => {
390
+ touchStartY = e.touches[0].clientY;
391
+ }, { passive: true });
392
+
393
+ messages.addEventListener("touchmove", (e) => {
394
+ const touchDeltaY = e.touches[0].clientY - touchStartY;
395
+ // touchDeltaY > 0 means finger moved down = content scrolls up
396
+ if (touchDeltaY > 5) {
397
+ _userScrolledUp = true;
398
+ }
399
+ }, { passive: true });
400
+
401
+ // Monitor scroll position: clear flag when user reaches bottom
402
+ messages.addEventListener("scroll", () => {
403
+ if (_isAtBottom(messages)) {
404
+ _userScrolledUp = false;
405
+ _hideNewMessageBanner();
406
+ }
407
+ });
408
+ }
409
+
410
+ // ── New session controls (split button + welcome + modal) ──────────────
411
+ //
412
+ // Wires up every button/interaction that kicks off session creation:
413
+ // - "+ New Session" inline split-button (quick create)
414
+ // - "▾" arrow button (opens dropdown → advanced options modal)
415
+ // - "+ New Session" big button on the welcome screen
416
+ // - New Session Modal: close / cancel / create / overlay click / browse
417
+ // - Load-more button (rendered dynamically by renderList)
418
+ //
419
+ // All elements below are static in index.html and therefore must exist —
420
+ // we call addEventListener directly (no ?. / no `if` guards). If any is
421
+ // missing, it means HTML and JS drifted and we want the loud error.
422
+ function _initNewSessionControls() {
423
+ // Split button: main (quick create)
424
+ document.getElementById("btn-new-session-inline")
425
+ .addEventListener("click", () => Sessions.create("general"));
426
+
427
+ // Split button: arrow (toggle dropdown)
428
+ document.getElementById("btn-new-session-arrow")
429
+ .addEventListener("click", (e) => {
430
+ e.stopPropagation();
431
+ const dd = document.getElementById("new-session-dropdown");
432
+ dd.hidden = !dd.hidden;
433
+ });
434
+
435
+ // Dropdown item "Advanced Options…" — delegated because the dropdown
436
+ // panel may be re-rendered; this keeps the binding stable.
437
+ document.addEventListener("click", (e) => {
438
+ if (e.target && e.target.id === "btn-new-session-modal") {
439
+ e.stopPropagation();
440
+ document.getElementById("new-session-dropdown").hidden = true;
441
+ Sessions.openNewSessionModal();
442
+ }
443
+ });
444
+
445
+ // Close dropdown when clicking anywhere else
446
+ document.addEventListener("click", () => {
447
+ const dd = document.getElementById("new-session-dropdown");
448
+ if (dd && !dd.hidden) dd.hidden = true;
449
+ });
450
+
451
+ // Welcome screen "+ New Session" button
452
+ document.getElementById("btn-welcome-new")
453
+ .addEventListener("click", () => Sessions.create("general"));
454
+
455
+ // Modal: cancel / create / overlay click
456
+ document.getElementById("new-session-cancel")
457
+ .addEventListener("click", () => Sessions.closeNewSessionModal());
458
+ document.getElementById("new-session-create")
459
+ .addEventListener("click", () => Sessions.createFromModal());
460
+ document.getElementById("new-session-modal")
461
+ .addEventListener("click", (e) => {
462
+ // Only close when the click lands on the overlay itself, not on
463
+ // the inner dialog panel.
464
+ if (e.target.id === "new-session-modal") {
465
+ Sessions.closeNewSessionModal();
466
+ }
467
+ });
468
+
469
+ // (Removed dead binding for `new-session-browse-btn` — no such element
470
+ // exists in index.html. Originally guarded by `if ($(...))`; deleting the
471
+ // defense exposed that it never ran. Native file-browser picker is not
472
+ // implemented on the web UI — users type a path directly.)
473
+
474
+ // Load-more sessions button is rendered dynamically by renderList(),
475
+ // so we listen via event delegation.
476
+ document.addEventListener("click", (e) => {
477
+ if (e.target && e.target.id === "btn-load-more-sessions") {
478
+ Sessions.loadMore();
479
+ }
480
+ });
481
+ }
482
+
483
+ // ── Composer: attachments, send button, and sendMessage ────────────────
484
+ //
485
+ // Everything below is the "composer" — the input box at the bottom of
486
+ // the chat panel and the user-attached image/file pipeline. It owns:
487
+ // - In-memory staging buffers for pending images and files (_pendingImages / _pendingFiles)
488
+ // - Client-side image compression (scale down + progressive JPEG quality)
489
+ // - File upload via POST /api/upload (documents only, not images)
490
+ // - Preview strip rendering (image thumbnails + file cards)
491
+ // - Drag-drop, paste, and "+ attach" button → file pipeline
492
+ // - sendMessage() — assembles content + files and dispatches over WS
493
+ //
494
+ // Scope: everything here is strictly session-scoped. The pending buffers
495
+ // are cleared on each send. There is no "draft" persistence across sessions.
496
+ //
497
+ // Bindings set up by _initComposer() — wired in Sessions.init() below.
498
+
499
+ const _pendingImages = [];
500
+ const _pendingFiles = [];
501
+ const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB — hard reject before compression
502
+ const MAX_IMAGE_BYTES_SEND = 512 * 1024; // 512 KB — target after compression
503
+ const MAX_IMAGE_LONG_EDGE = 1920; // px — scale down if larger
504
+ const MAX_FILE_BYTES = 32 * 1024 * 1024; // 32 MB
505
+ const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
506
+ const ACCEPTED_DOC_TYPES = [
507
+ "application/pdf",
508
+ "application/zip",
509
+ "application/x-zip-compressed",
510
+ "application/gzip",
511
+ "application/x-gzip",
512
+ "application/x-tar",
513
+ "application/x-compressed-tar",
514
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
515
+ "application/msword", // .doc
516
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
517
+ "application/vnd.ms-excel", // .xls
518
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
519
+ "application/vnd.ms-powerpoint", // .ppt
520
+ "text/csv", // .csv
521
+ "application/csv", // .csv (some browsers)
522
+ "text/markdown", // .md
523
+ "text/x-markdown", // .md (some browsers)
524
+ "text/plain", // .md / .txt (many browsers report this)
525
+ ];
526
+
527
+ // Extension-based fallback for files whose MIME type is missing or unreliable.
528
+ // Browsers frequently report "" or "application/octet-stream" for .md / .tar.gz.
529
+ const ACCEPTED_DOC_EXTENSIONS = [
530
+ ".pdf", ".zip",
531
+ ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
532
+ ".csv",
533
+ ".md", ".markdown", ".txt", ".log",
534
+ ".tar", ".gz", ".tgz", ".tar.gz", ".rar", ".7z"
535
+ ];
536
+
537
+ function _hasAcceptedDocExt(filename) {
538
+ const lower = (filename || "").toLowerCase();
539
+ return ACCEPTED_DOC_EXTENSIONS.some(ext => lower.endsWith(ext));
540
+ }
541
+
542
+ function _isAcceptedDoc(file) {
543
+ if (!file) return false;
544
+ if (file.type && ACCEPTED_DOC_TYPES.includes(file.type)) return true;
545
+ return _hasAcceptedDocExt(file.name);
546
+ }
547
+
548
+ function _isAcceptedImage(file) {
549
+ if (!file) return false;
550
+ return ACCEPTED_IMAGE_TYPES.includes(file.type);
551
+ }
552
+
553
+ function _isAcceptedFile(file) {
554
+ return _isAcceptedImage(file) || _isAcceptedDoc(file);
555
+ }
556
+
557
+ function _docTypeIcon(mimeType, filename) {
558
+ const lower = (filename || "").toLowerCase();
559
+ if (mimeType === "application/pdf" || lower.endsWith(".pdf")) return "📄";
560
+ if (mimeType === "application/zip" || mimeType === "application/x-zip-compressed" || lower.endsWith(".zip")) return "🗜️";
561
+ if (mimeType === "application/gzip" || mimeType === "application/x-gzip" ||
562
+ mimeType === "application/x-tar" || mimeType === "application/x-compressed-tar" ||
563
+ lower.endsWith(".tar") || lower.endsWith(".gz") || lower.endsWith(".tgz") || lower.endsWith(".tar.gz") ||
564
+ lower.endsWith(".rar") || lower.endsWith(".7z")) return "🗜️";
565
+ if ((mimeType && mimeType.includes("wordprocessingml")) || mimeType === "application/msword" ||
566
+ lower.endsWith(".doc") || lower.endsWith(".docx")) return "📝";
567
+ if ((mimeType && mimeType.includes("spreadsheetml")) || mimeType === "application/vnd.ms-excel" ||
568
+ lower.endsWith(".xls") || lower.endsWith(".xlsx")) return "📊";
569
+ if ((mimeType && mimeType.includes("presentationml")) || mimeType === "application/vnd.ms-powerpoint" ||
570
+ lower.endsWith(".ppt") || lower.endsWith(".pptx")) return "📋";
571
+ if (mimeType === "text/csv" || mimeType === "application/csv" || lower.endsWith(".csv")) return "📊";
572
+ if (mimeType === "text/markdown" || mimeType === "text/x-markdown" ||
573
+ lower.endsWith(".md") || lower.endsWith(".markdown")) return "📝";
574
+ if (mimeType === "text/plain" || lower.endsWith(".txt") || lower.endsWith(".log")) return "📄";
575
+ return "📎";
576
+ }
577
+
578
+ // Compress an image File/Blob to a data URL within MAX_IMAGE_BYTES_SEND.
579
+ // Strategy: scale down to MAX_IMAGE_LONG_EDGE, then reduce JPEG quality until small enough.
580
+ // GIF is not compressible via Canvas — rendered as JPEG (LLMs only see first frame anyway).
581
+ function _compressImage(file) {
582
+ return new Promise((resolve, reject) => {
583
+ const reader = new FileReader();
584
+ reader.onerror = () => reject(new Error("Failed to read image"));
585
+ reader.onload = e => {
586
+ const img = new Image();
587
+ img.onerror = () => reject(new Error("Failed to decode image"));
588
+ img.onload = () => {
589
+ // Scale down if needed
590
+ let { width, height } = img;
591
+ if (width > MAX_IMAGE_LONG_EDGE || height > MAX_IMAGE_LONG_EDGE) {
592
+ const ratio = Math.min(MAX_IMAGE_LONG_EDGE / width, MAX_IMAGE_LONG_EDGE / height);
593
+ width = Math.round(width * ratio);
594
+ height = Math.round(height * ratio);
595
+ }
596
+
597
+ const canvas = document.createElement("canvas");
598
+ canvas.width = width;
599
+ canvas.height = height;
600
+ const ctx = canvas.getContext("2d");
601
+ ctx.drawImage(img, 0, 0, width, height);
602
+
603
+ // Try decreasing quality until under limit
604
+ let quality = 0.85;
605
+ let dataUrl = canvas.toDataURL("image/jpeg", quality);
606
+ while (dataUrl.length * 0.75 > MAX_IMAGE_BYTES_SEND && quality > 0.2) {
607
+ quality -= 0.1;
608
+ dataUrl = canvas.toDataURL("image/jpeg", quality);
609
+ }
610
+ resolve(dataUrl);
611
+ };
612
+ img.src = e.target.result;
613
+ };
614
+ reader.readAsDataURL(file);
615
+ });
616
+ }
617
+
618
+ function _addImageFile(file) {
619
+ if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
620
+ alert(`Unsupported image type: ${file.type}\nSupported: PNG, JPEG, GIF, WEBP`);
621
+ return;
622
+ }
623
+ if (file.size > MAX_IMAGE_SIZE) {
624
+ alert(`Image too large: ${file.name} (max 5 MB)`);
625
+ return;
626
+ }
627
+ _compressImage(file)
628
+ .then(dataUrl => {
629
+ _pendingImages.push({ dataUrl, name: file.name, mimeType: "image/jpeg" });
630
+ _renderAttachmentPreviews();
631
+ })
632
+ .catch(err => alert(`Image processing failed: ${err.message}`));
633
+ }
634
+
635
+ function _addGenericFile(file) {
636
+ if (file.size > MAX_FILE_BYTES) {
637
+ alert(`File too large: ${file.name} (max 32 MB)`);
638
+ return;
639
+ }
640
+ // Upload file to server via HTTP — only the path is returned, no base64 in memory
641
+ const formData = new FormData();
642
+ formData.append("file", file);
643
+ fetch("/api/upload", { method: "POST", body: formData })
644
+ .then(r => r.json())
645
+ .then(data => {
646
+ if (!data.ok) { alert(`Upload failed: ${data.error}`); return; }
647
+ _pendingFiles.push({
648
+ name: data.name,
649
+ path: data.path,
650
+ mime_type: file.type
651
+ });
652
+ _renderAttachmentPreviews();
653
+ setTimeout(() => $("user-input").focus(), 100);
654
+ })
655
+ .catch(err => alert(`Upload error: ${err.message}`));
656
+ }
657
+
658
+ function _addAttachmentFile(file) {
659
+ // Route by content category. Images must match known image MIME types
660
+ // (MIME is reliable for images). Documents fall back to extension-based
661
+ // detection because browsers frequently report "" or "application/octet-stream"
662
+ // for .md / .tar.gz files.
663
+ if (_isAcceptedImage(file)) {
664
+ _addImageFile(file);
665
+ } else if (_isAcceptedDoc(file)) {
666
+ _addGenericFile(file);
667
+ } else {
668
+ alert(`Unsupported file: ${file.name}\nSupported: images (PNG/JPG/GIF/WEBP), PDF, Office (DOC/XLS/PPT), ZIP, TAR, TAR.GZ, MD, TXT, CSV`);
669
+ }
670
+ }
671
+
672
+ function _renderAttachmentPreviews() {
673
+ const strip = $("image-preview-strip");
674
+ strip.innerHTML = "";
675
+ const hasContent = _pendingImages.length > 0 || _pendingFiles.length > 0;
676
+ if (!hasContent) {
677
+ strip.style.display = "none";
678
+ return;
679
+ }
680
+ strip.style.display = "flex";
681
+
682
+ // Render image thumbnails
683
+ _pendingImages.forEach((img, idx) => {
684
+ const item = document.createElement("div");
685
+ item.className = "img-preview-item";
686
+ item.title = img.name;
687
+ const thumbnail = document.createElement("img");
688
+ thumbnail.src = img.dataUrl;
689
+ thumbnail.alt = img.name;
690
+ const removeBtn = document.createElement("button");
691
+ removeBtn.className = "img-preview-remove";
692
+ removeBtn.textContent = "✕";
693
+ removeBtn.title = "Remove";
694
+ removeBtn.addEventListener("click", () => {
695
+ _pendingImages.splice(idx, 1);
696
+ _renderAttachmentPreviews();
697
+ });
698
+ item.appendChild(thumbnail);
699
+ item.appendChild(removeBtn);
700
+ strip.appendChild(item);
701
+ });
702
+
703
+ // Render file cards (PDF, ZIP, DOC, XLS, PPT, etc.)
704
+ _pendingFiles.forEach((f, idx) => {
705
+ const item = document.createElement("div");
706
+ item.className = "pdf-preview-item";
707
+ item.title = f.name;
708
+
709
+ const icon = document.createElement("div");
710
+ icon.className = "pdf-preview-icon";
711
+ icon.textContent = _docTypeIcon(f.mime_type, f.name);
712
+
713
+ const info = document.createElement("div");
714
+ info.className = "pdf-preview-info";
715
+
716
+ const name = document.createElement("div");
717
+ name.className = "pdf-preview-name";
718
+ name.textContent = f.name;
719
+
720
+ const typeLabel = document.createElement("div");
721
+ typeLabel.className = "pdf-preview-type";
722
+ const _lowerName = (f.name || "").toLowerCase();
723
+ typeLabel.textContent = _lowerName.endsWith(".tar.gz")
724
+ ? "TAR.GZ"
725
+ : (f.name.split(".").pop() || "file").toUpperCase();
726
+
727
+ info.appendChild(name);
728
+ info.appendChild(typeLabel);
729
+
730
+ const removeBtn = document.createElement("button");
731
+ removeBtn.className = "pdf-preview-remove";
732
+ removeBtn.textContent = "✕";
733
+ removeBtn.title = "Remove";
734
+ removeBtn.addEventListener("click", () => {
735
+ _pendingFiles.splice(idx, 1);
736
+ _renderAttachmentPreviews();
737
+ });
738
+
739
+ item.appendChild(icon);
740
+ item.appendChild(info);
741
+ item.appendChild(removeBtn);
742
+ strip.appendChild(item);
743
+ });
744
+ }
745
+
746
+ // ── sendMessage ────────────────────────────────────────────────────────
747
+ let _sending = false;
748
+
749
+ function _sendMessage() {
750
+ if (_sending) return;
751
+ const input = $("user-input");
752
+ const content = input.value.trim();
753
+ if (!content && _pendingImages.length === 0 && _pendingFiles.length === 0) return;
754
+ if (!Sessions.activeId) return;
755
+
756
+ if (!WS.ready) {
757
+ const hint = $("ws-disconnect-hint");
758
+ if (hint) {
759
+ hint.textContent = I18n.t("chat.disconnected.hint");
760
+ hint.style.display = "block";
761
+ hint.style.opacity = "1";
762
+ clearTimeout(hint._hideTimer);
763
+ hint._hideTimer = setTimeout(() => {
764
+ hint.style.opacity = "0";
765
+ setTimeout(() => { hint.style.display = "none"; }, 400);
766
+ }, 2000);
767
+ }
768
+ return;
769
+ }
770
+
771
+ _sending = true;
772
+
773
+ let bubbleHtml = content ? escapeHtml(content) : "";
774
+ if (_pendingImages.length > 0) {
775
+ const thumbs = _pendingImages
776
+ .map(img => `<img src="${img.dataUrl}" alt="${escapeHtml(img.name)}" class="msg-image-thumb">`)
777
+ .join("");
778
+ bubbleHtml = thumbs + (bubbleHtml ? "<br>" + bubbleHtml : "");
779
+ }
780
+ if (_pendingFiles.length > 0) {
781
+ const badges = _pendingFiles.map(f => {
782
+ const icon = _docTypeIcon(f.mime_type);
783
+ const ext = (f.name.split(".").pop() || "file").toUpperCase();
784
+ return `<span class="msg-pdf-badge">` +
785
+ `<span class="msg-pdf-badge-icon">${icon}</span>` +
786
+ `<span class="msg-pdf-badge-info">` +
787
+ `<span class="msg-pdf-badge-name">${escapeHtml(f.name)}</span>` +
788
+ `<span class="msg-pdf-badge-type">${escapeHtml(ext)}</span>` +
789
+ `</span>` +
790
+ `</span>`;
791
+ }).join(" ");
792
+ bubbleHtml = badges + (bubbleHtml ? "<br>" + bubbleHtml : "");
793
+ }
794
+
795
+ // Always render a ghost bubble first. The real bubble replaces it
796
+ // when the agent drains the inbox and the server emits
797
+ // history_user_message — this avoids duplicate bubbles for idle agents.
798
+ const container = $("messages");
799
+ if (container) {
800
+ const el = document.createElement("div");
801
+ el.className = "msg msg-user msg-pending";
802
+ const spinner = `<span class="msg-pending-spinner"></span>`;
803
+ el.innerHTML = bubbleHtml + spinner;
804
+ container.appendChild(el);
805
+ container.scrollTop = container.scrollHeight;
806
+ }
807
+
808
+ // Merge images and files into unified files array for WS payload.
809
+ const files = [
810
+ ..._pendingImages.map(img => ({
811
+ name: img.name,
812
+ mime_type: img.mimeType || "image/jpeg",
813
+ data_url: img.dataUrl
814
+ })),
815
+ ..._pendingFiles.map(f => ({
816
+ name: f.name,
817
+ path: f.path,
818
+ mime_type: f.mime_type
819
+ }))
820
+ ];
821
+ _pendingImages.length = 0;
822
+ _pendingFiles.length = 0;
823
+ _renderAttachmentPreviews();
824
+
825
+ WS.send({ type: "message", session_id: Sessions.activeId, content, files });
826
+
827
+ // Save to session-scoped command history (localStorage, max 100)
828
+ if (content && Sessions.activeId) {
829
+ const key = "octo_cmd_history:" + Sessions.activeId;
830
+ const history = JSON.parse(localStorage.getItem(key) || "[]");
831
+ if (history.length === 0 || history[history.length - 1] !== content) {
832
+ history.push(content);
833
+ if (history.length > 100) history.shift();
834
+ localStorage.setItem(key, JSON.stringify(history));
835
+ }
836
+ }
837
+
838
+ input.value = "";
839
+ input.style.height = "auto";
840
+ // The user has effectively answered — any cached suggestion is stale now.
841
+ Sessions.clearInputSuggestion();
842
+ setTimeout(() => { _sending = false; }, 300);
843
+ }
844
+
845
+ // ── Ghost-text suggestion (rendered as textarea placeholder) ─────────────
846
+ //
847
+ // The agent emits "next_message_suggestion" after each task completes; we
848
+ // store the text and overwrite the input's `placeholder` with it. Hitting
849
+ // Tab on an empty input accepts the suggestion. Any user keystroke clears
850
+ // it (placeholder auto-hides as soon as `value` is non-empty, but we also
851
+ // drop our internal cache so a subsequent Tab doesn't refill it).
852
+ function _applySuggestionToDOM() {
853
+ const input = document.getElementById("user-input");
854
+ if (!input) return;
855
+ if (_defaultPlaceholder === null) _defaultPlaceholder = input.placeholder || "";
856
+ if (_suggestionText && !input.value) {
857
+ input.placeholder = _suggestionText;
858
+ } else {
859
+ input.placeholder = _defaultPlaceholder;
860
+ }
861
+ }
862
+
863
+ // ── Composer bindings ──────────────────────────────────────────────────
864
+ // Wires up the send button, attach button, file picker, drag-drop, paste,
865
+ // and IME composition tracking. All targets are static in index.html.
866
+ function _initComposer() {
867
+ // Send & attach buttons
868
+ document.getElementById("btn-send").addEventListener("click", _sendMessage);
869
+ document.getElementById("btn-attach")
870
+ .addEventListener("click", () => document.getElementById("image-file-input").click());
871
+
872
+ // Hidden <input type="file"> — triggered by btn-attach.
873
+ document.getElementById("image-file-input").addEventListener("change", (e) => {
874
+ Array.from(e.target.files).forEach(_addAttachmentFile);
875
+ e.target.value = "";
876
+ });
877
+
878
+ // Drag-drop onto the whole input area.
879
+ const inputArea = document.getElementById("input-area");
880
+ inputArea.addEventListener("dragover", (e) => {
881
+ e.preventDefault();
882
+ inputArea.classList.add("drag-over");
883
+ });
884
+ inputArea.addEventListener("dragleave", (e) => {
885
+ if (!inputArea.contains(e.relatedTarget)) inputArea.classList.remove("drag-over");
886
+ });
887
+ inputArea.addEventListener("drop", (e) => {
888
+ e.preventDefault();
889
+ inputArea.classList.remove("drag-over");
890
+ const files = Array.from(e.dataTransfer.files).filter(_isAcceptedFile);
891
+ if (files.length === 0) return;
892
+ files.forEach(_addAttachmentFile);
893
+ });
894
+
895
+ // Paste handler — images and accepted docs from the clipboard.
896
+ document.getElementById("user-input").addEventListener("paste", (e) => {
897
+ const items = Array.from(e.clipboardData?.items || []);
898
+ // Any file-kind item that's an image, or a document whose type/name
899
+ // passes our doc filter. Must check name via getAsFile() for .md/.tar.gz
900
+ // (browsers often leave item.type empty for these).
901
+ const attachItems = items.filter(it => {
902
+ if (it.kind !== "file") return false;
903
+ if (ACCEPTED_IMAGE_TYPES.includes(it.type)) return true;
904
+ if (ACCEPTED_DOC_TYPES.includes(it.type)) return true;
905
+ const f = it.getAsFile && it.getAsFile();
906
+ return f ? _hasAcceptedDocExt(f.name) : false;
907
+ });
908
+ if (attachItems.length === 0) return;
909
+ e.preventDefault();
910
+ attachItems.forEach(it => _addAttachmentFile(it.getAsFile()));
911
+ });
912
+
913
+ // Suggestion accept / clear handlers. Bound on the textarea (not document)
914
+ // so they don't compete with global shortcuts. Tab is a no-op in a normal
915
+ // textarea (it just shifts focus), so claiming it for "accept suggestion"
916
+ // is non-disruptive — and only happens while the input is empty.
917
+ const inputEl = document.getElementById("user-input");
918
+ inputEl.addEventListener("keydown", (e) => {
919
+ if (e.key === "Tab" && !e.shiftKey && !inputEl.value && _suggestionText) {
920
+ e.preventDefault();
921
+ inputEl.value = _suggestionText;
922
+ Sessions.clearInputSuggestion();
923
+ inputEl.dispatchEvent(new Event("input", { bubbles: true }));
924
+ } else if (e.key === "Escape" && _suggestionText && !inputEl.value) {
925
+ Sessions.clearInputSuggestion();
926
+ }
927
+ });
928
+ inputEl.addEventListener("input", () => {
929
+ if (_suggestionText) Sessions.clearInputSuggestion();
930
+ });
931
+ }
932
+
933
+ // ── Search bar bindings ────────────────────────────────────────────────
934
+ //
935
+ // All search-related interactions. The search UI lives in the sessions
936
+ // sidebar: a magnifier toggle button, the search panel (q input, type
937
+ // <select>, date <input>), inline ✕ clear, and "clear all filters" button.
938
+ //
939
+ // Everything uses event delegation because some elements (e.g. the clear
940
+ // buttons) are re-rendered as filter state changes.
941
+ function _initSearch() {
942
+ // Magnifier toggle button
943
+ document.addEventListener("click", (e) => {
944
+ if (e.target && e.target.closest("#btn-session-search-toggle")) {
945
+ Sessions.toggleSearch();
946
+ }
947
+ });
948
+
949
+ // Close button inside panel
950
+ document.addEventListener("click", (e) => {
951
+ if (e.target && e.target.id === "btn-session-search-close") {
952
+ if (Sessions.searchOpen) Sessions.toggleSearch();
953
+ }
954
+ });
955
+
956
+ // Enter key → commit search (fires whichever input has focus)
957
+ document.addEventListener("keydown", (e) => {
958
+ if (e.key === "Enter" && e.target && e.target.id === "session-search-q") {
959
+ e.preventDefault();
960
+ Sessions.commitSearch();
961
+ }
962
+ });
963
+
964
+ // Inline ✕ button — clear the q input and re-fetch
965
+ document.addEventListener("click", (e) => {
966
+ if (e.target && e.target.id === "btn-search-q-clear") {
967
+ const qEl = document.getElementById("session-search-q");
968
+ if (qEl) qEl.value = "";
969
+ Sessions.clearFilter("q");
970
+ }
971
+ });
972
+
973
+ // "Clear all filters" button — resets type + date and re-fetches once
974
+ document.addEventListener("click", (e) => {
975
+ if (e.target && e.target.id === "btn-search-clear-all") {
976
+ const typeEl = document.getElementById("session-search-type");
977
+ const dateEl = document.getElementById("session-search-date");
978
+ if (typeEl) typeEl.value = "";
979
+ if (dateEl) DatePicker.clear(dateEl);
980
+ Sessions.commitSearch();
981
+ }
982
+ });
983
+
984
+ // Show/hide inline ✕ as user types in search input
985
+ document.addEventListener("input", (e) => {
986
+ if (e.target && e.target.id === "session-search-q") {
987
+ const btn = document.getElementById("btn-search-q-clear");
988
+ if (btn) btn.hidden = !e.target.value;
989
+ }
990
+ });
991
+
992
+ // Type <select> and date picker — commit immediately on change
993
+ document.addEventListener("change", (e) => {
994
+ if (e.target && e.target.id === "session-search-type") {
995
+ Sessions.commitSearch();
996
+ }
997
+ });
998
+ document.addEventListener("datepicker:change", (e) => {
999
+ if (e.target && e.target.id === "session-search-date") {
1000
+ Sessions.commitSearch();
1001
+ }
1002
+ });
1003
+ }
1004
+
1005
+ // ── Message history bindings ───────────────────────────────────────────
1006
+ //
1007
+ // Session-scoped interactions inside the chat panel (not tied to a
1008
+ // specific session id at bind time — they look up Sessions.activeId
1009
+ // dynamically):
1010
+ // - Scroll-to-top on #messages → load older history
1011
+ // - #btn-interrupt → WS interrupt
1012
+ // - #btn-delete-session → delete current session (legacy — the
1013
+ // chat-header was removed; the button is now absent in fresh HTML
1014
+ // but kept here in case some brand / template still renders it).
1015
+ function _initMessageHistory() {
1016
+ // Infinite-scroll older history when the user reaches the top.
1017
+ document.getElementById("messages").addEventListener("scroll", () => {
1018
+ const messages = document.getElementById("messages");
1019
+ if (messages.scrollTop < 80 && Sessions.activeId && Sessions.hasMoreHistory(Sessions.activeId)) {
1020
+ Sessions.loadMoreHistory(Sessions.activeId);
1021
+ }
1022
+ });
1023
+
1024
+ // Interrupt button — tells the backend to stop the current task.
1025
+ document.getElementById("btn-interrupt").addEventListener("click", () => {
1026
+ WS.send({ type: "interrupt", session_id: Sessions.activeId });
1027
+ });
1028
+
1029
+ // Legacy delete button (removed from the chat header long ago). Keep a
1030
+ // guarded binding so that custom brand/templates rendering the old
1031
+ // element still work. In the default HTML this is a no-op.
1032
+ const btnDelete = document.getElementById("btn-delete-session");
1033
+ if (btnDelete) {
1034
+ btnDelete.addEventListener("click", () => {
1035
+ if (Sessions.activeId) Sessions.deleteSession(Sessions.activeId);
1036
+ });
1037
+ }
1038
+ }
1039
+
1040
+ // ── Tool group helpers ─────────────────────────────────────────────────
1041
+ //
1042
+ // A "tool group" is a collapsible <div class="tool-group"> that contains
1043
+ // one .tool-item row per tool_call in a consecutive run of tool calls.
1044
+ // While running: expanded (shows each tool + a "running" spinner).
1045
+ // When done (assistant_message or complete): collapsed to "⚙ N tools used".
1046
+
1047
+ // Build one .tool-item row element.
1048
+ function _makeToolItem(name, args, summary) {
1049
+ const item = document.createElement("div");
1050
+ item.className = "tool-item";
1051
+
1052
+ // Use backend-provided summary when available, fall back to client-side summarise
1053
+ const argSummary = summary || _summariseArgs(name, args);
1054
+
1055
+ // When a structured summary is available, show it as the primary label (no redundant tool name).
1056
+ // Otherwise show the raw tool name + arg summary as before.
1057
+ const label = summary
1058
+ ? `<span class="tool-item-name">⚙ ${escapeHtml(summary)}</span>`
1059
+ : `<span class="tool-item-name">⚙ ${escapeHtml(name)}</span>` +
1060
+ (argSummary ? `<span class="tool-item-arg">${escapeHtml(argSummary)}</span>` : "");
1061
+
1062
+ item.innerHTML =
1063
+ `<div class="tool-item-header">` +
1064
+ label +
1065
+ `<span class="tool-item-status running">…</span>` +
1066
+ `</div>` +
1067
+ `<pre class="tool-item-stdout" style="display:none"></pre>`;
1068
+ return item;
1069
+ }
1070
+
1071
+ // Convert ANSI escape codes to HTML spans with color classes.
1072
+ // Handles the common SGR codes used by shell scripts (colors + reset).
1073
+ function _ansiToHtml(text) {
1074
+ const ANSI_COLORS = {
1075
+ "30": "ansi-black", "31": "ansi-red", "32": "ansi-green",
1076
+ "33": "ansi-yellow", "34": "ansi-blue", "35": "ansi-magenta",
1077
+ "36": "ansi-cyan", "37": "ansi-white",
1078
+ "1;31": "ansi-bold ansi-red", "1;32": "ansi-bold ansi-green",
1079
+ "1;33": "ansi-bold ansi-yellow","1;34": "ansi-bold ansi-blue",
1080
+ "0;31": "ansi-red", "0;32": "ansi-green",
1081
+ "0;33": "ansi-yellow","0;34": "ansi-blue",
1082
+ };
1083
+ let result = "";
1084
+ let open = false;
1085
+ // Split on ESC[ sequences
1086
+ const parts = text.split(/\x1b\[([0-9;]*)m/);
1087
+ for (let i = 0; i < parts.length; i++) {
1088
+ if (i % 2 === 0) {
1089
+ // Plain text — escape HTML
1090
+ result += parts[i].replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
1091
+ } else {
1092
+ // Code
1093
+ const code = parts[i];
1094
+ if (open) { result += "</span>"; open = false; }
1095
+ if (code === "0" || code === "") {
1096
+ // reset — already closed above
1097
+ } else {
1098
+ const cls = ANSI_COLORS[code];
1099
+ if (cls) { result += `<span class="${cls}">`; open = true; }
1100
+ }
1101
+ }
1102
+ }
1103
+ if (open) result += "</span>";
1104
+ return result;
1105
+ }
1106
+
1107
+ // Convert a unified-diff text block to HTML with syntax highlighting.
1108
+ // Uses the same color classes as ANSI output so diff renders consistently
1109
+ // inside .tool-item-stdout.
1110
+ function _formatDiffToHtml(text) {
1111
+ if (!text) return "";
1112
+ return text.split("\n").map(line => {
1113
+ const escaped = escapeHtml(line);
1114
+ if (line.startsWith("+")) return `<span class="ansi-green">${escaped}</span>`;
1115
+ if (line.startsWith("-")) return `<span class="ansi-red">${escaped}</span>`;
1116
+ if (line.startsWith("@@")) return `<span class="ansi-blue">${escaped}</span>`;
1117
+ if (line.startsWith("---") || line.startsWith("+++")) return `<span class="ansi-cyan">${escaped}</span>`;
1118
+ return escaped;
1119
+ }).join("\n");
1120
+ }
1121
+
1122
+ // Render a diff block into the given tool-item's stdout area.
1123
+ function _applyDiffToItem(toolItem, diffText, truncated) {
1124
+ if (!toolItem) return;
1125
+ const stdout = toolItem.querySelector(".tool-item-stdout");
1126
+ if (!stdout) return;
1127
+ let html = _formatDiffToHtml(diffText);
1128
+ if (truncated) html += '\n\n<span class="ansi-yellow">… diff truncated</span>';
1129
+ stdout.innerHTML = html;
1130
+ if (stdout.style.display === "none") stdout.style.display = "";
1131
+ stdout.scrollTop = stdout.scrollHeight;
1132
+ }
1133
+
1134
+ // Rich result renderers — produce structured HTML for different tool result types.
1135
+ function _renderRichResult(payload) {
1136
+ switch (payload.type) {
1137
+ case "file_read":
1138
+ return _renderFileReadResult(payload);
1139
+ case "file_list":
1140
+ return _renderFileListResult(payload);
1141
+ case "search":
1142
+ return _renderSearchResult(payload);
1143
+ case "terminal":
1144
+ return _renderTerminalResult(payload);
1145
+ case "web_fetch":
1146
+ return _renderWebFetchResult(payload);
1147
+ case "web_search":
1148
+ return _renderWebSearchResult(payload);
1149
+ case "edit":
1150
+ return _renderEditResult(payload);
1151
+ case "write":
1152
+ return _renderWriteResult(payload);
1153
+ case "todo":
1154
+ return _renderTodoResult(payload);
1155
+ case "browser":
1156
+ return _renderBrowserResult(payload);
1157
+ default:
1158
+ return null;
1159
+ }
1160
+ }
1161
+
1162
+ function _renderFileReadResult(p) {
1163
+ const path = escapeHtml(p.path || "");
1164
+ const lines = p.lines_read != null ? `${p.lines_read}/${p.total_lines || "?"} lines` : "";
1165
+ const truncated = p.truncated ? ` <span class="tr-badge">truncated</span>` : "";
1166
+ const lang = p.language ? ` data-lang="${escapeHtml(p.language)}"` : "";
1167
+ let html = `<div class="tr-card tr-file-read">`;
1168
+ html += `<div class="tr-header"><span class="tr-icon">📄</span><span class="tr-path">${path}</span>${truncated}<span class="tr-meta">${lines}</span></div>`;
1169
+ if (p.content_preview) {
1170
+ const preview = escapeHtml(p.content_preview);
1171
+ html += `<pre class="tr-code"${lang}><code>${preview}</code></pre>`;
1172
+ }
1173
+ html += `</div>`;
1174
+ return html;
1175
+ }
1176
+
1177
+ function _renderFileListResult(p) {
1178
+ const entries = (p.entries || []).slice(0, 20);
1179
+ const total = p.total || entries.length;
1180
+ let html = `<div class="tr-card tr-file-list">`;
1181
+ html += `<div class="tr-header"><span class="tr-icon">📁</span><span class="tr-path">${escapeHtml(p.path || ".")}</span><span class="tr-meta">${total} items</span></div>`;
1182
+ if (entries.length) {
1183
+ html += `<div class="tr-list">`;
1184
+ entries.forEach(e => {
1185
+ const icon = e.is_dir ? "📂" : "📄";
1186
+ html += `<div class="tr-list-item">${icon} <span>${escapeHtml(e.name)}</span></div>`;
1187
+ });
1188
+ if (total > entries.length) html += `<div class="tr-list-more">… and ${total - entries.length} more</div>`;
1189
+ html += `</div>`;
1190
+ }
1191
+ html += `</div>`;
1192
+ return html;
1193
+ }
1194
+
1195
+ function _renderSearchResult(p) {
1196
+ const matches = (p.matches || []).slice(0, 10);
1197
+ const total = p.total_matches || 0;
1198
+ let html = `<div class="tr-card tr-search">`;
1199
+ html += `<div class="tr-header"><span class="tr-icon">🔍</span><span class="tr-query">${escapeHtml(p.pattern || "")}</span><span class="tr-meta">${total} matches in ${p.files_with_matches || 0} files</span></div>`;
1200
+ if (matches.length) {
1201
+ html += `<div class="tr-search-results">`;
1202
+ matches.forEach(m => {
1203
+ html += `<div class="tr-search-match">`;
1204
+ html += `<div class="tr-search-file">${escapeHtml(m.file || "")}:${m.line_no}</div>`;
1205
+ html += `<div class="tr-search-line">${escapeHtml(m.line || "")}</div>`;
1206
+ html += `</div>`;
1207
+ });
1208
+ if (total > matches.length) html += `<div class="tr-list-more">… and ${total - matches.length} more</div>`;
1209
+ html += `</div>`;
1210
+ }
1211
+ html += `</div>`;
1212
+ return html;
1213
+ }
1214
+
1215
+ function _renderTerminalResult(p) {
1216
+ const status = p.status || "success";
1217
+ const statusIcon = status === "success" ? "✓" : status === "failed" ? "✗" : status === "error" ? "⚠" : "…";
1218
+ const statusCls = `tr-status-${status}`;
1219
+ let html = `<div class="tr-card tr-terminal ${statusCls}">`;
1220
+ html += `<div class="tr-header"><span class="tr-icon">🖥</span><span class="tr-cmd">${escapeHtml(p.command || "")}</span><span class="tr-status">${statusIcon} ${status}</span></div>`;
1221
+ if (status === "error" && p.error) {
1222
+ html += `<pre class="tr-code tr-terminal-output"><code>${escapeHtml(p.error)}</code></pre>`;
1223
+ } else if (p.output_preview) {
1224
+ html += `<pre class="tr-code tr-terminal-output"><code>${escapeHtml(p.output_preview)}</code></pre>`;
1225
+ } else if (status === "failed" && p.exit_code != null) {
1226
+ html += `<pre class="tr-code tr-terminal-output"><code>Exit code: ${p.exit_code}</code></pre>`;
1227
+ }
1228
+ if (p.full_output_file) {
1229
+ html += `<div class="tr-note">Full output: ${escapeHtml(p.full_output_file)}</div>`;
1230
+ }
1231
+ html += `</div>`;
1232
+ return html;
1233
+ }
1234
+
1235
+ function _renderWebFetchResult(p) {
1236
+ let html = `<div class="tr-card tr-web">`;
1237
+ html += `<div class="tr-header"><span class="tr-icon">🌐</span><a href="${escapeHtml(p.url || "#")}" target="_blank" class="tr-url">${escapeHtml(p.title || p.url || "")}</a>`;
1238
+ if (p.status_code) html += `<span class="tr-meta">${p.status_code}</span>`;
1239
+ html += `</div>`;
1240
+ if (p.content_preview) {
1241
+ html += `<div class="tr-text-preview">${escapeHtml(p.content_preview)}</div>`;
1242
+ }
1243
+ html += `</div>`;
1244
+ return html;
1245
+ }
1246
+
1247
+ function _renderWebSearchResult(p) {
1248
+ const results = (p.results || []).slice(0, 5);
1249
+ let html = `<div class="tr-card tr-web">`;
1250
+ html += `<div class="tr-header"><span class="tr-icon">🔎</span><span class="tr-query">${escapeHtml(p.query || "")}</span><span class="tr-meta">${p.total || 0} results</span></div>`;
1251
+ if (results.length) {
1252
+ html += `<div class="tr-search-results">`;
1253
+ results.forEach(r => {
1254
+ html += `<div class="tr-search-match">`;
1255
+ html += `<a href="${escapeHtml(r.url || "#")}" target="_blank" class="tr-search-title">${escapeHtml(r.title || "")}</a>`;
1256
+ if (r.snippet) html += `<div class="tr-search-snippet">${escapeHtml(r.snippet)}</div>`;
1257
+ html += `</div>`;
1258
+ });
1259
+ html += `</div>`;
1260
+ }
1261
+ html += `</div>`;
1262
+ return html;
1263
+ }
1264
+
1265
+ function _renderEditResult(p) {
1266
+ let html = `<div class="tr-card tr-edit">`;
1267
+ html += `<div class="tr-header"><span class="tr-icon">✏️</span><span class="tr-path">${escapeHtml(p.path || "")}</span><span class="tr-meta">${p.occurrences || 1} change(s)</span></div>`;
1268
+ if (p.diff) {
1269
+ html += `<pre class="tr-code tr-diff"><code>${_formatDiffToHtml(p.diff)}</code></pre>`;
1270
+ }
1271
+ html += `</div>`;
1272
+ return html;
1273
+ }
1274
+
1275
+ function _renderWriteResult(p) {
1276
+ const size = p.size_bytes ? `${p.size_bytes} bytes` : "";
1277
+ return `<div class="tr-card tr-write"><div class="tr-header"><span class="tr-icon">📝</span><span class="tr-path">${escapeHtml(p.path || "")}</span><span class="tr-meta">${size}</span></div></div>`;
1278
+ }
1279
+
1280
+ function _renderTodoResult(p) {
1281
+ const todos = (p.todos || []).slice(0, 10);
1282
+ const progress = p.progress ? `<span class="tr-meta">${escapeHtml(p.progress)}</span>` : "";
1283
+ let html = `<div class="tr-card tr-todo">`;
1284
+ html += `<div class="tr-header"><span class="tr-icon">✅</span><span class="tr-action">${escapeHtml(p.action || "")}</span>${progress}</div>`;
1285
+ if (todos.length) {
1286
+ html += `<div class="tr-list">`;
1287
+ todos.forEach(t => {
1288
+ const icon = t.status === "completed" ? "☑" : "☐";
1289
+ html += `<div class="tr-list-item ${t.status === "completed" ? "done" : ""}">${icon} <span>${escapeHtml(t.task || "")}</span></div>`;
1290
+ });
1291
+ html += `</div>`;
1292
+ }
1293
+ html += `</div>`;
1294
+ return html;
1295
+ }
1296
+
1297
+ function _renderBrowserResult(p) {
1298
+ return `<div class="tr-card tr-browser"><div class="tr-header"><span class="tr-icon">🌐</span><span class="tr-action">${escapeHtml(p.action || "")}</span><span class="tr-meta">${escapeHtml(p.url || "")}</span></div></div>`;
1299
+ }
1300
+
1301
+ // Produce a short one-line summary of tool arguments for the compact view.
1302
+ function _summariseArgs(toolName, args) {
1303
+ if (!args || typeof args !== "object") return String(args || "");
1304
+ // Pick the most informative single field as a short summary
1305
+ const pick = args.path || args.command || args.query || args.url ||
1306
+ args.task || args.content || args.question || args.message;
1307
+ if (pick) return String(pick).slice(0, 80);
1308
+ // Fallback: first string value
1309
+ const first = Object.values(args).find(v => typeof v === "string");
1310
+ return first ? first.slice(0, 80) : "";
1311
+ }
1312
+
1313
+ // Create a new tool group element (collapsed header + empty body).
1314
+ function _makeToolGroup() {
1315
+ const group = document.createElement("div");
1316
+ group.className = "tool-group expanded";
1317
+
1318
+ const header = document.createElement("div");
1319
+ header.className = "tool-group-header";
1320
+ // Header is hidden until the group has ≥ 2 tool calls.
1321
+ // When there is only one tool call, the single .tool-item renders
1322
+ // directly (no redundant "1 tool(s) used" label above it).
1323
+ header.style.display = "none";
1324
+ header.innerHTML =
1325
+ `<span class="tool-group-arrow">▶</span>` +
1326
+ `<span class="tool-group-label">⚙ <span class="tg-count">0</span> tool(s) used</span>`;
1327
+ header.addEventListener("click", () => {
1328
+ group.classList.toggle("expanded");
1329
+ });
1330
+
1331
+ const body = document.createElement("div");
1332
+ body.className = "tool-group-body";
1333
+
1334
+ group.appendChild(header);
1335
+ group.appendChild(body);
1336
+ return group;
1337
+ }
1338
+
1339
+ // Add a tool_call to a group; returns the new .tool-item element.
1340
+ function _addToolCallToGroup(group, name, args, summary) {
1341
+ const body = group.querySelector(".tool-group-body");
1342
+ const header = group.querySelector(".tool-group-header");
1343
+ const count = group.querySelector(".tg-count");
1344
+ const item = _makeToolItem(name, args, summary);
1345
+ body.appendChild(item);
1346
+ const n = body.children.length;
1347
+ count.textContent = n;
1348
+ // Reveal the header once there are 2 or more tool calls
1349
+ if (n >= 2 && header.style.display === "none") header.style.display = "";
1350
+ return item;
1351
+ }
1352
+
1353
+ // Mark the last tool-item in a group as done (update status indicator).
1354
+ function _completeLastToolItem(group, result, uiPayload) {
1355
+ const body = group.querySelector(".tool-group-body");
1356
+ const items = body.querySelectorAll(".tool-item");
1357
+ if (!items.length) return;
1358
+ const last = items[items.length - 1];
1359
+ const status = last.querySelector(".tool-item-status");
1360
+ if (status) {
1361
+ const st = uiPayload && uiPayload.status ? uiPayload.status : "ok";
1362
+ if (st === "error" || st === "failed") {
1363
+ status.className = "tool-item-status failed";
1364
+ status.textContent = "✗";
1365
+ } else {
1366
+ status.className = "tool-item-status ok";
1367
+ status.textContent = "✓";
1368
+ }
1369
+ }
1370
+ // Render the result string (e.g. "waiting (#4) — 128B\nstep1\nstep2…")
1371
+ // into the stdout area so the user can see what actually happened.
1372
+ // If the area already has streamed content (future feature), leave it.
1373
+ const stdout = last.querySelector(".tool-item-stdout");
1374
+ if (stdout) {
1375
+ const existing = stdout.textContent.trim();
1376
+ if (uiPayload && uiPayload.type) {
1377
+ const rich = _renderRichResult(uiPayload);
1378
+ if (rich) {
1379
+ stdout.innerHTML = rich;
1380
+ stdout.style.display = "";
1381
+ return;
1382
+ }
1383
+ }
1384
+ const resultStr = (result == null) ? "" : String(result).trim();
1385
+ if (!existing && resultStr) {
1386
+ stdout.innerHTML = _ansiToHtml(resultStr);
1387
+ stdout.style.display = "";
1388
+ } else if (!existing && !resultStr) {
1389
+ stdout.style.display = "none";
1390
+ }
1391
+ // else: leave existing content as-is
1392
+ }
1393
+ }
1394
+
1395
+ // Collapse a tool group (called when AI responds or task finishes).
1396
+ // When a group has only one tool call and no visible header, the body stays
1397
+ // "expanded" so the single tool item remains visible after collapse.
1398
+ function _collapseToolGroup(group) {
1399
+ const body = group.querySelector(".tool-group-body");
1400
+ const n = body ? body.children.length : 0;
1401
+ // Only hide the body (collapse) when there are multiple tools with a visible header.
1402
+ // A single-tool group has no header, so we keep its body visible forever.
1403
+ if (n > 1) group.classList.add("expanded");
1404
+ }
1405
+
1406
+ // Render a single history event into a target container.
1407
+ // Reuses the same display logic as the live WS handler.
1408
+ // historyGroup: optional { group } state object shared across events in a round
1409
+ // (so consecutive tool_calls get grouped, and tool_results match up).
1410
+ function _renderHistoryEvent(ev, container, historyCtx) {
1411
+ // historyCtx = { group: DOMElement|null, lastItem: DOMElement|null }
1412
+ if (!historyCtx) historyCtx = { group: null, lastItem: null };
1413
+
1414
+ switch (ev.type) {
1415
+ case "history_user_message": {
1416
+ // Collapse any open tool group from the previous round
1417
+ if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; }
1418
+ // Remove any pending (ghost) user bubbles — the real bubble from
1419
+ // the server replaces them, keeping the timeline clean.
1420
+ container.querySelectorAll(".msg-pending").forEach(el => el.remove());
1421
+ const el = document.createElement("div");
1422
+ el.className = "msg msg-user";
1423
+ // Render image thumbnails and PDF badges (if any) followed by the text content
1424
+ let bubbleHtml = "";
1425
+ if (Array.isArray(ev.images) && ev.images.length > 0) {
1426
+ bubbleHtml += ev.images.map(src => {
1427
+ if (src && src.startsWith("pdf:")) {
1428
+ // File badge — extract filename and extension from sentinel "pdf:<name>"
1429
+ const fname = src.slice(4);
1430
+ const lower = fname.toLowerCase();
1431
+ const ext = (fname.split(".").pop() || "file").toUpperCase();
1432
+ // Special-case compound extension ".tar.gz" so the badge shows TAR.GZ instead of GZ
1433
+ const displayExt = lower.endsWith(".tar.gz") ? "TAR.GZ" : ext;
1434
+ const icon = ext === "PDF" ? "📄" :
1435
+ (ext === "ZIP" || ext === "GZ" || ext === "TGZ" || ext === "TAR" ||
1436
+ ext === "RAR" || ext === "7Z" || lower.endsWith(".tar.gz")) ? "🗜️" :
1437
+ (ext === "DOC" || ext === "DOCX") ? "📝" :
1438
+ (ext === "XLS" || ext === "XLSX" || ext === "CSV") ? "📊" :
1439
+ (ext === "PPT" || ext === "PPTX") ? "📋" :
1440
+ (ext === "MD" || ext === "MARKDOWN") ? "📝" :
1441
+ (ext === "TXT" || ext === "LOG") ? "📄" : "📎";
1442
+ return `<span class="msg-pdf-badge">` +
1443
+ `<span class="msg-pdf-badge-icon">${icon}</span>` +
1444
+ `<span class="msg-pdf-badge-info">` +
1445
+ `<span class="msg-pdf-badge-name">${escapeHtml(fname)}</span>` +
1446
+ `<span class="msg-pdf-badge-type">${escapeHtml(displayExt)}</span>` +
1447
+ `</span>` +
1448
+ `</span>`;
1449
+ }
1450
+ if (src && src.startsWith("expired:")) {
1451
+ // Image whose tmp file has been deleted — show an expired badge
1452
+ const fname = src.slice(8);
1453
+ return `<span class="msg-pdf-badge msg-image-expired">` +
1454
+ `<span class="msg-pdf-badge-icon">🖼️</span>` +
1455
+ `<span class="msg-pdf-badge-info">` +
1456
+ `<span class="msg-pdf-badge-name">${escapeHtml(fname || "image")}</span>` +
1457
+ `<span class="msg-pdf-badge-type">${I18n.t("chat.image_expired") || "Expired"}</span>` +
1458
+ `</span>` +
1459
+ `</span>`;
1460
+ }
1461
+ return `<img src="${escapeHtml(src)}" alt="image" class="msg-image-thumb">`;
1462
+ }).join("");
1463
+ if (ev.content) bubbleHtml += "<br>";
1464
+ }
1465
+ bubbleHtml += escapeHtml(ev.content || "");
1466
+ el.innerHTML = bubbleHtml;
1467
+ _appendMsgTime(el, ev.created_at);
1468
+ container.appendChild(el);
1469
+ break;
1470
+ }
1471
+
1472
+ case "assistant_message": {
1473
+ // Collapse tool group before assistant reply
1474
+ if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; }
1475
+ const el = document.createElement("div");
1476
+ el.className = "msg msg-assistant";
1477
+ el.dataset.raw = ev.content || "";
1478
+ el.innerHTML = _renderMarkdown(ev.content || "");
1479
+ _appendCopyButton(el);
1480
+ container.appendChild(el);
1481
+ break;
1482
+ }
1483
+
1484
+ case "tool_call": {
1485
+ // Start or reuse tool group
1486
+ if (!historyCtx.group) {
1487
+ historyCtx.group = _makeToolGroup();
1488
+ container.appendChild(historyCtx.group);
1489
+ }
1490
+ historyCtx.lastItem = _addToolCallToGroup(historyCtx.group, ev.name, ev.args, ev.summary);
1491
+ break;
1492
+ }
1493
+
1494
+ case "tool_result": {
1495
+ if (historyCtx.group && historyCtx.lastItem) {
1496
+ const status = historyCtx.lastItem.querySelector(".tool-item-status");
1497
+ if (status) {
1498
+ const st = ev.ui_payload && ev.ui_payload.status ? ev.ui_payload.status : "ok";
1499
+ if (st === "error" || st === "failed") {
1500
+ status.className = "tool-item-status failed";
1501
+ status.textContent = "✗";
1502
+ } else {
1503
+ status.className = "tool-item-status ok";
1504
+ status.textContent = "✓";
1505
+ }
1506
+ }
1507
+ const stdout = historyCtx.lastItem.querySelector(".tool-item-stdout");
1508
+ if (stdout) {
1509
+ if (ev.ui_payload && ev.ui_payload.type) {
1510
+ const rich = _renderRichResult(ev.ui_payload);
1511
+ if (rich) {
1512
+ stdout.innerHTML = rich;
1513
+ stdout.style.display = "";
1514
+ }
1515
+ } else {
1516
+ const resultStr = (ev.result == null) ? "" : String(ev.result).trim();
1517
+ if (resultStr && !stdout.textContent.trim()) {
1518
+ stdout.innerHTML = _ansiToHtml(resultStr);
1519
+ stdout.style.display = "";
1520
+ } else if (!resultStr && !stdout.textContent.trim()) {
1521
+ stdout.style.display = "none";
1522
+ }
1523
+ }
1524
+ }
1525
+ historyCtx.lastItem = null;
1526
+ }
1527
+ break;
1528
+ }
1529
+
1530
+ case "token_usage": {
1531
+ // Collapse any open tool group before rendering the token line
1532
+ if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; }
1533
+ Sessions.appendTokenUsage(ev, container);
1534
+ break;
1535
+ }
1536
+
1537
+ default:
1538
+ return; // skip unknown types
1539
+ }
1540
+ }
1541
+
1542
+ // Write stdout lines into a .tool-item's stdout area, showing it if hidden.
1543
+ // Shared by appendToolStdout (live) and _flushPendingStdout (deferred).
1544
+ function _applyStdoutToItem(toolItem, lines) {
1545
+ const stdout = toolItem.querySelector(".tool-item-stdout");
1546
+ if (!stdout) return;
1547
+ stdout.innerHTML += lines.map(_ansiToHtml).join("");
1548
+ if (stdout.style.display === "none") stdout.style.display = "";
1549
+ stdout.scrollTop = stdout.scrollHeight;
1550
+ const messages = $("messages");
1551
+ _scrollToBottomIfNeeded(messages);
1552
+ }
1553
+
1554
+ // Flush any stdout lines buffered while history was still loading.
1555
+ // Called from _fetchHistory right after the DOM fragment is inserted.
1556
+ function _flushPendingStdout() {
1557
+ if (!_pendingStdoutLines || _pendingStdoutLines.length === 0) return;
1558
+ const lines = _pendingStdoutLines;
1559
+ _pendingStdoutLines = null;
1560
+
1561
+ const messages = $("messages");
1562
+ if (!messages) return;
1563
+ const items = messages.querySelectorAll(".tool-item");
1564
+ if (items.length === 0) return;
1565
+ const toolItem = items[items.length - 1];
1566
+ _applyStdoutToItem(toolItem, lines);
1567
+ }
1568
+
1569
+ // Fetch one page of history and insert into #messages or cache.
1570
+ // before=null means most recent page; prepend=true for scroll-up load.
1571
+ async function _fetchHistory(id, before = null, prepend = false) {
1572
+ const state = _historyState[id] || (_historyState[id] = { hasMore: true, oldestCreatedAt: null, loading: false });
1573
+ if (state.loading) return;
1574
+ state.loading = true;
1575
+
1576
+ try {
1577
+ const params = new URLSearchParams({ limit: 30 });
1578
+ if (before) params.set("before", before);
1579
+
1580
+ const res = await fetch(`/api/sessions/${id}/messages?${params}`);
1581
+ if (!res.ok) {
1582
+ if (id === _activeId) {
1583
+ let reason = "";
1584
+ try { const d = await res.json(); reason = d.error || ""; } catch {}
1585
+ const suffix = reason ? `: ${reason}` : "";
1586
+ Sessions.appendMsg("info", `${I18n.t("chat.history_load_failed")} (${res.status}${suffix})`);
1587
+ }
1588
+ return;
1589
+ }
1590
+ const data = await res.json();
1591
+
1592
+ state.hasMore = !!data.has_more;
1593
+
1594
+ const events = data.events || [];
1595
+ if (events.length === 0) return;
1596
+
1597
+ // Track oldest created_at for next cursor (scroll-up pagination)
1598
+ events.forEach(ev => {
1599
+ if (ev.type === "history_user_message" && ev.created_at) {
1600
+ if (state.oldestCreatedAt === null || ev.created_at < state.oldestCreatedAt) {
1601
+ state.oldestCreatedAt = ev.created_at;
1602
+ }
1603
+ }
1604
+ });
1605
+
1606
+ // Dedup by created_at: skip rounds already rendered (e.g. arrived via live WS)
1607
+ const dedup = _renderedCreatedAt[id] || (_renderedCreatedAt[id] = new Set());
1608
+ const frag = document.createDocumentFragment();
1609
+
1610
+ let currentCreatedAt = null;
1611
+ let skipRound = false;
1612
+ // Shared context for tool grouping across a page of history events
1613
+ const historyCtx = { group: null, lastItem: null };
1614
+
1615
+ events.forEach(ev => {
1616
+ if (ev.type === "history_user_message") {
1617
+ currentCreatedAt = ev.created_at;
1618
+ skipRound = currentCreatedAt && dedup.has(currentCreatedAt);
1619
+ if (!skipRound && currentCreatedAt) dedup.add(currentCreatedAt);
1620
+ }
1621
+ if (!skipRound) _renderHistoryEvent(ev, frag, historyCtx);
1622
+ });
1623
+
1624
+ // Collapse any tool group still open at end of page
1625
+ if (historyCtx.group) _collapseToolGroup(historyCtx.group);
1626
+
1627
+ // Insert into #messages (only renders if this session is currently active)
1628
+ if (id === _activeId) {
1629
+ const messages = $("messages");
1630
+ if (prepend && messages.firstChild) {
1631
+ const scrollBefore = messages.scrollHeight - messages.scrollTop;
1632
+ messages.insertBefore(frag, messages.firstChild);
1633
+ messages.scrollTop = messages.scrollHeight - scrollBefore;
1634
+ } else {
1635
+ // Initial load or append: scroll to bottom (user just opened session or sent message)
1636
+ // If a progress indicator is already visible (attached instantly on session switch),
1637
+ // insert history above it so the progress element stays at the bottom.
1638
+ const pState = Sessions._sessionProgress[id];
1639
+ const existingProgressEl = pState && pState.el;
1640
+ if (existingProgressEl && existingProgressEl.parentNode === messages) {
1641
+ messages.insertBefore(frag, existingProgressEl);
1642
+ } else {
1643
+ messages.appendChild(frag);
1644
+ }
1645
+ messages.scrollTop = messages.scrollHeight;
1646
+ // Flush any tool_stdout lines that arrived via WS before this history
1647
+ // fetch completed (race condition on session switch).
1648
+ if (!prepend) _flushPendingStdout();
1649
+ }
1650
+
1651
+ // If no more history remains, insert a "beginning of conversation" marker at the top.
1652
+ // Remove any existing marker first to avoid duplicates.
1653
+ messages.querySelector(".history-start-marker")?.remove();
1654
+ if (!state.hasMore) {
1655
+ const marker = document.createElement("div");
1656
+ marker.className = "history-start-marker";
1657
+ marker.textContent = I18n.t("chat.history_start");
1658
+ messages.insertBefore(marker, messages.firstChild);
1659
+ }
1660
+
1661
+ // Restore transient UI state based on session status after initial load
1662
+ // (not prepend, which is scroll-up pagination — no need to re-restore then)
1663
+ if (!prepend) {
1664
+ const session = _sessions.find(s => s.id === id);
1665
+ if (session) {
1666
+ if (session.status === "running") {
1667
+ // Progress UI is already attached (done eagerly in Router._apply).
1668
+ // The backend's replay_live_state event will arrive shortly and call
1669
+ // showProgress() with the authoritative started_at, which is the
1670
+ // single source of truth for first-visit sessions (no cached state).
1671
+ } else if (session.status === "error" && session.error) {
1672
+ // Show the stored error message at the end of history
1673
+ Sessions.appendMsg("error", session.error);
1674
+ }
1675
+ }
1676
+ }
1677
+ }
1678
+ } finally {
1679
+ state.loading = false;
1680
+ // After loading finishes, re-evaluate the empty-state hint in case
1681
+ // the session is genuinely empty (no events + no existing DOM content).
1682
+ if (id === _activeId) _updateEmptyHint();
1683
+ }
1684
+ }
1685
+
1686
+ // ── Private helpers ───────────────────────────────────────────────────
1687
+
1688
+ // Return a human-readable relative label for a session with no name.
1689
+ // e.g. "Today 14:14" / "Yesterday" / "Mar 21"
1690
+ function _relativeTime(createdAt) {
1691
+ if (!createdAt) return I18n.t("sessions.untitled") || "Untitled";
1692
+ const d = new Date(createdAt);
1693
+ const now = new Date();
1694
+ const diffDays = Math.floor((now - d) / 86400000);
1695
+ const pad = n => String(n).padStart(2, "0");
1696
+ const hhmm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
1697
+ if (diffDays === 0) return `Today ${hhmm}`;
1698
+ if (diffDays === 1) return `Yesterday ${hhmm}`;
1699
+ return `${d.getMonth() + 1}/${d.getDate()} ${hhmm}`;
1700
+ }
1701
+
1702
+ // Format a timestamp for display inside a message bubble.
1703
+ // Same-day: "HH:MM"; cross-day: "MM-DD HH:MM".
1704
+ //
1705
+ // Accepts:
1706
+ // - ISO string ("2026-04-30T21:45:00Z")
1707
+ // - JS millisecond epoch (number ≥ 1e12)
1708
+ // - Unix second epoch (number < 1e12) — what the Ruby backend emits via
1709
+ // Time.now.to_f; we multiply by 1000 before handing to Date(), otherwise
1710
+ // JS interprets 1.77e9 as ~1970-01-21 and we get bogus timestamps.
1711
+ function _formatMsgTime(dateOrStr) {
1712
+ if (!dateOrStr) return "";
1713
+ let input = dateOrStr;
1714
+ if (typeof input === "number" && input < 1e12) input = input * 1000;
1715
+ const d = new Date(input);
1716
+ if (isNaN(d)) return "";
1717
+ const now = new Date();
1718
+ const pad = n => String(n).padStart(2, "0");
1719
+ const hhmm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
1720
+ const sameDay = d.getFullYear() === now.getFullYear() &&
1721
+ d.getMonth() === now.getMonth() &&
1722
+ d.getDate() === now.getDate();
1723
+ return sameDay ? hhmm : `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hhmm}`;
1724
+ }
1725
+
1726
+ // Append a .msg-time span to a message element.
1727
+ function _appendMsgTime(el, dateOrStr) {
1728
+ const t = _formatMsgTime(dateOrStr);
1729
+ if (!t) return;
1730
+ const span = document.createElement("span");
1731
+ span.className = "msg-time";
1732
+ span.textContent = t;
1733
+ el.appendChild(span);
1734
+ }
1735
+
1736
+ // ── Copy button for assistant messages ──────────────────────────────────
1737
+ //
1738
+ // Each assistant bubble gets a small copy button in its top-right corner.
1739
+ // Hidden by default (CSS), revealed on bubble hover — same UX pattern as
1740
+ // .msg-time. The raw markdown is read from el.dataset.raw (set by the
1741
+ // caller); falls back to textContent for safety.
1742
+ //
1743
+ // Clicks are handled via event delegation (see _ensureCopyDelegation below)
1744
+ // so we don't attach one listener per bubble.
1745
+
1746
+ function _appendCopyButton(el) {
1747
+ const btn = document.createElement("button");
1748
+ btn.type = "button";
1749
+ btn.className = "msg-copy-btn";
1750
+ btn.setAttribute("aria-label", I18n.t("chat.copy"));
1751
+ btn.title = I18n.t("chat.copy");
1752
+ btn.innerHTML =
1753
+ `<svg class="msg-copy-icon" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
1754
+ `<path fill="currentColor" d="M10 1H4a2 2 0 0 0-2 2v8h1.5V3a.5.5 0 0 1 .5-.5h6V1zm3 3H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm.5 10a.5.5 0 0 1-.5.5H6a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5v8z"/>` +
1755
+ `</svg>` +
1756
+ `<svg class="msg-copy-icon-check" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
1757
+ `<path fill="currentColor" d="M13.5 3.5 6 11 2.5 7.5 1 9l5 5 9-9z"/>` +
1758
+ `</svg>`;
1759
+ el.appendChild(btn);
1760
+ _ensureCopyDelegation();
1761
+ }
1762
+
1763
+ // Install the click-delegation listener on #messages exactly once.
1764
+ // Handles copy clicks for all current AND future assistant bubbles
1765
+ // AND code block copy buttons.
1766
+ let _copyDelegationInstalled = false;
1767
+ function _ensureCopyDelegation() {
1768
+ if (_copyDelegationInstalled) return;
1769
+ const messages = $("messages");
1770
+ if (!messages) return;
1771
+ messages.addEventListener("click", (e) => {
1772
+ // ── Code block copy button ──
1773
+ const codeBtn = e.target.closest(".code-block-copy");
1774
+ if (codeBtn) {
1775
+ e.preventDefault();
1776
+ e.stopPropagation();
1777
+ const block = codeBtn.closest(".code-block");
1778
+ if (!block) return;
1779
+ const codeEl = block.querySelector("pre code");
1780
+ if (!codeEl) return;
1781
+ _copyTextAndFlash(codeBtn, codeEl.textContent || "");
1782
+ return;
1783
+ }
1784
+ // ── Message-level copy button ──
1785
+ const btn = e.target.closest(".msg-copy-btn");
1786
+ if (!btn) return;
1787
+ e.preventDefault();
1788
+ e.stopPropagation();
1789
+ const bubble = btn.closest(".msg-assistant");
1790
+ if (!bubble) return;
1791
+ // Prefer the original raw markdown; fall back to rendered text.
1792
+ const raw = bubble.dataset.raw;
1793
+ const text = (raw && raw.length > 0) ? raw : _extractBubbleText(bubble);
1794
+ _copyTextAndFlash(btn, text);
1795
+ });
1796
+ _copyDelegationInstalled = true;
1797
+ }
1798
+
1799
+ // Extract visible text from a rendered assistant bubble, excluding the
1800
+ // copy button itself and the (collapsed) .msg-time span.
1801
+ function _extractBubbleText(bubble) {
1802
+ const clone = bubble.cloneNode(true);
1803
+ clone.querySelectorAll(".msg-copy-btn, .msg-time").forEach(n => n.remove());
1804
+ return (clone.textContent || "").trim();
1805
+ }
1806
+
1807
+ // Copy text to clipboard with a legacy fallback, then flash the button
1808
+ // into its "copied" state for 1.5s.
1809
+ function _copyTextAndFlash(btn, text) {
1810
+ const flash = (ok) => {
1811
+ if (!ok) return;
1812
+ btn.classList.add("is-copied");
1813
+ const prevLabel = btn.getAttribute("aria-label");
1814
+ btn.setAttribute("aria-label", I18n.t("chat.copied"));
1815
+ btn.title = I18n.t("chat.copied");
1816
+ setTimeout(() => {
1817
+ btn.classList.remove("is-copied");
1818
+ btn.setAttribute("aria-label", prevLabel || I18n.t("chat.copy"));
1819
+ btn.title = I18n.t("chat.copy");
1820
+ }, 1500);
1821
+ };
1822
+
1823
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1824
+ navigator.clipboard.writeText(text).then(
1825
+ () => flash(true),
1826
+ () => _legacyCopy(text, flash)
1827
+ );
1828
+ } else {
1829
+ _legacyCopy(text, flash);
1830
+ }
1831
+ }
1832
+
1833
+ // Fallback for browsers (or non-HTTPS contexts) without async clipboard API.
1834
+ function _legacyCopy(text, done) {
1835
+ try {
1836
+ const ta = document.createElement("textarea");
1837
+ ta.value = text;
1838
+ ta.setAttribute("readonly", "");
1839
+ ta.style.position = "absolute";
1840
+ ta.style.left = "-9999px";
1841
+ document.body.appendChild(ta);
1842
+ ta.select();
1843
+ const ok = document.execCommand("copy");
1844
+ document.body.removeChild(ta);
1845
+ done(ok);
1846
+ } catch (err) {
1847
+ console.warn("[copy] legacy copy failed:", err);
1848
+ done(false);
1849
+ }
1850
+ }
1851
+
1852
+ // Build the unified load-more button.
1853
+ function _makeLoadMoreBtn() {
1854
+ const btn = document.createElement("button");
1855
+ btn.className = "btn-load-more-sessions";
1856
+ btn.disabled = _loadingMore;
1857
+ btn.textContent = _loadingMore ? I18n.t("sessions.loadingMore") : I18n.t("sessions.loadMore");
1858
+ btn.onclick = () => Sessions.loadMore();
1859
+ return btn;
1860
+ }
1861
+
1862
+ // ── Private render helper ─────────────────────────────────────────────
1863
+ //
1864
+ // Build and append a single session-item <div> into `container`.
1865
+ // Used by both the general list and the coding section.
1866
+ function _renderSessionItem(container, s) {
1867
+ const el = document.createElement("div");
1868
+ el.className = "session-item" + (s.id === _activeId ? " active" : "");
1869
+ el.dataset.sessionId = s.id; // Add data attribute for easier lookup
1870
+ if (s.pinned) el.classList.add("pinned");
1871
+
1872
+ const displayName = s.name || _relativeTime(s.created_at);
1873
+
1874
+ // Meta line — prefer relative time of last activity. Tasks count is
1875
+ // only shown when > 0 to avoid visual noise on fresh sessions.
1876
+ // Cost is intentionally dropped from the list (move to hover/details).
1877
+ const metaParts = [];
1878
+ if (s.total_tasks && s.total_tasks > 0) {
1879
+ metaParts.push(I18n.t("sessions.metaTasks", { n: s.total_tasks }));
1880
+ }
1881
+ metaParts.push(_relativeTime(s.updated_at || s.created_at));
1882
+ const metaText = metaParts.join(" · ");
1883
+
1884
+ // Source badge — primary identity (cron/channel/setup).
1885
+ // Coding is the agent_profile (what kind of assistant is inside); we
1886
+ // show it as a subdued neutral badge alongside — they don't conflict
1887
+ // because source is "how the session was created" and coding is "what
1888
+ // agent runs inside". Using a muted badge for coding avoids drawing
1889
+ // attention away from the running-state dot, which is more important.
1890
+ const badgeKey = s.source === "cron" ? "sessions.badge.cron"
1891
+ : s.source === "channel" ? "sessions.badge.channel"
1892
+ : s.source === "setup" ? "sessions.badge.setup"
1893
+ : null;
1894
+ const badgeHtml = badgeKey
1895
+ ? `<span class="session-badge session-badge--${s.source}">${I18n.t(badgeKey)}</span>`
1896
+ : "";
1897
+
1898
+ // Coding profile badge (agent_profile === "coding"). Neutral styling so
1899
+ // it lives peacefully with the source badge and the status dot.
1900
+ const codingBadgeHtml = s.agent_profile === "coding"
1901
+ ? `<span class="session-badge session-badge--coding">${I18n.t("sessions.badge.coding")}</span>`
1902
+ : "";
1903
+
1904
+ // Pin icon (always visible for pinned sessions)
1905
+ const pinIcon = s.pinned ? `<span class="session-pin-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" style="transform:rotate(45deg);display:block"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/></svg></span>` : "";
1906
+
1907
+ // Status dot: only rendered for non-idle states. Idle is the default
1908
+ // state for 95% of sessions and doesn't deserve a persistent visual marker.
1909
+ const dotHtml = (s.status && s.status !== "idle")
1910
+ ? `<span class="session-dot dot-${s.status}"></span>`
1911
+ : "";
1912
+
1913
+ el.innerHTML = `
1914
+ <div class="session-body">
1915
+ <div class="session-name">${dotHtml}<span class="session-name__text">${escapeHtml(displayName)}</span>${badgeHtml}${codingBadgeHtml}${pinIcon}</div>
1916
+ <div class="session-meta">${metaText}</div>
1917
+ </div>
1918
+ <button class="session-actions-btn" title="Actions"><svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="2.5" cy="7" r="1.2" fill="currentColor"/><circle cx="7" cy="7" r="1.2" fill="currentColor"/><circle cx="11.5" cy="7" r="1.2" fill="currentColor"/></svg></button>`;
1919
+
1920
+ // Use a click timer to distinguish single-click (select) from double-click (old rename behavior).
1921
+ let clickTimer = null;
1922
+ el.onclick = (e) => {
1923
+ // Ignore clicks on the actions button
1924
+ if (e.target.closest(".session-actions-btn")) return;
1925
+
1926
+ if (clickTimer) {
1927
+ clearTimeout(clickTimer);
1928
+ clickTimer = null;
1929
+ return;
1930
+ }
1931
+ clickTimer = setTimeout(() => {
1932
+ clickTimer = null;
1933
+ Sessions.select(s.id);
1934
+ }, 200);
1935
+ };
1936
+
1937
+ // Actions button - show menu
1938
+ const actionsBtn = el.querySelector(".session-actions-btn");
1939
+ actionsBtn.onclick = (e) => {
1940
+ e.stopPropagation();
1941
+ Sessions._showActionsMenu(e.target, s);
1942
+ };
1943
+
1944
+ container.appendChild(el);
1945
+ }
1946
+
1947
+ // ── Cron group entry (renders the folded "Scheduled Tasks" entry) ─────
1948
+ function _renderCronGroupItem(container, count) {
1949
+ const el = document.createElement("div");
1950
+ el.className = "session-item cron-group-item";
1951
+ el.innerHTML = `
1952
+ <div class="session-body">
1953
+ <div class="session-name">
1954
+ <span class="session-dot dot-idle" style="display:inline-block;opacity:0.6"></span>
1955
+ <span class="session-name__text">📋 ${I18n.t("sessions.cronGroup")} (${count})</span>
1956
+ </div>
1957
+ <div class="session-meta">${I18n.t("sessions.cronGroupMeta", { n: count })}</div>
1958
+ </div>
1959
+ `;
1960
+ el.onclick = () => {
1961
+ _cronView = true;
1962
+ Sessions.renderList();
1963
+ };
1964
+ container.appendChild(el);
1965
+ }
1966
+
1967
+ // ── Chat-section header visibility ────────────────────────────────────
1968
+ function _updateChatHeader(isCronView) {
1969
+ const chatSection = document.getElementById("chat-section");
1970
+ if (!chatSection) return;
1971
+
1972
+ const normalHeader = chatSection.querySelector(":scope > .sidebar-divider:first-of-type");
1973
+ const cronHeader = document.getElementById("cron-view-header");
1974
+ const searchBar = document.getElementById("session-search-bar");
1975
+ const newSessionBtn = document.getElementById("btn-session-search-toggle");
1976
+
1977
+ if (isCronView) {
1978
+ if (normalHeader) normalHeader.style.display = "none";
1979
+ if (cronHeader) cronHeader.style.display = "";
1980
+ if (searchBar) searchBar.hidden = true;
1981
+ if (newSessionBtn) newSessionBtn.style.display = "none";
1982
+ } else {
1983
+ if (normalHeader) normalHeader.style.display = "";
1984
+ if (cronHeader) cronHeader.style.display = "none";
1985
+ if (searchBar) searchBar.hidden = !_searchOpen;
1986
+ // newSessionBtn display managed by renderList's magnifier logic
1987
+ }
1988
+ }
1989
+
1990
+ // ── Public API ─────────────────────────────────────────────────────────
1991
+ return {
1992
+ get all() { return _sessions; },
1993
+ get activeId() { return _activeId; },
1994
+ get searchOpen() { return _searchOpen; },
1995
+ find: id => _sessions.find(s => s.id === id),
1996
+
1997
+ // Composer entry point — called by Skill autocomplete keydown handler
1998
+ // (in app.js) when the user presses Enter without an active completion.
1999
+ // Will be internalised once the Skill autocomplete moves into skills.js.
2000
+ sendMessage: _sendMessage,
2001
+
2002
+ // Ghost-text suggestion (placeholder + Tab-to-accept). Called from the
2003
+ // WS dispatcher when the backend emits "next_message_suggestion".
2004
+ setInputSuggestion(text) {
2005
+ if (!text || typeof text !== "string") {
2006
+ this.clearInputSuggestion();
2007
+ return;
2008
+ }
2009
+ const input = document.getElementById("user-input");
2010
+ // Don't overwrite a suggestion the user is already in the middle of
2011
+ // ignoring (they've started typing). Browser hides the placeholder
2012
+ // anyway, but we'd rather not waste DOM writes.
2013
+ if (input && input.value) return;
2014
+ _suggestionText = text;
2015
+ _applySuggestionToDOM();
2016
+ },
2017
+ clearInputSuggestion() {
2018
+ if (_suggestionText === null) {
2019
+ // Still call _applySuggestionToDOM to restore the default placeholder
2020
+ // in case it was overwritten by a stale render.
2021
+ _applySuggestionToDOM();
2022
+ return;
2023
+ }
2024
+ _suggestionText = null;
2025
+ _applySuggestionToDOM();
2026
+ },
2027
+ updateBgTasksBadge(running, tasks) {
2028
+ const badge = $("sib-bgtasks");
2029
+ const sep = document.querySelector(".sib-sep-after-bgtasks");
2030
+ if (!badge) return;
2031
+
2032
+ if (!running || running === 0) {
2033
+ badge.style.display = "none";
2034
+ if (sep) sep.style.display = "none";
2035
+ const pop = $("sib-bgtasks-popover");
2036
+ if (pop) pop.style.display = "none";
2037
+ return;
2038
+ }
2039
+
2040
+ const label = I18n.t("bgtasks.badge", { n: running });
2041
+ badge.innerHTML = `<span class="bgtasks-dot" aria-hidden="true"></span><span class="bgtasks-count">${escapeHtml(label)}</span>`;
2042
+ badge.style.display = "";
2043
+ badge.removeAttribute("data-i18n-title");
2044
+ if (sep) sep.style.display = "";
2045
+
2046
+ const lines = (tasks || []).slice(0, 5).map(t => {
2047
+ const cmd = (t.command || "").trim();
2048
+ const elapsed = t.elapsed || 0;
2049
+ return `${cmd} (${elapsed}s)`;
2050
+ });
2051
+ const more = (tasks && tasks.length > 5) ? `\n…and ${tasks.length - 5} more` : "";
2052
+ badge.title = lines.join("\n") + more;
2053
+
2054
+ badge.dataset.tasks = JSON.stringify(tasks || []);
2055
+ },
2056
+ setInputQueueHint(pending) {
2057
+ const hint = document.getElementById("input-queue-hint");
2058
+ if (!hint) return;
2059
+ const n = Number(pending) || 0;
2060
+ if (n <= 0) {
2061
+ hint.style.display = "none";
2062
+ hint.textContent = "";
2063
+ return;
2064
+ }
2065
+ const key = n === 1 ? "inbox.queue.one" : "inbox.queue.many";
2066
+ hint.textContent = I18n.t(key, { n: n });
2067
+ hint.style.display = "";
2068
+ },
2069
+ appendBgTaskNotice(command, handleId, status) {
2070
+ Sessions.collapseToolGroup();
2071
+ const messages = $("messages");
2072
+ const el = document.createElement("div");
2073
+ el.className = `msg msg-bgtask-notice msg-bgtask-${status || "success"}`;
2074
+
2075
+ const iconMap = { success: "✓", failed: "✗", cancelled: "⏹", error: "⚠" };
2076
+ const icon = iconMap[status] || "✓";
2077
+
2078
+ const cmdShort = (command || "").length > 60
2079
+ ? (command.slice(0, 60) + "…")
2080
+ : (command || "");
2081
+ const idSuffix = handleId ? ` <code class="bgtask-id">${escapeHtml(handleId)}</code>` : "";
2082
+
2083
+ el.innerHTML = `<span class="bgtask-icon">${icon}</span>` +
2084
+ `<span class="bgtask-text">${I18n.t("bgtasks.notice", { status: status || "success" })}</span> ` +
2085
+ `<code class="bgtask-cmd" title="${escapeHtml(command || '')}">${escapeHtml(cmdShort)}</code>` +
2086
+ idSuffix;
2087
+ messages.appendChild(el);
2088
+ _scrollToBottomIfNeeded(messages);
2089
+ },
2090
+ // ── Init ──────────────────────────────────────────────────────────────
2091
+ init() {
2092
+ _initNewMessageBanner();
2093
+ _initEmptyHint();
2094
+ _initNewSessionControls();
2095
+ _initComposer();
2096
+ _initSearch();
2097
+ _initMessageHistory();
2098
+ // Re-render session list (badges/labels) when the user switches language
2099
+ document.addEventListener("langchange", () => Sessions.renderList());
2100
+
2101
+ // Cron view back button
2102
+ document.getElementById("btn-cron-back")
2103
+ .addEventListener("click", () => {
2104
+ _cronView = false;
2105
+ Sessions.renderList();
2106
+ });
2107
+
2108
+ // Browsers block file:// navigation from http:// pages. Intercept clicks on
2109
+ // file:// links and delegate to the backend API.
2110
+ // Local deployments (localhost / 127.0.0.1 / ::1): open the file with the
2111
+ // OS default handler. Remote deployments: download the file.
2112
+ document.addEventListener("click", async (e) => {
2113
+ const link = e.target.closest("a[href^='file://']");
2114
+ if (!link) return;
2115
+ e.preventDefault();
2116
+ let filePath = decodeURIComponent(link.getAttribute("href").replace(/^file:\/\//, ""));
2117
+ // file:///C:/foo → /C:/foo after replace; strip the leading slash for Windows drive letters
2118
+ if (/^\/[A-Za-z]:/.test(filePath)) filePath = filePath.substring(1);
2119
+ if (!filePath) return;
2120
+
2121
+ const hostname = window.location.hostname;
2122
+ const isLocal = ["localhost", "127.0.0.1", "::1"].includes(hostname);
2123
+ const action = isLocal ? "open" : "download";
2124
+
2125
+ try {
2126
+ const resp = await fetch("/api/file-action", {
2127
+ method: "POST",
2128
+ headers: { "Content-Type": "application/json" },
2129
+ body: JSON.stringify({ path: filePath, action })
2130
+ });
2131
+
2132
+ if (action === "download" && resp.ok) {
2133
+ const blob = await resp.blob();
2134
+ const url = URL.createObjectURL(blob);
2135
+ const a = document.createElement("a");
2136
+ a.href = url;
2137
+ a.download = filePath.split("/").pop() || "download";
2138
+ document.body.appendChild(a);
2139
+ a.click();
2140
+ a.remove();
2141
+ URL.revokeObjectURL(url);
2142
+ }
2143
+ } catch (err) {
2144
+ console.error("file-action failed:", err);
2145
+ }
2146
+ });
2147
+ },
2148
+
2149
+ // ── List management ───────────────────────────────────────────────────
2150
+
2151
+ /** Populate list from initial session_list WS event (connect only). */
2152
+ setAll(list, hasMore = false, cronCount = 0) {
2153
+ _sessions.length = 0;
2154
+ _sessions.push(...list);
2155
+ _hasMore = !!hasMore;
2156
+ _cronCount = cronCount;
2157
+ },
2158
+
2159
+ /** Insert a newly created session into the local list. */
2160
+ add(session) {
2161
+ if (!_sessions.find(s => s.id === session.id)) {
2162
+ _sessions.push(session);
2163
+ if (session.source === "cron") _cronCount++;
2164
+ }
2165
+ },
2166
+
2167
+ /** Patch a single session's fields (from session_update event).
2168
+ * If the session is not in the list yet (e.g. just created by another tab),
2169
+ * prepend it so the sidebar shows it immediately. */
2170
+ patch(id, fields) {
2171
+ const s = _sessions.find(s => s.id === id);
2172
+ if (s) {
2173
+ Object.assign(s, fields);
2174
+ } else {
2175
+ _sessions.unshift({ id, ...fields });
2176
+ }
2177
+ },
2178
+
2179
+ /** Remove a session from the list (from session_deleted event). */
2180
+ remove(id) {
2181
+ const idx = _sessions.findIndex(s => s.id === id);
2182
+ if (idx !== -1) {
2183
+ if (_sessions[idx].source === "cron") _cronCount = Math.max(0, _cronCount - 1);
2184
+ _sessions.splice(idx, 1);
2185
+ }
2186
+ // Clean up per-session progress state (timer + DOM + logical state)
2187
+ Sessions._deleteProgressState(id);
2188
+ },
2189
+
2190
+ /** Load the next page of older sessions (unified time cursor). */
2191
+ async loadMore() {
2192
+ if (_loadingMore || !_hasMore) return;
2193
+ _loadingMore = true;
2194
+
2195
+ // Save scroll position so the sidebar doesn't jump back to the active
2196
+ // session when renderList() forces scroll-to-active.
2197
+ const sidebarList = document.getElementById("sidebar-list");
2198
+ const savedScrollTop = sidebarList ? sidebarList.scrollTop : 0;
2199
+ Sessions.renderList({ skipScrollToActive: true });
2200
+
2201
+ try {
2202
+ // Cursor: oldest created_at in the current list, EXCLUDING pinned
2203
+ // sessions. The backend always returns ALL pinned sessions on the
2204
+ // first page (they bypass pagination), so their created_at is
2205
+ // irrelevant for cursor calculation. Including them here would
2206
+ // cause the cursor to jump too far back and skip sessions between
2207
+ // the oldest pinned one and the real last-loaded non-pinned row.
2208
+ const oldest = _sessions.reduce((min, s) => {
2209
+ if (s.pinned) return min; // ignore pinned
2210
+ if (!s.created_at) return min;
2211
+ return (!min || s.created_at < min) ? s.created_at : min;
2212
+ }, null);
2213
+
2214
+ const params = new URLSearchParams({ limit: "20" });
2215
+ if (oldest) params.set("before", oldest);
2216
+ if (_filter.q) params.set("q", _filter.q);
2217
+ if (_filter.date) params.set("date", _filter.date);
2218
+ if (_filter.type) params.set("type", _filter.type);
2219
+
2220
+ const res = await fetch(`/api/sessions?${params}`);
2221
+ if (!res.ok) return;
2222
+ const data = await res.json();
2223
+
2224
+ (data.sessions || []).forEach(s => {
2225
+ if (!_sessions.find(x => x.id === s.id)) _sessions.push(s);
2226
+ });
2227
+ _hasMore = !!data.has_more;
2228
+ _cronCount = data.cron_count || 0;
2229
+ } catch (e) {
2230
+ console.error("loadMore error:", e);
2231
+ } finally {
2232
+ _loadingMore = false;
2233
+ Sessions.renderList({ skipScrollToActive: true });
2234
+ // Restore scroll position so the user stays where they were
2235
+ if (sidebarList) sidebarList.scrollTop = savedScrollTop;
2236
+ }
2237
+ },
2238
+
2239
+ /** Commit current filter values and re-fetch from server. Called by Enter / Go button. */
2240
+ async commitSearch() {
2241
+ // Read live input values into _filter
2242
+ const qEl = document.getElementById("session-search-q");
2243
+ const typeEl = document.getElementById("session-search-type");
2244
+ const dateEl = document.getElementById("session-search-date");
2245
+ if (qEl) _filter.q = qEl.value.trim();
2246
+ if (typeEl) _filter.type = typeEl.value;
2247
+ if (dateEl) _filter.date = dateEl.dataset.value || "";
2248
+
2249
+ // Clear list and reload from server with new filters
2250
+ _sessions.length = 0;
2251
+ _hasMore = false;
2252
+ _loadingMore = true;
2253
+ Sessions.renderList();
2254
+
2255
+ try {
2256
+ const params = new URLSearchParams({ limit: "20" });
2257
+ if (_filter.q) params.set("q", _filter.q);
2258
+ if (_filter.date) params.set("date", _filter.date);
2259
+ if (_filter.type) params.set("type", _filter.type);
2260
+
2261
+ const res = await fetch(`/api/sessions?${params}`);
2262
+ if (!res.ok) return;
2263
+ const data = await res.json();
2264
+ _sessions.push(...(data.sessions || []));
2265
+ _hasMore = !!data.has_more;
2266
+ _cronCount = data.cron_count || 0;
2267
+ } catch (e) {
2268
+ console.error("commitSearch error:", e);
2269
+ } finally {
2270
+ _loadingMore = false;
2271
+ Sessions.renderList();
2272
+ }
2273
+ },
2274
+
2275
+ /** Clear a single filter key and re-fetch. */
2276
+ async clearFilter(key) {
2277
+ _filter[key] = "";
2278
+ const ids = { q: "session-search-q", type: "session-search-type", date: "session-search-date" };
2279
+ const el = document.getElementById(ids[key]);
2280
+ if (el) {
2281
+ if (key === "date") DatePicker.clear(el);
2282
+ else el.value = "";
2283
+ }
2284
+ await Sessions.commitSearch();
2285
+ },
2286
+
2287
+ /** Toggle the search panel open/closed. */
2288
+ toggleSearch() {
2289
+ _searchOpen = !_searchOpen;
2290
+ const panel = document.getElementById("session-search-bar");
2291
+ const togBtn = document.getElementById("btn-session-search-toggle");
2292
+ if (!panel) return;
2293
+
2294
+ if (_searchOpen) {
2295
+ panel.hidden = false;
2296
+ panel.classList.add("search-panel--open");
2297
+ togBtn && togBtn.classList.add("active");
2298
+ // Auto-focus the text input
2299
+ const inp = document.getElementById("session-search-q");
2300
+ if (inp) setTimeout(() => inp.focus(), 30);
2301
+ } else {
2302
+ panel.classList.remove("search-panel--open");
2303
+ togBtn && togBtn.classList.remove("active");
2304
+ // After animation finishes, hide panel and reset inputs
2305
+ const hadActiveFilter = _filter.q || _filter.date || _filter.type;
2306
+ setTimeout(() => {
2307
+ panel.hidden = true;
2308
+ // Reset DOM inputs
2309
+ const qEl = document.getElementById("session-search-q");
2310
+ const dEl = document.getElementById("session-search-date");
2311
+ const tEl = document.getElementById("session-search-type");
2312
+ if (qEl) qEl.value = "";
2313
+ if (dEl) DatePicker.clear(dEl);
2314
+ if (tEl) tEl.value = "";
2315
+ // Clear filter state
2316
+ _filter.q = _filter.date = _filter.type = "";
2317
+ // Only re-fetch if a filter was actually active (avoids pointless reload)
2318
+ if (hadActiveFilter) Sessions.commitSearch();
2319
+ }, 180);
2320
+ }
2321
+ },
2322
+
2323
+ // kept for compat
2324
+ setTab() {},
2325
+ /** @deprecated — use commitSearch */
2326
+ async search(patch) {
2327
+ Object.assign(_filter, patch);
2328
+ await Sessions.commitSearch();
2329
+ },
2330
+
2331
+ /** Delete a session via API (called from UI delete button). */
2332
+ async deleteSession(id) {
2333
+ const s = _sessions.find(s => s.id === id);
2334
+ const name = s ? s.name : id;
2335
+ const confirmed = await Modal.confirm(I18n.t("sessions.confirmDelete", { name }));
2336
+ if (!confirmed) return;
2337
+
2338
+ try {
2339
+ const res = await fetch(`/api/sessions/${id}`, { method: "DELETE" });
2340
+ if (res.ok) {
2341
+ // Optimistically remove from local list immediately without waiting for
2342
+ // the WS session_deleted broadcast (handles WS lag or disconnected state).
2343
+ Sessions.remove(id);
2344
+ if (id === Sessions.activeId) Router.navigate("welcome");
2345
+ Sessions.renderList();
2346
+ } else {
2347
+ const data = await res.json().catch(() => ({}));
2348
+ console.error("Failed to delete session:", data.error || res.status);
2349
+ // If server says not found, remove it from local list anyway to keep UI consistent.
2350
+ if (res.status === 404) {
2351
+ Sessions.remove(id);
2352
+ if (id === Sessions.activeId) Router.navigate("welcome");
2353
+ Sessions.renderList();
2354
+ }
2355
+ }
2356
+ // Server also broadcasts session_deleted WS event; Sessions.remove() is idempotent
2357
+ // so duplicate removal is harmless.
2358
+ } catch (err) {
2359
+ console.error("Delete session error:", err);
2360
+ }
2361
+ },
2362
+
2363
+ // ── Selection ─────────────────────────────────────────────────────────
2364
+ //
2365
+ // Panel switching is handled by Router — Sessions only manages state.
2366
+
2367
+ /** Navigate to a session. Delegates panel switching to Router. */
2368
+ select(id) {
2369
+ const s = _sessions.find(s => s.id === id);
2370
+ if (!s) return;
2371
+ Router.navigate("session", { id });
2372
+ },
2373
+
2374
+ /** Deselect active session and go to welcome screen. */
2375
+ deselect() {
2376
+ _cacheActiveMessages();
2377
+ _activeId = null;
2378
+ WS.setSubscribedSession(null);
2379
+ Router.navigate("welcome");
2380
+ },
2381
+
2382
+ // ── Router interface ──────────────────────────────────────────────────
2383
+ // These methods are called exclusively by Router._apply() to mutate
2384
+ // session state as part of a coordinated view transition. They must NOT
2385
+ // trigger further Router.navigate() calls to avoid infinite loops.
2386
+
2387
+ /** Set _activeId directly (called by Router when activating a session). */
2388
+ _setActiveId(id) {
2389
+ _activeId = id;
2390
+ // Suggestions are scoped to whoever owned the input at the time of
2391
+ // emission; switching session means the previous suggestion no longer
2392
+ // applies. The new session's most recent suggestion (if any) will
2393
+ // arrive via replay if/when the server re-emits.
2394
+ _suggestionText = null;
2395
+ _applySuggestionToDOM();
2396
+ // Inbox-queue hint is per-session; clear stale display until the new
2397
+ // session's first user_message_queue_status arrives (or doesn't, in
2398
+ // which case the hint stays hidden — correct).
2399
+ Sessions.setInputQueueHint(0);
2400
+ },
2401
+
2402
+ /** Restore cached messages for a session into the #messages container. */
2403
+ _restoreMessagesPublic(id) {
2404
+ _restoreMessages(id);
2405
+ },
2406
+
2407
+ /** Cache messages + clear activeId without touching panel visibility.
2408
+ * Called by Router before switching away from a session view. */
2409
+ _cacheActiveAndDeselect() {
2410
+ _cacheActiveMessages();
2411
+ // Detach progress UI (DOM + timer) but preserve the logical state
2412
+ // so it can be restored when the user switches back to this session.
2413
+ if (_activeId) Sessions._detachProgressUI(_activeId);
2414
+ _activeId = null;
2415
+ WS.setSubscribedSession(null);
2416
+ Sessions.renderList();
2417
+ },
2418
+
2419
+ // ── Rendering ─────────────────────────────────────────────────────────
2420
+
2421
+ renderList({ skipScrollToActive = false } = {}) {
2422
+ // Sort helper: pinned first, then newest-first by created_at
2423
+ const byPinnedAndTime = (a, b) => {
2424
+ // Pinned sessions always come first
2425
+ if (a.pinned && !b.pinned) return -1;
2426
+ if (!a.pinned && b.pinned) return 1;
2427
+ // Within same pinned status, sort by time (newest first)
2428
+ const ta = a.created_at ? new Date(a.created_at) : 0;
2429
+ const tb = b.created_at ? new Date(b.created_at) : 0;
2430
+ return tb - ta;
2431
+ };
2432
+
2433
+ // ── Apply client-side filter (mirrors server params for instant feedback) ─
2434
+ const { q, date, type } = _filter;
2435
+ let visible = [..._sessions].sort(byPinnedAndTime);
2436
+ if (date) visible = visible.filter(s => (s.created_at || "").startsWith(date));
2437
+ if (type) {
2438
+ visible = type === "coding"
2439
+ ? visible.filter(s => s.agent_profile === "coding")
2440
+ : visible.filter(s => s.source === type && s.agent_profile !== "coding");
2441
+ }
2442
+
2443
+ // ── Show/hide magnifier button ─────────────────────────────────────
2444
+ // Always visible when search panel is open; otherwise hide when < 10 sessions total.
2445
+ const togBtn = document.getElementById("btn-session-search-toggle");
2446
+ if (togBtn) togBtn.style.display = (_searchOpen || _sessions.length >= 10) ? "" : "none";
2447
+
2448
+ // ── Update filter UI: highlight active selects/date, show/hide clear button ──
2449
+ const typeEl = document.getElementById("session-search-type");
2450
+ const dateEl = document.getElementById("session-search-date");
2451
+ const clearAllBtn = document.getElementById("btn-search-clear-all");
2452
+ const qClearBtn = document.getElementById("btn-search-q-clear");
2453
+ if (typeEl) typeEl.dataset.active = _filter.type ? "true" : "false";
2454
+ if (dateEl) dateEl.dataset.active = _filter.date ? "true" : "false";
2455
+ const hasFilter = !!(_filter.type || _filter.date);
2456
+ if (clearAllBtn) clearAllBtn.hidden = !hasFilter;
2457
+ // ✕ inside the input — update based on current q value
2458
+ const qEl = document.getElementById("session-search-q");
2459
+ if (qClearBtn) qClearBtn.hidden = !(qEl && qEl.value);
2460
+
2461
+ // ── Split cron vs non-cron for folding ───────────────────────────
2462
+ const hasActiveFilter = !!(_filter.q || _filter.type || _filter.date);
2463
+ const isCronView = _cronView && !hasActiveFilter;
2464
+ const cronSessions = visible.filter(s => s.source === "cron");
2465
+ const nonCronSessions = visible.filter(s => s.source !== "cron");
2466
+
2467
+ // Update chat-section header based on view mode
2468
+ _updateChatHeader(isCronView);
2469
+
2470
+ const list = $("session-list");
2471
+ list.innerHTML = "";
2472
+
2473
+ if (hasActiveFilter) {
2474
+ // Filter active: show all matching results flat, no group entry
2475
+ visible.forEach(s => _renderSessionItem(list, s));
2476
+ } else if (isCronView) {
2477
+ // Cron sub-view: show only cron sessions.
2478
+ // If none are loaded yet, auto-load more pages until we find them.
2479
+ if (cronSessions.length === 0) {
2480
+ if (_hasMore && !_loadingMore) {
2481
+ list.innerHTML = `<div class="session-empty">${I18n.t("sessions.cronLoading")}</div>`;
2482
+ Sessions.loadMore(); // async — will call renderList() again when done
2483
+ return; // skip empty-state / load-more button for now
2484
+ }
2485
+ if (_loadingMore) {
2486
+ // A loadMore() call is already in flight (its own renderList call
2487
+ // reached us). Keep the loading indicator so the user never sees
2488
+ // the "no sessions" empty state during the gap.
2489
+ list.innerHTML = `<div class="session-empty">${I18n.t("sessions.cronLoading")}</div>`;
2490
+ return;
2491
+ }
2492
+ }
2493
+ cronSessions.forEach(s => _renderSessionItem(list, s));
2494
+ } else if (_cronCount > 0) {
2495
+ // Normal list view: group entry (uses total count, not just loaded) + non-cron sessions
2496
+ _renderCronGroupItem(list, _cronCount);
2497
+ nonCronSessions.forEach(s => _renderSessionItem(list, s));
2498
+ } else {
2499
+ // Normal list view, no cron sessions
2500
+ visible.forEach(s => _renderSessionItem(list, s));
2501
+ }
2502
+
2503
+ // Empty state fallback
2504
+ if (list.children.length === 0) {
2505
+ list.innerHTML = `<div class="session-empty">${I18n.t("sessions.empty")}</div>`;
2506
+ }
2507
+
2508
+ if (_hasMore) list.appendChild(_makeLoadMoreBtn());
2509
+
2510
+ // Scroll active session into view so the sidebar always shows the current session.
2511
+ if (!skipScrollToActive) {
2512
+ const activeEl = list.querySelector(".session-item.active");
2513
+ if (activeEl) {
2514
+ // If the active session is the very first item, scroll to top of the sidebar
2515
+ // container so sticky headers / expanded panels don't obscure it.
2516
+ if (activeEl === list.firstElementChild) {
2517
+ const sidebarList = document.getElementById("sidebar-list");
2518
+ if (sidebarList) sidebarList.scrollTop = 0;
2519
+ } else {
2520
+ activeEl.scrollIntoView({ block: "nearest" });
2521
+ }
2522
+ }
2523
+ }
2524
+ },
2525
+
2526
+ /** Show rename modal and update session name. */
2527
+ async _startRename(sessionId, nameDiv, currentName) {
2528
+ const newName = await Modal.rename(currentName);
2529
+ if (!newName || newName === currentName) return;
2530
+
2531
+ try {
2532
+ const res = await fetch(`/api/sessions/${sessionId}`, {
2533
+ method: "PATCH",
2534
+ headers: { "Content-Type": "application/json" },
2535
+ body: JSON.stringify({ name: newName })
2536
+ });
2537
+ if (res.ok) {
2538
+ Sessions.patch(sessionId, { name: newName });
2539
+ Sessions.renderList();
2540
+ } else {
2541
+ console.error("Rename failed:", await res.text());
2542
+ }
2543
+ } catch (err) {
2544
+ console.error("Rename error:", err);
2545
+ }
2546
+ },
2547
+
2548
+ /** Show actions menu (pin/rename/delete) next to the actions button. */
2549
+ _showActionsMenu(button, session) {
2550
+ // Close any existing menu first
2551
+ Sessions._closeActionsMenu();
2552
+
2553
+ // Lucide-style stroked icons to match the rest of the UI
2554
+ const iconPin = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="transform:rotate(45deg);display:block"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>`;
2555
+ const iconRename = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4z"/></svg>`;
2556
+ const iconTrash = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>`;
2557
+
2558
+ const pinLabel = session.pinned ? I18n.t("sessions.actions.unpin") : I18n.t("sessions.actions.pin");
2559
+
2560
+ const menu = document.createElement("div");
2561
+ menu.className = "session-actions-menu";
2562
+ menu.innerHTML = `
2563
+ <div class="session-actions-menu-item" data-action="pin">
2564
+ <span class="session-actions-menu-icon">${iconPin}</span>
2565
+ <span class="session-actions-menu-label">${escapeHtml(pinLabel)}</span>
2566
+ </div>
2567
+ <div class="session-actions-menu-item" data-action="rename">
2568
+ <span class="session-actions-menu-icon">${iconRename}</span>
2569
+ <span class="session-actions-menu-label">${escapeHtml(I18n.t("sessions.actions.rename"))}</span>
2570
+ </div>
2571
+ <div class="session-actions-menu-item session-actions-menu-item--danger" data-action="delete">
2572
+ <span class="session-actions-menu-icon">${iconTrash}</span>
2573
+ <span class="session-actions-menu-label">${escapeHtml(I18n.t("sessions.actions.delete"))}</span>
2574
+ </div>
2575
+ `;
2576
+
2577
+ // Position menu to the right of the button
2578
+ document.body.appendChild(menu);
2579
+ const rect = button.getBoundingClientRect();
2580
+ menu.style.position = "fixed";
2581
+ menu.style.top = rect.top + "px";
2582
+ menu.style.left = (rect.right + 8) + "px";
2583
+
2584
+ // Handle menu item clicks
2585
+ menu.addEventListener("click", async (e) => {
2586
+ const item = e.target.closest(".session-actions-menu-item");
2587
+ if (!item) return;
2588
+
2589
+ const action = item.dataset.action;
2590
+ Sessions._closeActionsMenu();
2591
+
2592
+ if (action === "pin") {
2593
+ await Sessions.togglePin(session.id);
2594
+ } else if (action === "rename") {
2595
+ // Close sidebar on mobile so the rename dialog isn't obscured
2596
+ window.mobileCloseSidebar?.();
2597
+ // Find the session item by data-session-id attribute
2598
+ const sessionItem = document.querySelector(`.session-item[data-session-id="${session.id}"]`);
2599
+ if (sessionItem) {
2600
+ const nameDiv = sessionItem.querySelector(".session-name");
2601
+ Sessions._startRename(session.id, nameDiv, session.name);
2602
+ }
2603
+ } else if (action === "delete") {
2604
+ // Close sidebar on mobile so the delete dialog isn't obscured
2605
+ window.mobileCloseSidebar?.();
2606
+ await Sessions.deleteSession(session.id);
2607
+ }
2608
+ });
2609
+
2610
+ // Close menu when clicking outside
2611
+ setTimeout(() => {
2612
+ document.addEventListener("click", Sessions._closeActionsMenu, { once: true });
2613
+ }, 0);
2614
+
2615
+ // Store reference for cleanup
2616
+ menu._isSessionActionsMenu = true;
2617
+ },
2618
+
2619
+ /** Close the actions menu if open. */
2620
+ _closeActionsMenu() {
2621
+ const existing = document.querySelector(".session-actions-menu");
2622
+ if (existing) existing.remove();
2623
+ },
2624
+
2625
+ /** Toggle pin status of a session. */
2626
+ async togglePin(sessionId) {
2627
+ const session = _sessions.find(s => s.id === sessionId);
2628
+ if (!session) return;
2629
+
2630
+ const newPinnedState = !session.pinned;
2631
+
2632
+ try {
2633
+ const res = await fetch(`/api/sessions/${sessionId}`, {
2634
+ method: "PATCH",
2635
+ headers: { "Content-Type": "application/json" },
2636
+ body: JSON.stringify({ pinned: newPinnedState })
2637
+ });
2638
+
2639
+ if (res.ok) {
2640
+ // Update local state
2641
+ session.pinned = newPinnedState;
2642
+ Sessions.renderList();
2643
+ } else {
2644
+ console.error("Toggle pin failed:", await res.text());
2645
+ }
2646
+ } catch (err) {
2647
+ console.error("Toggle pin error:", err);
2648
+ }
2649
+ },
2650
+
2651
+ /** Delete a session after confirmation. */
2652
+ async deleteSession(sessionId) {
2653
+ const session = _sessions.find(s => s.id === sessionId);
2654
+ if (!session) return;
2655
+
2656
+ const confirmed = await Modal.confirm(
2657
+ I18n.t("sessions.confirmDelete", { name: session.name })
2658
+ );
2659
+ if (!confirmed) return;
2660
+
2661
+ try {
2662
+ const res = await fetch(`/api/sessions/${sessionId}`, { method: "DELETE" });
2663
+ if (res.ok) {
2664
+ Sessions.remove(sessionId);
2665
+ Sessions.renderList();
2666
+ // If deleted session was active, switch to welcome
2667
+ if (sessionId === _activeId) {
2668
+ Router.navigate("welcome");
2669
+ }
2670
+ } else {
2671
+ console.error("Delete failed:", await res.text());
2672
+ }
2673
+ } catch (err) {
2674
+ console.error("Delete error:", err);
2675
+ }
2676
+ },
2677
+
2678
+ updateStatusBar(status) {
2679
+ // chat-header was removed; status text is now shown in the bottom session-info-bar (#sib-status).
2680
+ // Here we only update the interrupt button visibility.
2681
+ const interrupt = $("btn-interrupt");
2682
+ if (interrupt) interrupt.style.display = status === "running" ? "" : "none";
2683
+
2684
+ // Swap input placeholder so the user knows they can still send extra
2685
+ // info while the agent is working.
2686
+ const inp = $("user-input");
2687
+ if (inp) {
2688
+ const mobile = window.innerWidth <= 768;
2689
+ const key = status === "running"
2690
+ ? (mobile ? "chat.input.placeholderRunningMobile" : "chat.input.placeholderRunning")
2691
+ : (mobile ? "chat.input.placeholderMobile" : "chat.input.placeholder");
2692
+ inp.setAttribute("data-i18n-placeholder", key);
2693
+ inp.setAttribute("placeholder", I18n.t(key));
2694
+ }
2695
+ },
2696
+
2697
+ /**
2698
+ * No-op: the chat header element (#chat-header) was removed. All session
2699
+ * metadata (title, source, working dir, status) is now shown in the
2700
+ * sidebar and the bottom #session-info-bar. Kept as a stub so existing
2701
+ * call sites don't need to be updated.
2702
+ */
2703
+ updateChatHeader(_s) {
2704
+ // intentionally empty
2705
+ },
2706
+
2707
+ /** Update the session info bar below the chat header with current session metadata. */
2708
+ updateInfoBar(s) {
2709
+ this._lastSession = s;
2710
+ if (!s) {
2711
+ // Hide all spans when no session
2712
+ ["sib-id", "sib-status", "sib-dir", "sib-mode", "sib-model", "sib-reasoning", "sib-tasks"].forEach(id => {
2713
+ const el = $(id); if (el) el.textContent = "";
2714
+ });
2715
+ const sibIdEl = $("sib-id");
2716
+ if (sibIdEl) delete sibIdEl.dataset.sessionId;
2717
+ const actionsDd = $("sib-actions-dropdown");
2718
+ if (actionsDd) actionsDd.style.display = "none";
2719
+ const bar = $("session-info-bar");
2720
+ if (bar) bar.style.display = "none";
2721
+ return;
2722
+ }
2723
+
2724
+ // Status dot + text — first
2725
+ const sibStatus = $("sib-status");
2726
+ if (sibStatus) {
2727
+ sibStatus.textContent = `● ${s.status || "idle"}`;
2728
+ sibStatus.className = `sib-status-${s.status || "idle"}`;
2729
+ }
2730
+
2731
+ // Session ID (short — first 8 chars). The span itself is the click
2732
+ // trigger for the session actions dropdown (download, etc.).
2733
+ const sibId = $("sib-id");
2734
+ if (sibId) {
2735
+ sibId.textContent = s.id ? s.id.slice(0, 8) : "";
2736
+ sibId.title = s.id || "";
2737
+ if (s.id) {
2738
+ sibId.dataset.sessionId = s.id;
2739
+ } else {
2740
+ delete sibId.dataset.sessionId;
2741
+ }
2742
+ }
2743
+
2744
+ // Working dir — show full path
2745
+ const sibDir = $("sib-dir");
2746
+ if (sibDir && s.working_dir) {
2747
+ sibDir.textContent = s.working_dir;
2748
+ sibDir.title = `${s.working_dir} (${I18n.t("sib.dir.tooltip")})`;
2749
+ sibDir.dataset.workingDir = s.working_dir;
2750
+ sibDir.dataset.sessionId = s.id;
2751
+ }
2752
+
2753
+ // Permission mode — hide element and its separator if empty
2754
+ const sibMode = $("sib-mode");
2755
+ const sibSepAfterMode = document.querySelector(".sib-sep-after-mode");
2756
+ if (sibMode) {
2757
+ sibMode.textContent = s.permission_mode || "";
2758
+ sibMode.style.display = s.permission_mode ? "" : "none";
2759
+ }
2760
+ if (sibSepAfterMode) {
2761
+ sibSepAfterMode.style.display = s.permission_mode ? "" : "none";
2762
+ }
2763
+
2764
+ // Model — hide wrap entirely if empty
2765
+ const sibModelWrap = $("sib-model-wrap");
2766
+ const sibModel = $("sib-model");
2767
+ if (sibModel) {
2768
+ sibModel.textContent = s.model || "";
2769
+ // Store current session ID on the model element for later use
2770
+ sibModel.dataset.sessionId = s.id;
2771
+ if (s.model_id) {
2772
+ sibModel.dataset.modelId = s.model_id;
2773
+ } else {
2774
+ delete sibModel.dataset.modelId;
2775
+ }
2776
+ }
2777
+ if (sibModelWrap) sibModelWrap.style.display = s.model ? "" : "none";
2778
+
2779
+ const sibReasoning = $("sib-reasoning");
2780
+ const sibReasoningWrap = $("sib-reasoning-wrap");
2781
+ const sibSepAfterReasoning = document.querySelector(".sib-sep-after-reasoning");
2782
+ if (sibReasoning) {
2783
+ const eff = (s.reasoning_effort || "off").toLowerCase();
2784
+ sibReasoning.textContent = I18n.t(`sib.reasoning.${eff}`);
2785
+ sibReasoning.dataset.sessionId = s.id;
2786
+ sibReasoning.dataset.reasoningEffort = eff;
2787
+ }
2788
+ if (sibReasoningWrap) sibReasoningWrap.style.display = "";
2789
+ if (sibSepAfterReasoning) sibSepAfterReasoning.style.display = "";
2790
+
2791
+ // Latency signal — read from s.latest_latency (populated by:
2792
+ // - HTTP /api/sessions → session_registry#list (from agent.latest_latency)
2793
+ // - WS session_update events patched by app.js
2794
+ // Hidden entirely when no latency recorded yet (fresh session, or old
2795
+ // pre-feature sessions that have never made an LLM call this run).
2796
+ this._renderSignal(s.latest_latency);
2797
+
2798
+ // Tasks
2799
+ const sibTasks = $("sib-tasks");
2800
+ if (sibTasks) sibTasks.textContent = I18n.t("sessions.metaTasks", { n: s.total_tasks || 0 });
2801
+
2802
+ const bar = $("session-info-bar");
2803
+ if (bar) bar.style.display = "flex";
2804
+ },
2805
+
2806
+ /** Render the 4-bar latency signal next to the model name in the status bar.
2807
+ *
2808
+ * @param {Object|null} lat latency metrics from agent.latest_latency
2809
+ * shape: { ttft_ms, duration_ms, output_tokens, tps, model, streaming }
2810
+ *
2811
+ * Visibility: hidden whenever lat is falsy (no measurement yet). Never
2812
+ * renders a "loading" state — we would rather show nothing than a stale or
2813
+ * misleading number.
2814
+ *
2815
+ * Signal thresholds (TTFT):
2816
+ * Note: this is measured over the WHOLE non-streaming response (we
2817
+ * don't have a real TTFT yet — the server returns one completed body),
2818
+ * so for a large generation — "write me a 2000-line snake game" — the
2819
+ * number naturally balloons. Thresholds below are tuned to that reality:
2820
+ * 60s is considered NORMAL, 120s is slow, beyond that we flag bad.
2821
+ *
2822
+ * ≤ 2000 ms → 4 bars, green, "⚡" fast
2823
+ * ≤ 60000 ms → 3 bars, green, normal
2824
+ * ≤ 120000 ms → 2 bars, amber, slow
2825
+ * > 120000 ms → 1 bar, red, very slow
2826
+ *
2827
+ * Hover tooltip: built from the latency hash — full breakdown for power
2828
+ * users; the compact inline text is just "1.2s" style for scannability.
2829
+ */
2830
+ _renderSignal(lat) {
2831
+ const wrap = $("sib-signal-wrap");
2832
+ const sep = document.querySelector(".sib-sep-after-signal");
2833
+ const el = $("sib-signal");
2834
+ if (!wrap || !el) return;
2835
+
2836
+ if (!lat || !lat.ttft_ms) {
2837
+ wrap.style.display = "none";
2838
+ if (sep) sep.style.display = "none";
2839
+ return;
2840
+ }
2841
+
2842
+ const ttft = Number(lat.ttft_ms) || 0;
2843
+ let bars, level;
2844
+ if (ttft <= 2000) { bars = 4; level = "ok"; }
2845
+ else if (ttft <= 60000) { bars = 3; level = "ok"; }
2846
+ else if (ttft <= 120000) { bars = 2; level = "warn"; }
2847
+ else { bars = 1; level = "bad"; }
2848
+
2849
+ // Paint bars: active ones get .on, others stay dim
2850
+ el.querySelectorAll(".sig-bars i").forEach((bar, i) => {
2851
+ bar.classList.toggle("on", i < bars);
2852
+ });
2853
+ el.className = `sib-signal-clickable sib-signal-${level}`;
2854
+
2855
+ // Inline text: just the TTFT in human-friendly units
2856
+ const ttftStr = ttft >= 1000 ? (ttft / 1000).toFixed(1) + "s" : ttft + "ms";
2857
+ const text = el.querySelector(".sig-text");
2858
+ if (text) text.textContent = ttftStr;
2859
+
2860
+ // Tooltip: full metrics breakdown
2861
+ const parts = [`TTFT ${ttftStr}`];
2862
+ if (lat.duration_ms && lat.duration_ms !== ttft) {
2863
+ const durStr = lat.duration_ms >= 1000
2864
+ ? (lat.duration_ms / 1000).toFixed(1) + "s"
2865
+ : lat.duration_ms + "ms";
2866
+ parts.push(`total ${durStr}`);
2867
+ }
2868
+ if (lat.tps) parts.push(`${lat.tps} tok/s`);
2869
+ if (lat.output_tokens) parts.push(`${lat.output_tokens} tokens`);
2870
+ if (lat.model) parts.push(`@ ${lat.model}`);
2871
+ el.title = "Last LLM call — " + parts.join(" · ");
2872
+
2873
+ wrap.style.display = "";
2874
+ if (sep) sep.style.display = "";
2875
+
2876
+ // Mobile: bind tap-to-show popup once (flag prevents re-binding on every update)
2877
+ if (!el._signalTapBound) {
2878
+ el._signalTapBound = true;
2879
+ el.addEventListener("click", (e) => {
2880
+ if (window.innerWidth > 768) return; // desktop: native title tooltip is fine
2881
+ e.stopPropagation();
2882
+ // Remove any existing popup
2883
+ const existing = document.querySelector(".sib-signal-popup");
2884
+ if (existing) { existing.remove(); return; }
2885
+
2886
+ const popup = document.createElement("div");
2887
+ popup.className = "sib-signal-popup";
2888
+ // Format tooltip text: replace " · " with newlines for readability
2889
+ popup.textContent = el.title.replace(/ · /g, "\n");
2890
+ document.body.appendChild(popup);
2891
+
2892
+ // Position: above the signal element, aligned to its left edge
2893
+ const rect = el.getBoundingClientRect();
2894
+ let left = rect.left;
2895
+ // Prevent overflow off right edge
2896
+ const popupWidth = 220;
2897
+ if (left + popupWidth > window.innerWidth - 8) {
2898
+ left = window.innerWidth - popupWidth - 8;
2899
+ }
2900
+ popup.style.left = left + "px";
2901
+ popup.style.visibility = "hidden";
2902
+ // Use rAF to get actual rendered height before positioning
2903
+ requestAnimationFrame(() => {
2904
+ const popupHeight = popup.getBoundingClientRect().height;
2905
+ popup.style.top = (rect.top - popupHeight - 6) + "px";
2906
+ popup.style.visibility = "";
2907
+ });
2908
+
2909
+ // Close on next tap anywhere
2910
+ setTimeout(() => {
2911
+ document.addEventListener("click", () => popup.remove(), { once: true });
2912
+ }, 0);
2913
+ });
2914
+ }
2915
+ },
2916
+
2917
+ // ── Message helpers ────────────────────────────────────────────────────
2918
+
2919
+ // Live tool group state (one active group per session at a time)
2920
+ _liveToolGroup: null, // current open .tool-group DOM element
2921
+ _liveLastToolItem: null, // last .tool-item added (for tool_result pairing)
2922
+
2923
+ // Append a tool_call as a compact item inside the live tool group.
2924
+ // Creates the group if it doesn't exist yet.
2925
+ appendToolCall(name, args, summary) {
2926
+ const messages = $("messages");
2927
+ if (!Sessions._liveToolGroup) {
2928
+ Sessions._liveToolGroup = _makeToolGroup();
2929
+ messages.appendChild(Sessions._liveToolGroup);
2930
+ }
2931
+ Sessions._liveLastToolItem = _addToolCallToGroup(Sessions._liveToolGroup, name, args, summary);
2932
+ // Flush a pending diff (emitted by show_tool_preview before this tool_call).
2933
+ if (_pendingDiff) {
2934
+ _applyDiffToItem(Sessions._liveLastToolItem, _pendingDiff.text, _pendingDiff.truncated);
2935
+ _pendingDiff = null;
2936
+ }
2937
+ _scrollToBottomIfNeeded(messages);
2938
+ },
2939
+
2940
+ // Update the last tool-item with a result status tick.
2941
+ // If uiPayload is provided, renders a rich structured card instead of plain text.
2942
+ appendToolResult(result, uiPayload) {
2943
+ if (Sessions._liveToolGroup && Sessions._liveLastToolItem) {
2944
+ _completeLastToolItem(Sessions._liveToolGroup, result, uiPayload);
2945
+ Sessions._liveLastToolItem = null;
2946
+ }
2947
+ },
2948
+
2949
+ // Append stdout lines to the currently running tool-item.
2950
+ // Shows the stdout area automatically on first content.
2951
+ appendToolStdout(lines) {
2952
+ // Resolve the target tool-item.
2953
+ // After a session switch, _liveLastToolItem is null because the messages pane
2954
+ // was wiped and re-rendered from history. In that case fall back to the last
2955
+ // .tool-item visible in the DOM — that is the in-flight tool the stdout belongs to.
2956
+ let toolItem = Sessions._liveLastToolItem;
2957
+ if (!toolItem) {
2958
+ const messages = $("messages");
2959
+ if (messages) {
2960
+ const items = messages.querySelectorAll(".tool-item");
2961
+ if (items.length > 0) toolItem = items[items.length - 1];
2962
+ }
2963
+ }
2964
+
2965
+ // If no tool-item exists yet, history is still loading via HTTP.
2966
+ // Buffer the lines and they will be flushed once _fetchHistory appends its fragment.
2967
+ if (!toolItem) {
2968
+ if (!_pendingStdoutLines) _pendingStdoutLines = [];
2969
+ _pendingStdoutLines.push(...lines);
2970
+ return;
2971
+ }
2972
+
2973
+ _applyStdoutToItem(toolItem, lines);
2974
+ },
2975
+
2976
+ // Append a diff block to the currently running tool-item.
2977
+ // Used by write/edit tools to show the unified diff preview.
2978
+ //
2979
+ // Important: the server emits "diff" during show_tool_preview, which runs
2980
+ // BEFORE show_tool_call. At that moment _liveLastToolItem is null (the
2981
+ // previous tool's appendToolResult cleared it). If we fell back to the
2982
+ // last DOM .tool-item we would write the diff into an unrelated card
2983
+ // (e.g. a preceding Read). Instead, buffer until the matching tool_call
2984
+ // creates the real owner, and let appendToolCall flush it.
2985
+ appendDiff(diffText, truncated) {
2986
+ if (Sessions._liveLastToolItem) {
2987
+ _applyDiffToItem(Sessions._liveLastToolItem, diffText, truncated);
2988
+ _scrollToBottomIfNeeded($("messages"));
2989
+ return;
2990
+ }
2991
+ _pendingDiff = { text: diffText, truncated: !!truncated };
2992
+ },
2993
+
2994
+ // Append a token usage line directly to the message list.
2995
+ // Server guarantees this event arrives AFTER assistant_message, so no buffering needed.
2996
+ // Format mirrors CLI:
2997
+ // [Tokens] | +409 | [*] | Input: 69,977 (cache: 69,566 read, 410 write) | Output: 101 | Total: 70,078 | Cost: $0.02392
2998
+ appendTokenUsage(ev, container) {
2999
+ const messages = container || $("messages");
3000
+ const el = document.createElement("div");
3001
+ el.className = "token-usage-line";
3002
+
3003
+ // Delta: +N or -N with colour coding
3004
+ const delta = ev.delta_tokens || 0;
3005
+ const deltaStr = delta >= 0 ? `+${delta.toLocaleString()}` : `${delta.toLocaleString()}`;
3006
+ let deltaCls = delta > 10000 ? "tu-delta-high" : delta > 5000 ? "tu-delta-mid" : "tu-delta-ok";
3007
+ if (delta < 0) deltaCls = "tu-delta-neg";
3008
+
3009
+ // Cache indicator [*] when cache was used
3010
+ const cacheRead = ev.cache_read || 0;
3011
+ const cacheWrite = ev.cache_write || 0;
3012
+ const cacheUsed = cacheRead > 0 || cacheWrite > 0;
3013
+
3014
+ // Input: base tokens + cache breakdown
3015
+ const promptTokens = ev.prompt_tokens || 0;
3016
+ let inputStr = promptTokens.toLocaleString();
3017
+ if (cacheUsed) {
3018
+ const parts = [];
3019
+ if (cacheRead > 0) parts.push(`${cacheRead.toLocaleString()} read`);
3020
+ if (cacheWrite > 0) parts.push(`${cacheWrite.toLocaleString()} write`);
3021
+ inputStr += ` (cache: ${parts.join(", ")})`;
3022
+ }
3023
+
3024
+ // Always-visible: label, delta, cache indicator
3025
+ // Detail fields (Input/Output/Total) are hidden until hover
3026
+ el.innerHTML =
3027
+ `<span class="tu-label">[Tokens]</span>` +
3028
+ `<span class="tu-sep">|</span>` +
3029
+ `<span class="tu-delta ${deltaCls}">${escapeHtml(deltaStr)}</span>` +
3030
+ (cacheUsed ? `<span class="tu-sep">|</span><span class="tu-cache">[*]</span>` : "") +
3031
+ `<span class="tu-detail">` +
3032
+ `<span class="tu-sep">|</span>` +
3033
+ `<span class="tu-field">Input: <b>${escapeHtml(inputStr)}</b></span>` +
3034
+ `<span class="tu-sep">|</span>` +
3035
+ `<span class="tu-field">Output: <b>${(ev.completion_tokens || 0).toLocaleString()}</b></span>` +
3036
+ `<span class="tu-sep">|</span>` +
3037
+ `<span class="tu-field">Total: <b>${(ev.total_tokens || 0).toLocaleString()}</b></span>` +
3038
+ `</span>`;
3039
+
3040
+ messages.appendChild(el);
3041
+ if (!container) _scrollToBottomIfNeeded(messages); // only auto-scroll for live events
3042
+ },
3043
+
3044
+ // Collapse the live tool group (call when AI starts responding or task ends).
3045
+ collapseToolGroup() {
3046
+ if (Sessions._liveToolGroup) {
3047
+ _collapseToolGroup(Sessions._liveToolGroup);
3048
+ Sessions._liveToolGroup = null;
3049
+ Sessions._liveLastToolItem = null;
3050
+ }
3051
+ // Drop any diff that never found its owner (e.g. tool was denied).
3052
+ _pendingDiff = null;
3053
+ },
3054
+
3055
+ appendMsg(type, html, { time } = {}) {
3056
+ // Starting a new assistant/user/info message: close any open tool group
3057
+ if (type !== "tool") Sessions.collapseToolGroup();
3058
+
3059
+ const messages = $("messages");
3060
+
3061
+ // For error messages: remove any existing error messages first to avoid duplicates
3062
+ if (type === "error") {
3063
+ messages.querySelectorAll(".msg-error").forEach(el => el.remove());
3064
+ }
3065
+
3066
+ const el = document.createElement("div");
3067
+ el.className = `msg msg-${type}`;
3068
+ // Assistant messages are rendered as Markdown (raw text → HTML via marked).
3069
+ // All other types receive pre-escaped HTML strings and are inserted directly.
3070
+ if (type === "assistant") {
3071
+ // Stash the raw markdown for the copy button. If the caller passed
3072
+ // pre-rendered HTML (e.g. feedback card), dataset.raw will still hold it;
3073
+ // the copy handler falls back to textContent in that case.
3074
+ el.dataset.raw = html || "";
3075
+ el.innerHTML = _renderMarkdown(html);
3076
+ _appendCopyButton(el);
3077
+ } else {
3078
+ el.innerHTML = html;
3079
+ }
3080
+ if (type === "user" && time) _appendMsgTime(el, time);
3081
+
3082
+ // For error messages, add a retry button
3083
+ if (type === "error") {
3084
+ const retryBtn = document.createElement("button");
3085
+ retryBtn.className = "retry-btn";
3086
+ retryBtn.textContent = I18n.t("chat.retry");
3087
+ retryBtn.onclick = () => {
3088
+ if (!_activeId) return;
3089
+ // Send "continue" or "继续" based on user's language preference
3090
+ const retryMessage = I18n.lang() === "zh" ? "继续" : "continue";
3091
+ WS.send({
3092
+ type: "message",
3093
+ session_id: _activeId,
3094
+ content: retryMessage
3095
+ });
3096
+ retryBtn.disabled = true; // Disable button after clicking (keep it visible)
3097
+ };
3098
+ el.appendChild(retryBtn);
3099
+ }
3100
+
3101
+ // Keep user messages before any progress indicator so the timeline reads
3102
+ // naturally: user message → Analyzing… → assistant reply.
3103
+ if (type === "user" && messages.lastElementChild && messages.lastElementChild.classList.contains("progress-msg")) {
3104
+ messages.insertBefore(el, messages.lastElementChild);
3105
+ } else {
3106
+ messages.appendChild(el);
3107
+ }
3108
+ // User messages: force scroll to bottom (user just sent a message)
3109
+ // Assistant/info: conditional scroll (preserve position if user is viewing history)
3110
+ if (type === "user") {
3111
+ messages.scrollTop = messages.scrollHeight;
3112
+ } else {
3113
+ _scrollToBottomIfNeeded(messages);
3114
+ }
3115
+ },
3116
+
3117
+ appendInfo(text, subline) {
3118
+ Sessions.collapseToolGroup();
3119
+ const messages = $("messages");
3120
+ const el = document.createElement("div");
3121
+ el.className = subline ? "msg msg-info msg-info-main" : "msg msg-info";
3122
+ el.textContent = text;
3123
+ messages.appendChild(el);
3124
+ if (subline) {
3125
+ const sub = document.createElement("div");
3126
+ sub.className = "msg msg-info-sub";
3127
+ sub.textContent = subline;
3128
+ messages.appendChild(sub);
3129
+ }
3130
+ _scrollToBottomIfNeeded(messages);
3131
+ },
3132
+
3133
+ /**
3134
+ * Show / hide the "{{n}} messages waiting" hint above the input bar.
3135
+ * pending === 0 hides the element; pending > 0 shows it with the count.
3136
+ * Called from the WS dispatcher when "user_message_queue_status" arrives.
3137
+ */
3138
+ setInputQueueHint(pending) {
3139
+ const hint = document.getElementById("input-queue-hint");
3140
+ if (!hint) return;
3141
+ const n = Number(pending) || 0;
3142
+ if (n <= 0) {
3143
+ hint.style.display = "none";
3144
+ hint.textContent = "";
3145
+ return;
3146
+ }
3147
+ const key = n === 1 ? "inbox.queue.one" : "inbox.queue.many";
3148
+ hint.textContent = I18n.t(key, { n: n });
3149
+ hint.style.display = "";
3150
+ },
3151
+
3152
+ /**
3153
+ * Render pending inbox messages as ghost bubbles with spinners.
3154
+ * Called from ws-dispatcher on "pending_user_messages" (WebSocket
3155
+ * subscribe replay). Each ghost is a .msg-pending div that the
3156
+ * history_user_message handler will replace when the agent drains it.
3157
+ */
3158
+ renderPendingMessages(messages) {
3159
+ const container = $("messages");
3160
+ if (!container) return;
3161
+ if (!Array.isArray(messages) || messages.length === 0) return;
3162
+
3163
+ messages.forEach(msg => {
3164
+ const el = document.createElement("div");
3165
+ el.className = "msg msg-user msg-pending";
3166
+
3167
+ let bubbleHtml = msg.content ? escapeHtml(msg.content) : "";
3168
+ const files = msg.files || [];
3169
+
3170
+ // Image thumbnails (files with data_url)
3171
+ const imageFiles = files.filter(f => f.data_url);
3172
+ if (imageFiles.length > 0) {
3173
+ const thumbs = imageFiles.map(f =>
3174
+ `<img src="${f.data_url}" alt="${escapeHtml(f.name || '')}" class="msg-image-thumb">`
3175
+ ).join("");
3176
+ bubbleHtml = thumbs + (bubbleHtml ? "<br>" + bubbleHtml : "");
3177
+ }
3178
+
3179
+ // File badges (files without data_url)
3180
+ const otherFiles = files.filter(f => !f.data_url);
3181
+ if (otherFiles.length > 0) {
3182
+ const badges = otherFiles.map(f => {
3183
+ const icon = _docTypeIcon(f.mime_type, f.name);
3184
+ const ext = (f.name || "").split(".").pop() || "file";
3185
+ const displayExt = ext.toUpperCase();
3186
+ return `<span class="msg-pdf-badge">` +
3187
+ `<span class="msg-pdf-badge-icon">${icon}</span>` +
3188
+ `<span class="msg-pdf-badge-info">` +
3189
+ `<span class="msg-pdf-badge-name">${escapeHtml(f.name)}</span>` +
3190
+ `<span class="msg-pdf-badge-type">${escapeHtml(displayExt)}</span>` +
3191
+ `</span>` +
3192
+ `</span>`;
3193
+ }).join(" ");
3194
+ bubbleHtml = badges + (bubbleHtml ? "<br>" + bubbleHtml : "");
3195
+ }
3196
+
3197
+ const spinner = `<span class="msg-pending-spinner"></span>`;
3198
+ el.innerHTML = bubbleHtml + spinner;
3199
+ container.appendChild(el);
3200
+ });
3201
+
3202
+ container.scrollTop = container.scrollHeight;
3203
+ },
3204
+
3205
+ // Display a request_user_feedback UI card with optional clickable option buttons.
3206
+ // Called when the agent needs user input to continue.
3207
+ showFeedbackRequest(question, context, options) {
3208
+ Sessions.collapseToolGroup();
3209
+ const messages = $("messages");
3210
+ const hasOptions = options && Array.isArray(options) && options.length > 0;
3211
+
3212
+ // Normalize bullet symbols to markdown list format so marked renders them as <ul>
3213
+ const normalizeBullets = (text) => text ? text.replace(/^[•·‣▸▪\-–]\s*/gm, '- ') : text;
3214
+
3215
+ // No options → plain assistant bubble (card UI adds no value without choices)
3216
+ if (!hasOptions) {
3217
+ const parts = [context && context.trim(), question].filter(Boolean);
3218
+ const text = parts.map(normalizeBullets).join("\n\n");
3219
+ // Pass raw markdown; appendMsg renders it via _renderMarkdown and
3220
+ // also stashes it on dataset.raw for the copy button.
3221
+ Sessions.appendMsg("assistant", text);
3222
+ return;
3223
+ }
3224
+
3225
+ // Has options → render interactive card
3226
+ const card = document.createElement("div");
3227
+ card.className = "feedback-card";
3228
+
3229
+ let cardHtml = "";
3230
+ if (context && context.trim()) {
3231
+ cardHtml += `<div class="feedback-context msg-assistant">${_renderMarkdown(context)}</div>`;
3232
+ }
3233
+ cardHtml += `<div class="feedback-question msg-assistant">${_renderMarkdown(question)}</div>`;
3234
+ cardHtml += `<div class="feedback-options">`;
3235
+ options.forEach((opt, idx) => {
3236
+ cardHtml += `<button class="feedback-option-btn" data-option-index="${idx}">${escapeHtml(opt)}</button>`;
3237
+ });
3238
+ cardHtml += `</div>`;
3239
+ cardHtml += `<div class="feedback-hint">${I18n.t("chat.feedback_hint")}</div>`;
3240
+
3241
+ card.innerHTML = cardHtml;
3242
+
3243
+ // Click → disable card + submit immediately via _sendMessage()
3244
+ card.querySelectorAll(".feedback-option-btn").forEach(btn => {
3245
+ btn.onclick = () => {
3246
+ card.querySelectorAll(".feedback-option-btn").forEach(b => b.disabled = true);
3247
+ card.classList.add("feedback-card--submitted");
3248
+ const input = $("user-input");
3249
+ if (input) input.value = btn.textContent.trim();
3250
+ _sendMessage();
3251
+ };
3252
+ });
3253
+
3254
+ messages.appendChild(card);
3255
+ _scrollToBottomIfNeeded(messages);
3256
+ },
3257
+
3258
+ // ── Per-session progress state ──────────────────────────────────────
3259
+ //
3260
+ // Each session maintains its own progress state so switching sessions
3261
+ // and switching back does NOT reset the elapsed timer.
3262
+ //
3263
+ // State map: { [sessionId]: { el, interval, startTime, type, displayText } }
3264
+ // el — DOM element (.progress-msg) currently in #messages (or null if detached)
3265
+ // interval — setInterval id for the ticking counter (or null if detached)
3266
+ // startTime — Date.now()-compatible ms timestamp when progress began
3267
+ // type — "thinking" | "retrying" | "idle_compress" | …
3268
+ // displayText — the label shown before the "(Ns)" suffix
3269
+
3270
+ _sessionProgress: {},
3271
+
3272
+ _getProgressState(id) {
3273
+ if (!id) return null;
3274
+ if (!Sessions._sessionProgress[id]) {
3275
+ Sessions._sessionProgress[id] = { el: null, interval: null, startTime: null, type: null, displayText: null, metadata: null, lastChunkAt: null };
3276
+ }
3277
+ return Sessions._sessionProgress[id];
3278
+ },
3279
+
3280
+ // Compact a token count: 1234 → "1.2k", 12345 → "12k", 1234567 → "1.2M".
3281
+ _compactTokenCount(n) {
3282
+ if (n < 1000) return String(n);
3283
+ if (n < 1_000_000) {
3284
+ const k = n / 1000;
3285
+ return k >= 10 ? `${Math.floor(k)}k` : `${k.toFixed(1)}k`;
3286
+ }
3287
+ const m = n / 1_000_000;
3288
+ return m >= 10 ? `${Math.floor(m)}M` : `${m.toFixed(1)}M`;
3289
+ },
3290
+
3291
+ // Render LLM streaming output token count as "↓ 234 tokens".
3292
+ // Returns null when no positive output_tokens — matches CLI behaviour
3293
+ // (input is hidden mid-stream because most providers only ship
3294
+ // input_tokens with the final usage frame).
3295
+ _formatTokenSuffix(metadata) {
3296
+ if (!metadata) return null;
3297
+ const output = metadata.output_tokens;
3298
+ if (output == null || output <= 0) return null;
3299
+ return `↓ ${Sessions._compactTokenCount(output)} tokens`;
3300
+ },
3301
+
3302
+ // Compose the live progress line:
3303
+ // "<text>… (Ns · ↓N tokens · reasoning…)"
3304
+ // The "reasoning" tail surfaces inter-chunk silence so users see
3305
+ // the model is in extended thinking, not stuck. Threshold mirrors
3306
+ // ProgressHandle::IDLE_HINT_THRESHOLD_SECONDS. Animated dots avoid
3307
+ // duplicating the elapsed counter.
3308
+ _composeProgressLine(displayText, startTime, metadata, lastChunkAt) {
3309
+ const now = Date.now();
3310
+ const elapsed = startTime ? Math.floor((now - startTime) / 1000) : 0;
3311
+ const tokenStr = Sessions._formatTokenSuffix(metadata);
3312
+ const parts = [];
3313
+ if (elapsed > 0) parts.push(`${elapsed}s`);
3314
+ if (tokenStr) parts.push(tokenStr);
3315
+ if (tokenStr && lastChunkAt) {
3316
+ const idle = Math.floor((now - lastChunkAt) / 1000);
3317
+ if (idle >= 2) {
3318
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
3319
+ const frame = frames[Math.floor(now / 250) % frames.length];
3320
+ parts.push(`reasoning ${frame} `);
3321
+ }
3322
+ }
3323
+ if (parts.length === 0) return displayText;
3324
+ return `${displayText}… (${parts.join(" · ")})`;
3325
+ },
3326
+
3327
+ // Build the display label for a given progress type (pure — no side effects).
3328
+ _buildDisplayText(text, progress_type, metadata) {
3329
+ if (progress_type === "thinking") {
3330
+ return text || getRandomThinkingVerb();
3331
+ } else if (progress_type === "retrying") {
3332
+ const { attempt, total } = metadata || {};
3333
+ if (text && attempt && total) {
3334
+ return `${I18n.t("chat.retrying")}: ${text} (${attempt}/${total})`;
3335
+ } else if (attempt && total) {
3336
+ return `${I18n.t("chat.retrying")} (${attempt}/${total})`;
3337
+ }
3338
+ return text || I18n.t("chat.retrying");
3339
+ } else if (progress_type === "idle_compress") {
3340
+ return text || "Compressing...";
3341
+ }
3342
+ return text || I18n.t("chat.thinking");
3343
+ },
3344
+
3345
+ // Attach the progress UI (DOM element + setInterval) for a given session.
3346
+ // Requires the session's progress state to already have startTime + displayText set.
3347
+ _attachProgressUI(id) {
3348
+ const state = Sessions._getProgressState(id);
3349
+ if (!state || !state.startTime) return;
3350
+
3351
+ // Only attach if this session is currently visible
3352
+ if (id !== _activeId) return;
3353
+
3354
+ const messages = $("messages");
3355
+ if (!messages) return;
3356
+
3357
+ // Clean up any previous DOM/timer for this session (idempotent)
3358
+ Sessions._detachProgressUI(id);
3359
+
3360
+ const el = document.createElement("div");
3361
+ el.className = "progress-msg";
3362
+ el.textContent = Sessions._composeProgressLine(state.displayText, state.startTime, state.metadata, state.lastChunkAt);
3363
+ messages.appendChild(el);
3364
+ state.el = el;
3365
+ _scrollToBottomIfNeeded(messages);
3366
+
3367
+ // Tick at 250ms so streaming token counts feel live. The elapsed
3368
+ // counter only displays whole seconds, but token numbers update at
3369
+ // sub-second cadence on fast streams.
3370
+ state.interval = setInterval(() => {
3371
+ if (state.el) {
3372
+ state.el.textContent = Sessions._composeProgressLine(state.displayText, state.startTime, state.metadata, state.lastChunkAt);
3373
+ }
3374
+ }, 250);
3375
+ },
3376
+
3377
+ // Detach only the DOM element and timer for a session, preserving logical state
3378
+ // (startTime, type, displayText). Called when switching away from a session.
3379
+ _detachProgressUI(id) {
3380
+ const state = Sessions._sessionProgress[id];
3381
+ if (!state) return;
3382
+ if (state.interval) {
3383
+ clearInterval(state.interval);
3384
+ state.interval = null;
3385
+ }
3386
+ if (state.el) {
3387
+ state.el.remove();
3388
+ state.el = null;
3389
+ }
3390
+ },
3391
+
3392
+ showProgress(text, progress_type = "thinking", metadata = {}, startedAt = null) {
3393
+ const sid = _activeId;
3394
+ if (!sid) return;
3395
+
3396
+ const newStartTime = startedAt || Date.now();
3397
+
3398
+ const existing = Sessions._sessionProgress[sid];
3399
+ if (existing && existing.el) {
3400
+ // Same start time → same progress phase. Most common case during LLM
3401
+ // streaming (token counts arriving every ~250ms with message: null).
3402
+ // Keep the existing displayText so the random "thinking" verb does
3403
+ // NOT churn on every chunk. Just refresh metadata; the interval tick
3404
+ // will repaint with fresh tokens.
3405
+ if (existing.startTime === newStartTime) {
3406
+ existing.type = progress_type;
3407
+ existing.metadata = metadata || {};
3408
+ existing.lastChunkAt = Date.now();
3409
+ // Only adopt a new displayText if the server actually sent one.
3410
+ if (text) existing.displayText = Sessions._buildDisplayText(text, progress_type, metadata);
3411
+ return;
3412
+ }
3413
+ // Different start time → new progress phase. Update state in-place
3414
+ // and reset the timer base, but reuse the existing DOM element so
3415
+ // the user never sees the indicator disappear/reappear.
3416
+ const newDisplayText = Sessions._buildDisplayText(text, progress_type, metadata);
3417
+ existing.type = progress_type;
3418
+ existing.startTime = newStartTime;
3419
+ existing.displayText = newDisplayText;
3420
+ existing.metadata = metadata || {};
3421
+ existing.lastChunkAt = newStartTime;
3422
+ existing.el.textContent = Sessions._composeProgressLine(newDisplayText, newStartTime, metadata, existing.lastChunkAt);
3423
+ if (existing.interval) clearInterval(existing.interval);
3424
+ existing.interval = setInterval(() => {
3425
+ if (existing.el) {
3426
+ existing.el.textContent = Sessions._composeProgressLine(existing.displayText, existing.startTime, existing.metadata, existing.lastChunkAt);
3427
+ }
3428
+ }, 250);
3429
+ _scrollToBottomIfNeeded($("messages"));
3430
+ return;
3431
+ }
3432
+
3433
+ // No existing visible progress — create from scratch.
3434
+ Sessions.clearProgress(sid);
3435
+
3436
+ const state = Sessions._getProgressState(sid);
3437
+ state.type = progress_type;
3438
+ state.startTime = newStartTime;
3439
+ state.displayText = Sessions._buildDisplayText(text, progress_type, metadata);
3440
+ state.metadata = metadata || {};
3441
+ state.lastChunkAt = newStartTime;
3442
+
3443
+ Sessions._attachProgressUI(sid);
3444
+ },
3445
+
3446
+ clearProgress(sessionIdOrMessage = null, finalMessage = null) {
3447
+ // Backward-compatible overload resolution:
3448
+ // clearProgress() — clear active session
3449
+ // clearProgress("some message") — clear active session + final message
3450
+ // clearProgress(sessionId) — clear specific session (id looks like UUID)
3451
+ // clearProgress(sessionId, "message") — clear specific session + final message
3452
+ let sid;
3453
+ if (sessionIdOrMessage && typeof sessionIdOrMessage === "string") {
3454
+ // Heuristic: session IDs are UUIDs (contain hyphens or are 32+ hex chars).
3455
+ // Anything else is treated as a finalMessage for the active session.
3456
+ if (/^[0-9a-f-]{8,}$/i.test(sessionIdOrMessage)) {
3457
+ sid = sessionIdOrMessage;
3458
+ } else {
3459
+ finalMessage = sessionIdOrMessage;
3460
+ sid = _activeId;
3461
+ }
3462
+ } else {
3463
+ sid = _activeId;
3464
+ }
3465
+ if (!sid) return;
3466
+
3467
+ const state = Sessions._sessionProgress[sid];
3468
+ if (!state) return;
3469
+
3470
+ // Detach DOM + timer
3471
+ Sessions._detachProgressUI(sid);
3472
+
3473
+ // Show final message if provided (for idle_compress, etc.)
3474
+ if (finalMessage && state.type && state.type !== "thinking") {
3475
+ Sessions.appendInfo(`· ${finalMessage}`);
3476
+ }
3477
+
3478
+ // Clear logical state
3479
+ state.startTime = null;
3480
+ state.type = null;
3481
+ state.displayText = null;
3482
+ state.metadata = null;
3483
+ state.lastChunkAt = null;
3484
+ },
3485
+
3486
+ // Delete all progress state for a session (used when session is removed).
3487
+ _deleteProgressState(id) {
3488
+ Sessions._detachProgressUI(id);
3489
+ delete Sessions._sessionProgress[id];
3490
+ },
3491
+
3492
+ // Clear progress for ALL sessions (used on WS disconnect).
3493
+ clearAllProgress() {
3494
+ for (const id of Object.keys(Sessions._sessionProgress)) {
3495
+ Sessions._detachProgressUI(id);
3496
+ }
3497
+ // Wipe the entire map — all state is stale after disconnect
3498
+ Sessions._sessionProgress = {};
3499
+ },
3500
+
3501
+ // ── Create ─────────────────────────────────────────────────────────────
3502
+
3503
+ /** Create a new session and navigate to it. */
3504
+ async create(agentProfile = "general") {
3505
+ const maxN = _sessions.reduce((max, s) => {
3506
+ const m = s.name.match(/^Session (\d+)$/);
3507
+ return m ? Math.max(max, parseInt(m[1], 10)) : max;
3508
+ }, 0);
3509
+ const name = "Session " + (maxN + 1);
3510
+
3511
+ const res = await fetch("/api/sessions", {
3512
+ method: "POST",
3513
+ headers: { "Content-Type": "application/json" },
3514
+ body: JSON.stringify({ name, agent_profile: agentProfile, source: "manual" })
3515
+ });
3516
+ const data = await res.json();
3517
+ if (!res.ok) { alert(I18n.t("sessions.createError") + (data.error || "unknown")); return; }
3518
+
3519
+ const session = data.session;
3520
+ if (!session) return;
3521
+
3522
+ Sessions.add(session);
3523
+
3524
+ Sessions.renderList();
3525
+ Sessions.select(session.id);
3526
+ },
3527
+
3528
+ // ── History loading ────────────────────────────────────────────────────
3529
+
3530
+ /** Load the most recent page of history for a session (called on first visit). */
3531
+ loadHistory(id) {
3532
+ return _fetchHistory(id, null, false);
3533
+ },
3534
+
3535
+ /** Load older history (called when user scrolls to top). */
3536
+ loadMoreHistory(id) {
3537
+ const state = _historyState[id];
3538
+ if (!state || !state.hasMore) return;
3539
+ return _fetchHistory(id, state.oldestCreatedAt, true);
3540
+ },
3541
+
3542
+ /** Check if there is more history to load for a session. */
3543
+ hasMoreHistory(id) {
3544
+ return _historyState[id]?.hasMore ?? true;
3545
+ },
3546
+
3547
+ /** Register a live-WS-rendered round's created_at so history replay skips it. */
3548
+ markRendered(id, createdAt) {
3549
+ if (!createdAt) return;
3550
+ const dedup = _renderedCreatedAt[id] || (_renderedCreatedAt[id] = new Set());
3551
+ dedup.add(createdAt);
3552
+ },
3553
+
3554
+ /** Mark a session as having a pending task that should start after subscribe. */
3555
+ setPendingRunTask(sessionId) {
3556
+ _pendingRunTaskId = sessionId;
3557
+ },
3558
+
3559
+ /** Consume and return the pending run-task session id (clears it). */
3560
+ takePendingRunTask() {
3561
+ const id = _pendingRunTaskId;
3562
+ _pendingRunTaskId = null;
3563
+ return id;
3564
+ },
3565
+
3566
+ /** Register a slash-command message to send after subscribe is confirmed. */
3567
+ setPendingMessage(sessionId, content) {
3568
+ _pendingMessage = { session_id: sessionId, content };
3569
+ },
3570
+
3571
+ /** Consume and return the pending message (clears it). */
3572
+ takePendingMessage() {
3573
+ const msg = _pendingMessage;
3574
+ _pendingMessage = null;
3575
+ return msg;
3576
+ },
3577
+
3578
+ // ── New Session Modal ──────────────────────────────────────────────────
3579
+
3580
+ /** Open the New Session modal with configuration options. */
3581
+ openNewSessionModal() {
3582
+ const modal = $("new-session-modal");
3583
+ if (!modal) return;
3584
+
3585
+ // Populate model dropdown from configured models
3586
+ _populateModelDropdown();
3587
+
3588
+ // Set default working directory
3589
+ const dirInput = $("new-session-directory");
3590
+ if (dirInput && !dirInput.value) {
3591
+ dirInput.value = "~/octo_workspace";
3592
+ }
3593
+
3594
+ // Setup agent type change listener to show/hide init project checkbox
3595
+ const agentSelect = $("new-session-agent");
3596
+ const initProjectField = $("new-session-init-project-field");
3597
+
3598
+ if (agentSelect && initProjectField) {
3599
+ // Set initial state based on current selection
3600
+ initProjectField.style.display = agentSelect.value === "coding" ? "block" : "none";
3601
+
3602
+ // Listen for changes
3603
+ agentSelect.addEventListener("change", function() {
3604
+ initProjectField.style.display = this.value === "coding" ? "block" : "none";
3605
+ });
3606
+ }
3607
+
3608
+ // Show modal
3609
+ modal.style.display = "flex";
3610
+ },
3611
+
3612
+ /** Close the New Session modal. */
3613
+ closeNewSessionModal() {
3614
+ const modal = $("new-session-modal");
3615
+ if (modal) modal.style.display = "none";
3616
+ },
3617
+
3618
+ /** Create session from modal form data. */
3619
+ async createFromModal() {
3620
+ const agentSelect = $("new-session-agent");
3621
+ const nameInput = $("new-session-name");
3622
+ const modelSelect = $("new-session-model");
3623
+ const dirInput = $("new-session-directory");
3624
+ const initCheckbox = $("new-session-init-project");
3625
+ const createBtn = $("new-session-create");
3626
+
3627
+ const agentProfile = agentSelect ? agentSelect.value : "general";
3628
+ const customName = nameInput ? nameInput.value.trim() : "";
3629
+ // The dropdown's value is the model's stable runtime id (see
3630
+ // _populateModelDropdown). Using the id — not the model *name* — lets
3631
+ // the backend switch to the right full model entry (api_key, base_url,
3632
+ // anthropic_format) instead of mutating the current default entry's
3633
+ // name in place, which caused "unknown model <name>" errors when the
3634
+ // chosen model belonged to a different provider than the default.
3635
+ const selectedModelId = modelSelect ? modelSelect.value : "";
3636
+ const workingDir = dirInput ? dirInput.value.trim() : "";
3637
+ const initProject = initCheckbox ? initCheckbox.checked : false;
3638
+
3639
+ // Auto-generate name if not provided
3640
+ let name = customName;
3641
+ if (!name) {
3642
+ const maxN = _sessions.reduce((max, s) => {
3643
+ const m = s.name.match(/^Session (\d+)$/);
3644
+ return m ? Math.max(max, parseInt(m[1], 10)) : max;
3645
+ }, 0);
3646
+ name = "Session " + (maxN + 1);
3647
+ }
3648
+
3649
+ if (createBtn) createBtn.disabled = true;
3650
+
3651
+ try {
3652
+ const payload = {
3653
+ name,
3654
+ agent_profile: agentProfile,
3655
+ source: "manual"
3656
+ };
3657
+
3658
+ // Add optional fields
3659
+ if (workingDir) payload.working_dir = workingDir;
3660
+ if (selectedModelId) payload.model_id = selectedModelId;
3661
+
3662
+ const res = await fetch("/api/sessions", {
3663
+ method: "POST",
3664
+ headers: { "Content-Type": "application/json" },
3665
+ body: JSON.stringify(payload)
3666
+ });
3667
+ const data = await res.json();
3668
+
3669
+ if (!res.ok) {
3670
+ const msg = data.error || "unknown error";
3671
+ const friendly = res.status === 409
3672
+ ? I18n.t("sessions.dirNotEmpty")
3673
+ : I18n.t("sessions.createError") + msg;
3674
+ alert(friendly);
3675
+ if (createBtn) createBtn.disabled = false;
3676
+ return;
3677
+ }
3678
+
3679
+ const session = data.session;
3680
+ if (!session) return;
3681
+
3682
+ // Close modal and reset form
3683
+ Sessions.closeNewSessionModal();
3684
+ if (nameInput) nameInput.value = "";
3685
+ if (dirInput) dirInput.value = "";
3686
+ if (initCheckbox) initCheckbox.checked = false;
3687
+
3688
+ // Add to list and select
3689
+ Sessions.add(session);
3690
+ Sessions.renderList();
3691
+ Sessions.select(session.id);
3692
+
3693
+ // If init project was checked, send /new command
3694
+ if (initProject) {
3695
+ Sessions.setPendingMessage(session.id, "/new");
3696
+ }
3697
+ } catch (e) {
3698
+ alert(I18n.t("sessions.createError") + e.message);
3699
+ } finally {
3700
+ if (createBtn) createBtn.disabled = false;
3701
+ }
3702
+ },
3703
+ };
3704
+
3705
+ // ── Helper: Populate model dropdown ────────────────────────────────────────
3706
+
3707
+ async function _populateModelDropdown() {
3708
+ const modelSelect = $("new-session-model");
3709
+ if (!modelSelect) return;
3710
+
3711
+ try {
3712
+ const res = await fetch("/api/config");
3713
+ const data = await res.json();
3714
+ const models = data.models || [];
3715
+
3716
+ modelSelect.innerHTML = "";
3717
+
3718
+ if (models.length === 0) {
3719
+ const opt = document.createElement("option");
3720
+ opt.value = "";
3721
+ opt.textContent = "No models configured";
3722
+ modelSelect.appendChild(opt);
3723
+ return;
3724
+ }
3725
+
3726
+ // Add each configured model (CLI-style format).
3727
+ // The option's value is the model's stable runtime id — not the bare
3728
+ // model name — so the backend can switch to the exact model entry
3729
+ // (with matching api_key / base_url / anthropic_format) when the user
3730
+ // chooses a non-default model. See createFromModal + build_session.
3731
+ models.forEach(m => {
3732
+ const opt = document.createElement("option");
3733
+ opt.value = m.id || "";
3734
+
3735
+ // Format: [default] abs-claude-sonnet-4-5 (octo...8825)
3736
+ const typeBadge = m.type === "default" ? "[default] " : "";
3737
+ const label = `${typeBadge}${m.model} (${m.api_key_masked})`;
3738
+ opt.textContent = label;
3739
+
3740
+ // Pre-select default model
3741
+ if (m.type === "default") opt.selected = true;
3742
+ modelSelect.appendChild(opt);
3743
+ });
3744
+ } catch (e) {
3745
+ console.error("Failed to load models:", e);
3746
+ modelSelect.innerHTML = '<option value="">Error loading models</option>';
3747
+ }
3748
+ }
3749
+
3750
+ return Sessions;
3751
+ })();
3752
+
3753
+ // ─────────────────────────────────────────────────────────────────────────
3754
+ // Session Info Bar interactions (model switcher + working-directory switcher
3755
+ // + session-actions dropdown). Two self-contained IIFEs that bind themselves
3756
+ // on document (event delegation), so no explicit init() call is needed —
3757
+ // they just work once this file is loaded.
3758
+ //
3759
+ // Moved here from app.js verbatim; kept as IIFEs to preserve private state
3760
+ // (benchmark cache, open/closed flags) without polluting the Sessions closure.
3761
+ // ─────────────────────────────────────────────────────────────────────────
3762
+
3763
+ // ── Session Info Bar Model Switcher ───────────────────────────────────────
3764
+ (function() {
3765
+ let _isOpen = false;
3766
+ // Cache of the most recent benchmark results, keyed by model_id. Kept at
3767
+ // closure scope so the numbers survive closing & reopening the dropdown —
3768
+ // the user shouldn't have to re-run the test just to peek at results. We
3769
+ // intentionally do NOT persist this to disk: latency is a point-in-time
3770
+ // measurement, and yesterday's numbers are misleading.
3771
+ let _benchCache = {}; // { [model_id]: { ttft_ms, ok, error, ts } }
3772
+ let _benchInFlight = false; // prevent double-click spam
3773
+
3774
+ // Toggle model dropdown when clicking on model name
3775
+ document.addEventListener("click", async (e) => {
3776
+ const modelEl = e.target.closest("#sib-model");
3777
+ if (modelEl) {
3778
+ e.stopPropagation();
3779
+ const dropdown = $("sib-model-dropdown");
3780
+ if (!dropdown) return;
3781
+
3782
+ if (_isOpen) {
3783
+ dropdown.style.display = "none";
3784
+ _isOpen = false;
3785
+ } else {
3786
+ await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.dataset.modelId || null);
3787
+
3788
+ // Calculate position relative to the model element (fixed positioning)
3789
+ const rect = modelEl.getBoundingClientRect();
3790
+ dropdown.style.left = `${rect.left + rect.width / 2}px`;
3791
+ dropdown.style.top = `${rect.top - 6}px`; // 6px above the element
3792
+ dropdown.style.transform = "translate(-50%, -100%)"; // Center horizontally, move up by its own height
3793
+
3794
+ dropdown.style.display = "block";
3795
+ _isOpen = true;
3796
+ }
3797
+ return;
3798
+ }
3799
+
3800
+ // Close dropdown when clicking outside
3801
+ if (_isOpen && !e.target.closest(".sib-model-dropdown")) {
3802
+ const dropdown = $("sib-model-dropdown");
3803
+ if (dropdown) dropdown.style.display = "none";
3804
+ _isOpen = false;
3805
+ }
3806
+ });
3807
+
3808
+ // Populate dropdown with available models
3809
+ async function _populateModelDropdown(sessionId, currentModelId) {
3810
+ const dropdown = $("sib-model-dropdown");
3811
+ if (!dropdown) return;
3812
+
3813
+ try {
3814
+ console.log("[Model Switcher] Fetching /api/config...");
3815
+ const res = await fetch("/api/config");
3816
+ const data = await res.json();
3817
+ console.log("[Model Switcher] Received data:", data);
3818
+ const models = data.models || [];
3819
+ console.log("[Model Switcher] Models count:", models.length);
3820
+
3821
+ if (models.length === 0) {
3822
+ dropdown.innerHTML = '<div style="padding:0.75rem;text-align:center;color:var(--color-text-secondary);font-size:0.6875rem;">No models configured</div>';
3823
+ return;
3824
+ }
3825
+
3826
+ dropdown.innerHTML = "";
3827
+
3828
+ // ── Benchmark floating button (top-right of dropdown) ──────────────
3829
+ // Tiny ⚡ button pinned to the dropdown's top-right corner. Runs one
3830
+ // concurrent request per model and back-fills each row's latency cell.
3831
+ // We deliberately avoid a full-width banner — it ate visual space that
3832
+ // the model list needs, and most users open the dropdown to SWITCH,
3833
+ // not to benchmark. The floating button is discoverable but unobtrusive.
3834
+ const bench = document.createElement("div");
3835
+ bench.className = "sib-model-bench";
3836
+ const btnLabel = (typeof I18n !== "undefined") ? I18n.t("sib.bench.btn") : "Benchmark";
3837
+ const btnTooltip = (typeof I18n !== "undefined") ? I18n.t("sib.bench.tooltip") : "Test response latency for every configured model";
3838
+ bench.innerHTML = `
3839
+ <button type="button" class="sib-bench-btn" title="${btnTooltip}">⚡ <span class="sib-bench-label">${btnLabel}</span></button>
3840
+ <span class="sib-bench-hint"></span>
3841
+ `;
3842
+ dropdown.appendChild(bench);
3843
+
3844
+ const benchBtn = bench.querySelector(".sib-bench-btn");
3845
+ const benchLabel = bench.querySelector(".sib-bench-label");
3846
+ const benchHint = bench.querySelector(".sib-bench-hint");
3847
+ benchBtn.addEventListener("click", (ev) => {
3848
+ ev.stopPropagation();
3849
+ _runBenchmark(sessionId, dropdown, benchBtn, benchLabel, benchHint);
3850
+ });
3851
+
3852
+ // ── Model rows ─────────────────────────────────────────────────────
3853
+ const _nameCounts = models.reduce((acc, m) => {
3854
+ acc[m.model] = (acc[m.model] || 0) + 1;
3855
+ return acc;
3856
+ }, {});
3857
+
3858
+ models.forEach(m => {
3859
+ console.log("[Model Switcher] Adding model:", m.model, "id:", m.id, "current:", currentModelId);
3860
+ const opt = document.createElement("div");
3861
+ opt.className = "sib-model-option";
3862
+ opt.dataset.modelId = m.id;
3863
+ if (m.id === currentModelId) opt.classList.add("current");
3864
+
3865
+ const left = document.createElement("span");
3866
+ left.className = "sib-model-name";
3867
+
3868
+ const nameLine = document.createElement("span");
3869
+ nameLine.className = "sib-model-name-main";
3870
+ nameLine.textContent = m.model;
3871
+ left.appendChild(nameLine);
3872
+
3873
+ if (_nameCounts[m.model] > 1) {
3874
+ left.classList.add("has-sub");
3875
+ const host = (() => {
3876
+ try { return new URL(m.base_url).host; } catch { return m.base_url || ""; }
3877
+ })();
3878
+ const subBits = [host, m.api_key_masked].filter(Boolean);
3879
+ if (subBits.length) {
3880
+ const subLine = document.createElement("span");
3881
+ subLine.className = "sib-model-name-sub";
3882
+ subLine.textContent = subBits.join(" · ");
3883
+ left.appendChild(subLine);
3884
+ opt.title = `${m.model} · ${subBits.join(" · ")}`;
3885
+ }
3886
+ }
3887
+
3888
+ opt.appendChild(left);
3889
+
3890
+ const right = document.createElement("span");
3891
+ right.className = "sib-model-right";
3892
+
3893
+ if (m.type === "default") {
3894
+ const badge = document.createElement("span");
3895
+ badge.className = `model-badge ${m.type}`;
3896
+ badge.textContent = m.type;
3897
+ right.appendChild(badge);
3898
+ }
3899
+
3900
+ // Latency cell — populated from _benchCache on open, updated live
3901
+ // when a benchmark run completes. Empty slot keeps row heights stable
3902
+ // so the list doesn't visually jump mid-benchmark.
3903
+ const lat = document.createElement("span");
3904
+ lat.className = "sib-model-latency";
3905
+ _fillLatencyCell(lat, _benchCache[m.id]);
3906
+ right.appendChild(lat);
3907
+
3908
+ opt.appendChild(right);
3909
+
3910
+ // Switch by id (stable across reorders/edits). Keep model name for UI update.
3911
+ opt.addEventListener("click", () => _switchModel(sessionId, m.id, m.model));
3912
+ dropdown.appendChild(opt);
3913
+ });
3914
+ console.log("[Model Switcher] Dropdown populated, children count:", dropdown.children.length);
3915
+ } catch (e) {
3916
+ console.error("Failed to load models:", e);
3917
+ dropdown.innerHTML = '<div style="padding:0.75rem;text-align:center;color:var(--color-error);font-size:0.6875rem;">Error loading models</div>';
3918
+ }
3919
+ }
3920
+
3921
+ // Render one latency cell based on a cached result.
3922
+ // undefined → empty slot (never tested / in-flight starts from here)
3923
+ // { ok:true } → "812ms" in green/amber/red per threshold
3924
+ // { ok:false } → "✕" with error in tooltip
3925
+ // { pending:true } → "…" spinner-ish marker
3926
+ function _fillLatencyCell(el, entry) {
3927
+ el.className = "sib-model-latency";
3928
+ el.textContent = "";
3929
+ el.removeAttribute("title");
3930
+ if (!entry) return;
3931
+ if (entry.pending) {
3932
+ el.textContent = "…";
3933
+ el.classList.add("is-pending");
3934
+ return;
3935
+ }
3936
+ if (!entry.ok) {
3937
+ el.textContent = "✕";
3938
+ el.classList.add("is-err");
3939
+ el.title = entry.error || "failed";
3940
+ return;
3941
+ }
3942
+ const ms = entry.ttft_ms;
3943
+ // Same thresholds as the sib-signal status bar — keep them aligned so
3944
+ // "3 bars in the status bar" ≈ "green number in the picker".
3945
+ // We measure full non-streaming response time (not real TTFT), so ≤60s is
3946
+ // normal, ≤120s is slow, beyond is bad. ≤2s still gets the "feels instant"
3947
+ // green treatment like the 4-bar signal.
3948
+ let cls = "is-bad";
3949
+ if (ms <= 2000) cls = "is-ok";
3950
+ else if (ms <= 60000) cls = "is-ok";
3951
+ else if (ms <= 120000) cls = "is-warn";
3952
+ el.classList.add(cls);
3953
+ el.textContent = ms >= 1000 ? (ms / 1000).toFixed(1) + "s" : ms + "ms";
3954
+ if (typeof I18n !== "undefined") {
3955
+ el.title = I18n.t("sib.bench.latencyTooltip", {
3956
+ ttft: el.textContent,
3957
+ time: new Date(entry.ts).toLocaleTimeString(),
3958
+ });
3959
+ } else {
3960
+ el.title = `TTFT ${el.textContent} · tested ${new Date(entry.ts).toLocaleTimeString()}`;
3961
+ }
3962
+ }
3963
+
3964
+ async function _runBenchmark(sessionId, dropdown, btn, label, hint) {
3965
+ if (_benchInFlight) return;
3966
+ _benchInFlight = true;
3967
+ btn.disabled = true;
3968
+ const origLabel = label.textContent;
3969
+ const _t = (key, vars) => (typeof I18n !== "undefined") ? I18n.t(key, vars) : key;
3970
+ label.textContent = _t("sib.bench.running");
3971
+ hint.textContent = "";
3972
+
3973
+ // Mark every row as pending so the user sees instant feedback instead of
3974
+ // a silent button. _fillLatencyCell handles the visual treatment.
3975
+ dropdown.querySelectorAll(".sib-model-option").forEach(opt => {
3976
+ const id = opt.dataset.modelId;
3977
+ if (!id) return;
3978
+ _benchCache[id] = { pending: true };
3979
+ _fillLatencyCell(opt.querySelector(".sib-model-latency"), _benchCache[id]);
3980
+ });
3981
+
3982
+ const t0 = performance.now();
3983
+ try {
3984
+ const res = await fetch(`/api/sessions/${sessionId}/benchmark`, { method: "POST" });
3985
+ const data = await res.json();
3986
+ if (!res.ok || !data.ok) throw new Error(data.error || "benchmark failed");
3987
+
3988
+ const now = Date.now();
3989
+ (data.results || []).forEach(r => {
3990
+ _benchCache[r.model_id] = {
3991
+ ok: !!r.ok,
3992
+ ttft_ms: r.ttft_ms,
3993
+ error: r.error,
3994
+ ts: now,
3995
+ };
3996
+ const opt = dropdown.querySelector(`.sib-model-option[data-model-id="${CSS.escape(r.model_id)}"]`);
3997
+ if (opt) _fillLatencyCell(opt.querySelector(".sib-model-latency"), _benchCache[r.model_id]);
3998
+ });
3999
+
4000
+ const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
4001
+ hint.textContent = _t("sib.bench.done", { t: elapsed });
4002
+ } catch (e) {
4003
+ console.error("Benchmark failed:", e);
4004
+ hint.textContent = _t("sib.bench.failed", { msg: e.message });
4005
+ // Clear pending markers so rows don't stay stuck on "…"
4006
+ dropdown.querySelectorAll(".sib-model-option").forEach(opt => {
4007
+ const id = opt.dataset.modelId;
4008
+ if (id && _benchCache[id]?.pending) {
4009
+ _benchCache[id] = undefined;
4010
+ _fillLatencyCell(opt.querySelector(".sib-model-latency"), undefined);
4011
+ }
4012
+ });
4013
+ } finally {
4014
+ _benchInFlight = false;
4015
+ btn.disabled = false;
4016
+ label.textContent = origLabel;
4017
+ }
4018
+ }
4019
+
4020
+ // Switch session model via API
4021
+ // modelId — stable runtime id (required by backend)
4022
+ // modelName — display name, used for optimistic UI update
4023
+ async function _switchModel(sessionId, modelId, modelName) {
4024
+ const dropdown = $("sib-model-dropdown");
4025
+ if (dropdown) {
4026
+ dropdown.style.display = "none";
4027
+ _isOpen = false;
4028
+ }
4029
+
4030
+ try {
4031
+ const res = await fetch(`/api/sessions/${sessionId}/model`, {
4032
+ method: "PATCH",
4033
+ headers: { "Content-Type": "application/json" },
4034
+ body: JSON.stringify({ model_id: modelId })
4035
+ });
4036
+
4037
+ const data = await res.json();
4038
+
4039
+ if (!res.ok) {
4040
+ throw new Error(data.error || "Unknown error");
4041
+ }
4042
+
4043
+ // Update UI optimistically (will be confirmed by session_update broadcast)
4044
+ const sibModel = $("sib-model");
4045
+ if (sibModel) sibModel.textContent = modelName;
4046
+
4047
+ console.log(`Switched session ${sessionId} to model ${modelName} (${modelId})`);
4048
+ } catch (e) {
4049
+ console.error("Failed to switch model:", e);
4050
+ alert("Failed to switch model: " + e.message);
4051
+ }
4052
+ }
4053
+ })();
4054
+
4055
+ // ── Session Info Bar Working Directory Switcher ───────────────────────────
4056
+ (function() {
4057
+ // Handle click on working directory
4058
+ document.addEventListener("click", async (e) => {
4059
+ const dirEl = e.target.closest("#sib-dir");
4060
+ if (dirEl) {
4061
+ e.stopPropagation();
4062
+ const sessionId = dirEl.dataset.sessionId;
4063
+ const currentDir = dirEl.dataset.workingDir || dirEl.textContent;
4064
+
4065
+ const newDir = await Modal.prompt(I18n.t("sib.dir.changePrompt"), currentDir);
4066
+ if (newDir && newDir !== currentDir) {
4067
+ _changeWorkingDirectory(sessionId, newDir);
4068
+ }
4069
+ }
4070
+
4071
+ // Handle click on session ID — toggles a small actions dropdown with
4072
+ // items like "Download session files (for debugging)". Designed to be
4073
+ // extensible (more session-level actions can be added here later).
4074
+ const sibIdEl = e.target.closest("#sib-id");
4075
+ if (sibIdEl) {
4076
+ e.stopPropagation();
4077
+ const sessionId = sibIdEl.dataset.sessionId;
4078
+ if (!sessionId) return;
4079
+ _toggleSessionActionsDropdown(sibIdEl, sessionId);
4080
+ return;
4081
+ }
4082
+
4083
+ // Handle click on an item inside the actions dropdown.
4084
+ const actionItem = e.target.closest(".sib-actions-item");
4085
+ if (actionItem) {
4086
+ e.stopPropagation();
4087
+ const action = actionItem.dataset.action;
4088
+ const sessionId = actionItem.dataset.sessionId;
4089
+ _closeSessionActionsDropdown();
4090
+ if (action === "download" && sessionId) {
4091
+ _downloadSessionBundle(sessionId, actionItem);
4092
+ }
4093
+ return;
4094
+ }
4095
+
4096
+ // Toggle background-tasks popover when clicking the badge in the SIB.
4097
+ if (e.target.closest("#sib-bgtasks")) {
4098
+ e.stopPropagation();
4099
+ _toggleBgTasksPopover(e.target.closest("#sib-bgtasks"));
4100
+ return;
4101
+ }
4102
+
4103
+ // Click outside — close both the actions dropdown and bg-tasks popover if open.
4104
+ if (!e.target.closest("#sib-actions-dropdown")) {
4105
+ _closeSessionActionsDropdown();
4106
+ }
4107
+ if (!e.target.closest("#sib-bgtasks-popover") && !e.target.closest("#sib-bgtasks")) {
4108
+ _closeBgTasksPopover();
4109
+ }
4110
+ });
4111
+
4112
+ // Close dropdown on Escape.
4113
+ document.addEventListener("keydown", (e) => {
4114
+ if (e.key === "Escape") { _closeSessionActionsDropdown(); _closeBgTasksPopover(); }
4115
+ });
4116
+
4117
+ function _closeSessionActionsDropdown() {
4118
+ const dd = $("sib-actions-dropdown");
4119
+ if (dd && dd.style.display !== "none") dd.style.display = "none";
4120
+ }
4121
+
4122
+ // ── Background-tasks popover ──────────────────────────────────────────
4123
+
4124
+ function _ensureBgTasksPopover() {
4125
+ let pop = document.getElementById("sib-bgtasks-popover");
4126
+ if (pop) return pop;
4127
+ pop = document.createElement("div");
4128
+ pop.id = "sib-bgtasks-popover";
4129
+ pop.className = "sib-bgtasks-popover";
4130
+ pop.setAttribute("role", "tooltip");
4131
+ pop.style.display = "none";
4132
+ document.body.appendChild(pop);
4133
+ return pop;
4134
+ }
4135
+
4136
+ function _toggleBgTasksPopover(anchorEl) {
4137
+ const pop = _ensureBgTasksPopover();
4138
+ if (pop.style.display !== "none") {
4139
+ pop.style.display = "none";
4140
+ return;
4141
+ }
4142
+
4143
+ const tasks = _parseBgTasksData(anchorEl);
4144
+ if (!tasks || tasks.length === 0) return;
4145
+
4146
+ _renderBgTasksPopover(pop, tasks);
4147
+
4148
+ pop.style.display = "block";
4149
+ pop.style.visibility = "hidden";
4150
+ const popHeight = pop.offsetHeight;
4151
+
4152
+ const rect = anchorEl.getBoundingClientRect();
4153
+ const gap = 6;
4154
+ const vh = window.innerHeight;
4155
+ const fitsBelow = rect.bottom + gap + popHeight <= vh;
4156
+
4157
+ pop.style.left = `${rect.left + rect.width / 2}px`;
4158
+ pop.style.transform = "translate(-50%, 0)";
4159
+ if (fitsBelow) {
4160
+ pop.style.top = `${rect.bottom + gap}px`;
4161
+ } else {
4162
+ pop.style.top = `${rect.top - popHeight - gap}px`;
4163
+ }
4164
+ pop.style.visibility = "";
4165
+ }
4166
+
4167
+ function _closeBgTasksPopover() {
4168
+ const pop = document.getElementById("sib-bgtasks-popover");
4169
+ if (pop && pop.style.display !== "none") pop.style.display = "none";
4170
+ }
4171
+
4172
+ function _parseBgTasksData(badge) {
4173
+ try { return JSON.parse(badge.dataset.tasks || "[]"); }
4174
+ catch (_) { return []; }
4175
+ }
4176
+
4177
+ function _renderBgTasksPopover(pop, tasks) {
4178
+ const rows = tasks.map(t => {
4179
+ const cmd = escapeHtml((t.command || "").trim());
4180
+ const elapsed = t.elapsed || 0;
4181
+ const elapsedStr = elapsed >= 60
4182
+ ? `${Math.floor(elapsed / 60)}m ${elapsed % 60}s`
4183
+ : `${elapsed}s`;
4184
+ return `<div class="sib-bgtasks-popover-row">
4185
+ <code class="sib-bgtasks-popover-cmd">${cmd}</code>
4186
+ <span class="sib-bgtasks-popover-elapsed">${elapsedStr}</span>
4187
+ </div>`;
4188
+ }).join("");
4189
+ pop.innerHTML = rows;
4190
+ }
4191
+
4192
+ function _toggleSessionActionsDropdown(anchorEl, sessionId) {
4193
+ const dd = $("sib-actions-dropdown");
4194
+ if (!dd) return;
4195
+
4196
+ // If already open for this session, close it (toggle behaviour).
4197
+ if (dd.style.display !== "none" && dd.dataset.sessionId === sessionId) {
4198
+ dd.style.display = "none";
4199
+ return;
4200
+ }
4201
+
4202
+ _populateSessionActionsDropdown(dd, sessionId);
4203
+ dd.dataset.sessionId = sessionId;
4204
+
4205
+ // Position the dropdown above the session ID element (same pattern as
4206
+ // the model switcher — fixed positioning, centered horizontally).
4207
+ const rect = anchorEl.getBoundingClientRect();
4208
+ dd.style.left = `${rect.left + rect.width / 2}px`;
4209
+ dd.style.top = `${rect.top - 6}px`;
4210
+ dd.style.transform = "translate(-50%, -100%)";
4211
+ dd.style.display = "block";
4212
+ }
4213
+
4214
+ function _populateSessionActionsDropdown(dd, sessionId) {
4215
+ const t = (key, fallback) => {
4216
+ const s = I18n.t(key);
4217
+ return (s && s !== key) ? s : fallback;
4218
+ };
4219
+ dd.innerHTML = "";
4220
+
4221
+ // Download item
4222
+ const item = document.createElement("div");
4223
+ item.className = "sib-actions-item";
4224
+ item.setAttribute("role", "menuitem");
4225
+ item.dataset.action = "download";
4226
+ item.dataset.sessionId = sessionId;
4227
+
4228
+ const icon = document.createElement("span");
4229
+ icon.className = "sib-actions-icon";
4230
+ icon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
4231
+
4232
+ const label = document.createElement("span");
4233
+ label.className = "sib-actions-label";
4234
+ label.textContent = t("sessions.actions.download", "Download session files");
4235
+
4236
+ const hint = document.createElement("span");
4237
+ hint.className = "sib-actions-hint";
4238
+ hint.textContent = t("sessions.actions.downloadHint", "for debugging");
4239
+
4240
+ item.appendChild(icon);
4241
+ item.appendChild(label);
4242
+ item.appendChild(hint);
4243
+ dd.appendChild(item);
4244
+ }
4245
+
4246
+ async function _downloadSessionBundle(sessionId, btnEl) {
4247
+ // btnEl may be a <button> (legacy) or a menu item <div> — guard accordingly.
4248
+ const wasDisabled = btnEl && btnEl.disabled;
4249
+ if (btnEl) {
4250
+ try { btnEl.disabled = true; } catch (_) {}
4251
+ btnEl.classList && btnEl.classList.add("is-loading");
4252
+ }
4253
+ try {
4254
+ const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/export`);
4255
+ if (!res.ok) {
4256
+ let msg = `HTTP ${res.status}`;
4257
+ try { const data = await res.json(); if (data.error) msg = data.error; } catch (_) {}
4258
+ alert(I18n.t("sessions.export.failed") + ": " + msg);
4259
+ return;
4260
+ }
4261
+ const blob = await res.blob();
4262
+
4263
+ // Derive filename from Content-Disposition header, fall back to short id.
4264
+ let filename = `octo-session-${sessionId.slice(0, 8)}.zip`;
4265
+ const cd = res.headers.get("Content-Disposition") || "";
4266
+ const m = cd.match(/filename="?([^"]+)"?/i);
4267
+ if (m) filename = m[1];
4268
+
4269
+ const url = URL.createObjectURL(blob);
4270
+ const a = document.createElement("a");
4271
+ a.href = url;
4272
+ a.download = filename;
4273
+ document.body.appendChild(a);
4274
+ a.click();
4275
+ a.remove();
4276
+ // Revoke on next tick so the browser has a chance to start the download.
4277
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
4278
+ } catch (err) {
4279
+ console.error("Session export failed:", err);
4280
+ alert(I18n.t("sessions.export.failed") + ": " + err.message);
4281
+ } finally {
4282
+ if (btnEl) {
4283
+ try { btnEl.disabled = wasDisabled; } catch (_) {}
4284
+ btnEl.classList && btnEl.classList.remove("is-loading");
4285
+ }
4286
+ }
4287
+ }
4288
+
4289
+ // Change working directory via backend API
4290
+ async function _changeWorkingDirectory(sessionId, newDir) {
4291
+ try {
4292
+ const res = await fetch(`/api/sessions/${sessionId}/working_dir`, {
4293
+ method: "PATCH",
4294
+ headers: { "Content-Type": "application/json" },
4295
+ body: JSON.stringify({ working_dir: newDir })
4296
+ });
4297
+
4298
+ const data = await res.json();
4299
+
4300
+ if (!res.ok) {
4301
+ throw new Error(data.error || "Unknown error");
4302
+ }
4303
+
4304
+ // Update UI optimistically (will be confirmed by session_update broadcast)
4305
+ const sibDir = $("sib-dir");
4306
+ if (sibDir) {
4307
+ sibDir.textContent = newDir;
4308
+ sibDir.title = `${newDir} (${I18n.t("sib.dir.tooltip")})`;
4309
+ sibDir.dataset.workingDir = newDir;
4310
+ }
4311
+
4312
+ console.log(`Changed session ${sessionId} directory to ${newDir}`);
4313
+ } catch (e) {
4314
+ console.error("Failed to change directory:", e);
4315
+ alert("Failed to change directory: " + e.message);
4316
+ }
4317
+ }
4318
+
4319
+ })();
4320
+
4321
+ // ── Session Info Bar Reasoning Effort Switcher ────────────────────────────
4322
+ (function() {
4323
+ let _isOpen = false;
4324
+ const LEVELS = ["off", "low", "medium", "high"];
4325
+
4326
+ document.addEventListener("click", async (e) => {
4327
+ const el = e.target.closest("#sib-reasoning");
4328
+ if (el) {
4329
+ e.stopPropagation();
4330
+ const dropdown = $("sib-reasoning-dropdown");
4331
+ if (!dropdown) return;
4332
+
4333
+ if (_isOpen) {
4334
+ dropdown.style.display = "none";
4335
+ _isOpen = false;
4336
+ return;
4337
+ }
4338
+
4339
+ _populate(dropdown, el.dataset.sessionId, el.dataset.reasoningEffort || "off");
4340
+
4341
+ const rect = el.getBoundingClientRect();
4342
+ dropdown.style.left = `${rect.left + rect.width / 2}px`;
4343
+ dropdown.style.top = `${rect.top - 6}px`;
4344
+ dropdown.style.transform = "translate(-50%, -100%)";
4345
+ dropdown.style.display = "block";
4346
+ _isOpen = true;
4347
+ return;
4348
+ }
4349
+
4350
+ if (_isOpen && !e.target.closest("#sib-reasoning-dropdown")) {
4351
+ const dropdown = $("sib-reasoning-dropdown");
4352
+ if (dropdown) dropdown.style.display = "none";
4353
+ _isOpen = false;
4354
+ }
4355
+ });
4356
+
4357
+ function _populate(dropdown, sessionId, current) {
4358
+ dropdown.innerHTML = "";
4359
+
4360
+ const header = document.createElement("div");
4361
+ header.className = "sib-reasoning-header";
4362
+ const heading = document.createElement("div");
4363
+ heading.className = "sib-reasoning-heading";
4364
+ heading.textContent = I18n.t("sib.reasoning.heading");
4365
+ const hint = document.createElement("div");
4366
+ hint.className = "sib-reasoning-hint";
4367
+ hint.textContent = I18n.t("sib.reasoning.hint");
4368
+ header.appendChild(heading);
4369
+ header.appendChild(hint);
4370
+ dropdown.appendChild(header);
4371
+
4372
+ LEVELS.forEach(level => {
4373
+ const opt = document.createElement("div");
4374
+ opt.className = "sib-reasoning-option";
4375
+ if (level === current) opt.classList.add("current");
4376
+
4377
+ const label = document.createElement("span");
4378
+ label.className = "sib-reasoning-name";
4379
+ label.textContent = I18n.t(`sib.reasoning.${level}`);
4380
+ opt.appendChild(label);
4381
+
4382
+ opt.addEventListener("click", () => _switch(sessionId, level));
4383
+ dropdown.appendChild(opt);
4384
+ });
4385
+ }
4386
+
4387
+ async function _switch(sessionId, level) {
4388
+ const dropdown = $("sib-reasoning-dropdown");
4389
+ if (dropdown) {
4390
+ dropdown.style.display = "none";
4391
+ _isOpen = false;
4392
+ }
4393
+
4394
+ try {
4395
+ const res = await fetch(`/api/sessions/${sessionId}/reasoning_effort`, {
4396
+ method: "PATCH",
4397
+ headers: { "Content-Type": "application/json" },
4398
+ body: JSON.stringify({ reasoning_effort: level })
4399
+ });
4400
+ const data = await res.json();
4401
+ if (!res.ok) throw new Error(data.error || "Unknown error");
4402
+
4403
+ const el = $("sib-reasoning");
4404
+ if (el) {
4405
+ el.textContent = I18n.t(`sib.reasoning.${level}`);
4406
+ el.dataset.reasoningEffort = level;
4407
+ }
4408
+ } catch (e) {
4409
+ console.error("Failed to switch reasoning effort:", e);
4410
+ alert("Failed to switch reasoning effort: " + e.message);
4411
+ }
4412
+ }
4413
+ })();
4414
+
4415
+ document.addEventListener("langchange", () => {
4416
+ if (Sessions._lastSession) Sessions.updateInfoBar(Sessions._lastSession);
4417
+ });
4418
+
4419
+ document.addEventListener("currencychange", () => {
4420
+ if (Sessions._lastSession) Sessions.updateInfoBar(Sessions._lastSession);
4421
+ });