@fraziym/axoniz 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (512) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -0
  3. package/dist/agents/definition.d.ts +15 -0
  4. package/dist/agents/definition.d.ts.map +1 -0
  5. package/dist/agents/definition.js +41 -0
  6. package/dist/agents/definition.js.map +1 -0
  7. package/dist/agents/index.d.ts +12 -0
  8. package/dist/agents/index.d.ts.map +1 -0
  9. package/dist/agents/index.js +11 -0
  10. package/dist/agents/index.js.map +1 -0
  11. package/dist/agents/registry.d.ts +17 -0
  12. package/dist/agents/registry.d.ts.map +1 -0
  13. package/dist/agents/registry.js +23 -0
  14. package/dist/agents/registry.js.map +1 -0
  15. package/dist/agents/specialized.d.ts +31 -0
  16. package/dist/agents/specialized.d.ts.map +1 -0
  17. package/dist/agents/specialized.js +75 -0
  18. package/dist/agents/specialized.js.map +1 -0
  19. package/dist/awareness/index.d.ts +14 -0
  20. package/dist/awareness/index.d.ts.map +1 -0
  21. package/dist/awareness/index.js +22 -0
  22. package/dist/awareness/index.js.map +1 -0
  23. package/dist/awareness/service.d.ts +180 -0
  24. package/dist/awareness/service.d.ts.map +1 -0
  25. package/dist/awareness/service.js +789 -0
  26. package/dist/awareness/service.js.map +1 -0
  27. package/dist/cli/entry.d.ts +13 -0
  28. package/dist/cli/entry.d.ts.map +1 -0
  29. package/dist/cli/entry.js +35 -0
  30. package/dist/cli/entry.js.map +1 -0
  31. package/dist/comms/telegram.d.ts +41 -0
  32. package/dist/comms/telegram.d.ts.map +1 -0
  33. package/dist/comms/telegram.js +332 -0
  34. package/dist/comms/telegram.js.map +1 -0
  35. package/dist/core/agent.d.ts +252 -0
  36. package/dist/core/agent.d.ts.map +1 -0
  37. package/dist/core/agent.js +1399 -0
  38. package/dist/core/agent.js.map +1 -0
  39. package/dist/core/auth.d.ts +23 -0
  40. package/dist/core/auth.d.ts.map +1 -0
  41. package/dist/core/auth.js +113 -0
  42. package/dist/core/auth.js.map +1 -0
  43. package/dist/core/authority.d.ts +118 -0
  44. package/dist/core/authority.d.ts.map +1 -0
  45. package/dist/core/authority.js +402 -0
  46. package/dist/core/authority.js.map +1 -0
  47. package/dist/core/backend/async_queue.d.ts +17 -0
  48. package/dist/core/backend/async_queue.d.ts.map +1 -0
  49. package/dist/core/backend/async_queue.js +66 -0
  50. package/dist/core/backend/async_queue.js.map +1 -0
  51. package/dist/core/backend/index.d.ts +24 -0
  52. package/dist/core/backend/index.d.ts.map +1 -0
  53. package/dist/core/backend/index.js +128 -0
  54. package/dist/core/backend/index.js.map +1 -0
  55. package/dist/core/backend/llama_server.d.ts +39 -0
  56. package/dist/core/backend/llama_server.d.ts.map +1 -0
  57. package/dist/core/backend/llama_server.js +182 -0
  58. package/dist/core/backend/llama_server.js.map +1 -0
  59. package/dist/core/backend/llamacpp.d.ts +40 -0
  60. package/dist/core/backend/llamacpp.d.ts.map +1 -0
  61. package/dist/core/backend/llamacpp.js +200 -0
  62. package/dist/core/backend/llamacpp.js.map +1 -0
  63. package/dist/core/backend/lmstudio.d.ts +53 -0
  64. package/dist/core/backend/lmstudio.d.ts.map +1 -0
  65. package/dist/core/backend/lmstudio.js +274 -0
  66. package/dist/core/backend/lmstudio.js.map +1 -0
  67. package/dist/core/backend/ollama.d.ts +25 -0
  68. package/dist/core/backend/ollama.d.ts.map +1 -0
  69. package/dist/core/backend/ollama.js +86 -0
  70. package/dist/core/backend/ollama.js.map +1 -0
  71. package/dist/core/backend/openai.d.ts +27 -0
  72. package/dist/core/backend/openai.d.ts.map +1 -0
  73. package/dist/core/backend/openai.js +84 -0
  74. package/dist/core/backend/openai.js.map +1 -0
  75. package/dist/core/backend/types.d.ts +73 -0
  76. package/dist/core/backend/types.d.ts.map +1 -0
  77. package/dist/core/backend/types.js +137 -0
  78. package/dist/core/backend/types.js.map +1 -0
  79. package/dist/core/chat_store.d.ts +77 -0
  80. package/dist/core/chat_store.d.ts.map +1 -0
  81. package/dist/core/chat_store.js +332 -0
  82. package/dist/core/chat_store.js.map +1 -0
  83. package/dist/core/cli.d.ts +82 -0
  84. package/dist/core/cli.d.ts.map +1 -0
  85. package/dist/core/cli.js +602 -0
  86. package/dist/core/cli.js.map +1 -0
  87. package/dist/core/config.d.ts +127 -0
  88. package/dist/core/config.d.ts.map +1 -0
  89. package/dist/core/config.js +295 -0
  90. package/dist/core/config.js.map +1 -0
  91. package/dist/core/debug.d.ts +8 -0
  92. package/dist/core/debug.d.ts.map +1 -0
  93. package/dist/core/debug.js +8 -0
  94. package/dist/core/debug.js.map +1 -0
  95. package/dist/core/downloader.d.ts +31 -0
  96. package/dist/core/downloader.d.ts.map +1 -0
  97. package/dist/core/downloader.js +180 -0
  98. package/dist/core/downloader.js.map +1 -0
  99. package/dist/core/errors.d.ts +80 -0
  100. package/dist/core/errors.d.ts.map +1 -0
  101. package/dist/core/errors.js +134 -0
  102. package/dist/core/errors.js.map +1 -0
  103. package/dist/core/extras.d.ts +160 -0
  104. package/dist/core/extras.d.ts.map +1 -0
  105. package/dist/core/extras.js +553 -0
  106. package/dist/core/extras.js.map +1 -0
  107. package/dist/core/health.d.ts +27 -0
  108. package/dist/core/health.d.ts.map +1 -0
  109. package/dist/core/health.js +96 -0
  110. package/dist/core/health.js.map +1 -0
  111. package/dist/core/history.d.ts +50 -0
  112. package/dist/core/history.d.ts.map +1 -0
  113. package/dist/core/history.js +276 -0
  114. package/dist/core/history.js.map +1 -0
  115. package/dist/core/index.d.ts +6 -0
  116. package/dist/core/index.d.ts.map +1 -0
  117. package/dist/core/index.js +6 -0
  118. package/dist/core/index.js.map +1 -0
  119. package/dist/core/intelligence/ast_index.d.ts +255 -0
  120. package/dist/core/intelligence/ast_index.d.ts.map +1 -0
  121. package/dist/core/intelligence/ast_index.js +1369 -0
  122. package/dist/core/intelligence/ast_index.js.map +1 -0
  123. package/dist/core/intelligence/confidence.d.ts +113 -0
  124. package/dist/core/intelligence/confidence.d.ts.map +1 -0
  125. package/dist/core/intelligence/confidence.js +298 -0
  126. package/dist/core/intelligence/confidence.js.map +1 -0
  127. package/dist/core/intelligence/context_compressor.d.ts +80 -0
  128. package/dist/core/intelligence/context_compressor.d.ts.map +1 -0
  129. package/dist/core/intelligence/context_compressor.js +245 -0
  130. package/dist/core/intelligence/context_compressor.js.map +1 -0
  131. package/dist/core/intelligence/daemon.d.ts +247 -0
  132. package/dist/core/intelligence/daemon.d.ts.map +1 -0
  133. package/dist/core/intelligence/daemon.js +842 -0
  134. package/dist/core/intelligence/daemon.js.map +1 -0
  135. package/dist/core/intelligence/index.d.ts +60 -0
  136. package/dist/core/intelligence/index.d.ts.map +1 -0
  137. package/dist/core/intelligence/index.js +101 -0
  138. package/dist/core/intelligence/index.js.map +1 -0
  139. package/dist/core/intelligence/predictor.d.ts +246 -0
  140. package/dist/core/intelligence/predictor.d.ts.map +1 -0
  141. package/dist/core/intelligence/predictor.js +590 -0
  142. package/dist/core/intelligence/predictor.js.map +1 -0
  143. package/dist/core/intelligence/reflex.d.ts +84 -0
  144. package/dist/core/intelligence/reflex.d.ts.map +1 -0
  145. package/dist/core/intelligence/reflex.js +344 -0
  146. package/dist/core/intelligence/reflex.js.map +1 -0
  147. package/dist/core/intelligence/self_correction.d.ts +121 -0
  148. package/dist/core/intelligence/self_correction.d.ts.map +1 -0
  149. package/dist/core/intelligence/self_correction.js +327 -0
  150. package/dist/core/intelligence/self_correction.js.map +1 -0
  151. package/dist/core/intelligence/skill_distiller.d.ts +100 -0
  152. package/dist/core/intelligence/skill_distiller.d.ts.map +1 -0
  153. package/dist/core/intelligence/skill_distiller.js +246 -0
  154. package/dist/core/intelligence/skill_distiller.js.map +1 -0
  155. package/dist/core/intelligence/swarm.d.ts +330 -0
  156. package/dist/core/intelligence/swarm.d.ts.map +1 -0
  157. package/dist/core/intelligence/swarm.js +1064 -0
  158. package/dist/core/intelligence/swarm.js.map +1 -0
  159. package/dist/core/intelligence/trajectory.d.ts +107 -0
  160. package/dist/core/intelligence/trajectory.d.ts.map +1 -0
  161. package/dist/core/intelligence/trajectory.js +330 -0
  162. package/dist/core/intelligence/trajectory.js.map +1 -0
  163. package/dist/core/logger.d.ts +111 -0
  164. package/dist/core/logger.d.ts.map +1 -0
  165. package/dist/core/logger.js +640 -0
  166. package/dist/core/logger.js.map +1 -0
  167. package/dist/core/loop.d.ts +123 -0
  168. package/dist/core/loop.d.ts.map +1 -0
  169. package/dist/core/loop.js +423 -0
  170. package/dist/core/loop.js.map +1 -0
  171. package/dist/core/memory.d.ts +81 -0
  172. package/dist/core/memory.d.ts.map +1 -0
  173. package/dist/core/memory.js +328 -0
  174. package/dist/core/memory.js.map +1 -0
  175. package/dist/core/metrics.d.ts +29 -0
  176. package/dist/core/metrics.d.ts.map +1 -0
  177. package/dist/core/metrics.js +109 -0
  178. package/dist/core/metrics.js.map +1 -0
  179. package/dist/core/models.d.ts +51 -0
  180. package/dist/core/models.d.ts.map +1 -0
  181. package/dist/core/models.js +146 -0
  182. package/dist/core/models.js.map +1 -0
  183. package/dist/core/optimizer.d.ts +34 -0
  184. package/dist/core/optimizer.d.ts.map +1 -0
  185. package/dist/core/optimizer.js +131 -0
  186. package/dist/core/optimizer.js.map +1 -0
  187. package/dist/core/persona.d.ts +35 -0
  188. package/dist/core/persona.d.ts.map +1 -0
  189. package/dist/core/persona.js +126 -0
  190. package/dist/core/persona.js.map +1 -0
  191. package/dist/core/rate_limit.d.ts +23 -0
  192. package/dist/core/rate_limit.d.ts.map +1 -0
  193. package/dist/core/rate_limit.js +62 -0
  194. package/dist/core/rate_limit.js.map +1 -0
  195. package/dist/core/runner.d.ts +63 -0
  196. package/dist/core/runner.d.ts.map +1 -0
  197. package/dist/core/runner.js +671 -0
  198. package/dist/core/runner.js.map +1 -0
  199. package/dist/core/stream_parser.d.ts +43 -0
  200. package/dist/core/stream_parser.d.ts.map +1 -0
  201. package/dist/core/stream_parser.js +140 -0
  202. package/dist/core/stream_parser.js.map +1 -0
  203. package/dist/core/tool_schemas.d.ts +26 -0
  204. package/dist/core/tool_schemas.d.ts.map +1 -0
  205. package/dist/core/tool_schemas.js +124 -0
  206. package/dist/core/tool_schemas.js.map +1 -0
  207. package/dist/core/which.d.ts +2 -0
  208. package/dist/core/which.d.ts.map +1 -0
  209. package/dist/core/which.js +35 -0
  210. package/dist/core/which.js.map +1 -0
  211. package/dist/goals/index.d.ts +9 -0
  212. package/dist/goals/index.d.ts.map +1 -0
  213. package/dist/goals/index.js +17 -0
  214. package/dist/goals/index.js.map +1 -0
  215. package/dist/goals/service.d.ts +115 -0
  216. package/dist/goals/service.d.ts.map +1 -0
  217. package/dist/goals/service.js +415 -0
  218. package/dist/goals/service.js.map +1 -0
  219. package/dist/goals/types.d.ts +47 -0
  220. package/dist/goals/types.d.ts.map +1 -0
  221. package/dist/goals/types.js +66 -0
  222. package/dist/goals/types.js.map +1 -0
  223. package/dist/index.d.ts +22 -0
  224. package/dist/index.d.ts.map +1 -0
  225. package/dist/index.js +22 -0
  226. package/dist/index.js.map +1 -0
  227. package/dist/integrations/autonomous_startup.d.ts +51 -0
  228. package/dist/integrations/autonomous_startup.d.ts.map +1 -0
  229. package/dist/integrations/autonomous_startup.js +419 -0
  230. package/dist/integrations/autonomous_startup.js.map +1 -0
  231. package/dist/integrations/goose.d.ts +105 -0
  232. package/dist/integrations/goose.d.ts.map +1 -0
  233. package/dist/integrations/goose.js +408 -0
  234. package/dist/integrations/goose.js.map +1 -0
  235. package/dist/integrations/index.d.ts +21 -0
  236. package/dist/integrations/index.d.ts.map +1 -0
  237. package/dist/integrations/index.js +17 -0
  238. package/dist/integrations/index.js.map +1 -0
  239. package/dist/integrations/mcp.d.ts +73 -0
  240. package/dist/integrations/mcp.d.ts.map +1 -0
  241. package/dist/integrations/mcp.js +307 -0
  242. package/dist/integrations/mcp.js.map +1 -0
  243. package/dist/integrations/mempalace.d.ts +18 -0
  244. package/dist/integrations/mempalace.d.ts.map +1 -0
  245. package/dist/integrations/mempalace.js +25 -0
  246. package/dist/integrations/mempalace.js.map +1 -0
  247. package/dist/integrations/multi_agent.d.ts +82 -0
  248. package/dist/integrations/multi_agent.d.ts.map +1 -0
  249. package/dist/integrations/multi_agent.js +280 -0
  250. package/dist/integrations/multi_agent.js.map +1 -0
  251. package/dist/integrations/unified_memory.d.ts +415 -0
  252. package/dist/integrations/unified_memory.d.ts.map +1 -0
  253. package/dist/integrations/unified_memory.js +1489 -0
  254. package/dist/integrations/unified_memory.js.map +1 -0
  255. package/dist/roles/beru.yaml +48 -0
  256. package/dist/sidecar/client.d.ts +63 -0
  257. package/dist/sidecar/client.d.ts.map +1 -0
  258. package/dist/sidecar/client.js +222 -0
  259. package/dist/sidecar/client.js.map +1 -0
  260. package/dist/sidecar/index.d.ts +7 -0
  261. package/dist/sidecar/index.d.ts.map +1 -0
  262. package/dist/sidecar/index.js +6 -0
  263. package/dist/sidecar/index.js.map +1 -0
  264. package/dist/startup.d.ts +22 -0
  265. package/dist/startup.d.ts.map +1 -0
  266. package/dist/startup.js +194 -0
  267. package/dist/startup.js.map +1 -0
  268. package/dist/tools/_internal.d.ts +90 -0
  269. package/dist/tools/_internal.d.ts.map +1 -0
  270. package/dist/tools/_internal.js +375 -0
  271. package/dist/tools/_internal.js.map +1 -0
  272. package/dist/tools/_smoke.d.ts +2 -0
  273. package/dist/tools/_smoke.d.ts.map +1 -0
  274. package/dist/tools/_smoke.js +203 -0
  275. package/dist/tools/_smoke.js.map +1 -0
  276. package/dist/tools/axodex_tools.d.ts +51 -0
  277. package/dist/tools/axodex_tools.d.ts.map +1 -0
  278. package/dist/tools/axodex_tools.js +181 -0
  279. package/dist/tools/axodex_tools.js.map +1 -0
  280. package/dist/tools/code_tools.d.ts +33 -0
  281. package/dist/tools/code_tools.d.ts.map +1 -0
  282. package/dist/tools/code_tools.js +191 -0
  283. package/dist/tools/code_tools.js.map +1 -0
  284. package/dist/tools/computer_tools.d.ts +59 -0
  285. package/dist/tools/computer_tools.d.ts.map +1 -0
  286. package/dist/tools/computer_tools.js +348 -0
  287. package/dist/tools/computer_tools.js.map +1 -0
  288. package/dist/tools/file_tools.d.ts +51 -0
  289. package/dist/tools/file_tools.d.ts.map +1 -0
  290. package/dist/tools/file_tools.js +424 -0
  291. package/dist/tools/file_tools.js.map +1 -0
  292. package/dist/tools/index.d.ts +13 -0
  293. package/dist/tools/index.d.ts.map +1 -0
  294. package/dist/tools/index.js +13 -0
  295. package/dist/tools/index.js.map +1 -0
  296. package/dist/tools/shell_tools.d.ts +39 -0
  297. package/dist/tools/shell_tools.d.ts.map +1 -0
  298. package/dist/tools/shell_tools.js +163 -0
  299. package/dist/tools/shell_tools.js.map +1 -0
  300. package/dist/tools/web_tools.d.ts +31 -0
  301. package/dist/tools/web_tools.d.ts.map +1 -0
  302. package/dist/tools/web_tools.js +117 -0
  303. package/dist/tools/web_tools.js.map +1 -0
  304. package/dist/voice/index.d.ts +41 -0
  305. package/dist/voice/index.d.ts.map +1 -0
  306. package/dist/voice/index.js +58 -0
  307. package/dist/voice/index.js.map +1 -0
  308. package/dist/voice/light_daemon.d.ts +197 -0
  309. package/dist/voice/light_daemon.d.ts.map +1 -0
  310. package/dist/voice/light_daemon.js +746 -0
  311. package/dist/voice/light_daemon.js.map +1 -0
  312. package/dist/voice/setup.d.ts +63 -0
  313. package/dist/voice/setup.d.ts.map +1 -0
  314. package/dist/voice/setup.js +443 -0
  315. package/dist/voice/setup.js.map +1 -0
  316. package/dist/voice/stt.d.ts +148 -0
  317. package/dist/voice/stt.d.ts.map +1 -0
  318. package/dist/voice/stt.js +361 -0
  319. package/dist/voice/stt.js.map +1 -0
  320. package/dist/voice/tts.d.ts +168 -0
  321. package/dist/voice/tts.d.ts.map +1 -0
  322. package/dist/voice/tts.js +698 -0
  323. package/dist/voice/tts.js.map +1 -0
  324. package/dist/voice/voice_loop.d.ts +101 -0
  325. package/dist/voice/voice_loop.d.ts.map +1 -0
  326. package/dist/voice/voice_loop.js +463 -0
  327. package/dist/voice/voice_loop.js.map +1 -0
  328. package/dist/voice/wake_word.d.ts +153 -0
  329. package/dist/voice/wake_word.d.ts.map +1 -0
  330. package/dist/voice/wake_word.js +458 -0
  331. package/dist/voice/wake_word.js.map +1 -0
  332. package/dist/voice/wav.d.ts +99 -0
  333. package/dist/voice/wav.d.ts.map +1 -0
  334. package/dist/voice/wav.js +306 -0
  335. package/dist/voice/wav.js.map +1 -0
  336. package/dist/web/broker.d.ts +33 -0
  337. package/dist/web/broker.d.ts.map +1 -0
  338. package/dist/web/broker.js +58 -0
  339. package/dist/web/broker.js.map +1 -0
  340. package/dist/web/open_browser.d.ts +2 -0
  341. package/dist/web/open_browser.d.ts.map +1 -0
  342. package/dist/web/open_browser.js +42 -0
  343. package/dist/web/open_browser.js.map +1 -0
  344. package/dist/web/server.d.ts +54 -0
  345. package/dist/web/server.d.ts.map +1 -0
  346. package/dist/web/server.js +831 -0
  347. package/dist/web/server.js.map +1 -0
  348. package/dist/web/static/app.js +4121 -0
  349. package/dist/web/static/index.html +2281 -0
  350. package/dist/web/static/js/MODULE_SUMMARY.md +124 -0
  351. package/dist/web/static/js/a11y.js +165 -0
  352. package/dist/web/static/js/admin.js +2145 -0
  353. package/dist/web/static/js/assistant.js +475 -0
  354. package/dist/web/static/js/calendar/reminders.js +114 -0
  355. package/dist/web/static/js/calendar/utils.js +166 -0
  356. package/dist/web/static/js/calendar.js +3348 -0
  357. package/dist/web/static/js/censor.js +356 -0
  358. package/dist/web/static/js/chat.js +4546 -0
  359. package/dist/web/static/js/chatRenderer.js +2294 -0
  360. package/dist/web/static/js/chatStream.js +276 -0
  361. package/dist/web/static/js/codeRunner.js +398 -0
  362. package/dist/web/static/js/color/hex.js +14 -0
  363. package/dist/web/static/js/colorPicker.js +453 -0
  364. package/dist/web/static/js/compare/icons.js +77 -0
  365. package/dist/web/static/js/compare/index.js +1482 -0
  366. package/dist/web/static/js/compare/models.js +104 -0
  367. package/dist/web/static/js/compare/panes.js +817 -0
  368. package/dist/web/static/js/compare/probe.js +78 -0
  369. package/dist/web/static/js/compare/scoreboard.js +223 -0
  370. package/dist/web/static/js/compare/selector.js +1335 -0
  371. package/dist/web/static/js/compare/state.js +58 -0
  372. package/dist/web/static/js/compare/stream.js +695 -0
  373. package/dist/web/static/js/compare/vote.js +254 -0
  374. package/dist/web/static/js/cookbook-diagnosis.js +694 -0
  375. package/dist/web/static/js/cookbook-hwfit.js +1888 -0
  376. package/dist/web/static/js/cookbook.js +2093 -0
  377. package/dist/web/static/js/cookbookDownload.js +633 -0
  378. package/dist/web/static/js/cookbookProgressSignal.js +29 -0
  379. package/dist/web/static/js/cookbookRunning.js +3506 -0
  380. package/dist/web/static/js/cookbookServe.js +2128 -0
  381. package/dist/web/static/js/document.js +9731 -0
  382. package/dist/web/static/js/documentLibrary.js +3376 -0
  383. package/dist/web/static/js/dragSort.js +265 -0
  384. package/dist/web/static/js/editor/ai-inpaint.js +374 -0
  385. package/dist/web/static/js/editor/ai-models.js +273 -0
  386. package/dist/web/static/js/editor/ai-rembg.js +230 -0
  387. package/dist/web/static/js/editor/ai-tool-runner.js +147 -0
  388. package/dist/web/static/js/editor/ai-tools-misc.js +216 -0
  389. package/dist/web/static/js/editor/build/controls.js +366 -0
  390. package/dist/web/static/js/editor/build/popups.js +112 -0
  391. package/dist/web/static/js/editor/build/right-panel.js +200 -0
  392. package/dist/web/static/js/editor/build/toolbar.js +73 -0
  393. package/dist/web/static/js/editor/build/topbar.js +131 -0
  394. package/dist/web/static/js/editor/build/transform-popup.js +109 -0
  395. package/dist/web/static/js/editor/canvas-coords.js +21 -0
  396. package/dist/web/static/js/editor/canvas-events.js +197 -0
  397. package/dist/web/static/js/editor/canvas-transforms.js +132 -0
  398. package/dist/web/static/js/editor/checkerboard.js +24 -0
  399. package/dist/web/static/js/editor/clipboard-and-drop.js +133 -0
  400. package/dist/web/static/js/editor/composite-helpers.js +83 -0
  401. package/dist/web/static/js/editor/filters/blur.js +118 -0
  402. package/dist/web/static/js/editor/filters/edge-feather.js +70 -0
  403. package/dist/web/static/js/editor/fx/adj-popup.js +677 -0
  404. package/dist/web/static/js/editor/fx/filter-string.js +38 -0
  405. package/dist/web/static/js/editor/fx/histogram.js +67 -0
  406. package/dist/web/static/js/editor/fx/pixel-pass.js +249 -0
  407. package/dist/web/static/js/editor/harmonize-masks.js +116 -0
  408. package/dist/web/static/js/editor/history-panel.js +176 -0
  409. package/dist/web/static/js/editor/keyboard-shortcuts.js +271 -0
  410. package/dist/web/static/js/editor/layer-helpers.js +131 -0
  411. package/dist/web/static/js/editor/layer-panel.js +600 -0
  412. package/dist/web/static/js/editor/mask-utils.js +91 -0
  413. package/dist/web/static/js/editor/shortcuts-popover.js +149 -0
  414. package/dist/web/static/js/editor/slider-ux.js +191 -0
  415. package/dist/web/static/js/editor/snap.js +107 -0
  416. package/dist/web/static/js/editor/state.js +257 -0
  417. package/dist/web/static/js/editor/stroke-pipeline.js +162 -0
  418. package/dist/web/static/js/editor/stroke-tool-sliders.js +64 -0
  419. package/dist/web/static/js/editor/tools/clone.js +81 -0
  420. package/dist/web/static/js/editor/tools/crop.js +137 -0
  421. package/dist/web/static/js/editor/tools/flood-fill.js +72 -0
  422. package/dist/web/static/js/editor/tools/lasso-mask.js +171 -0
  423. package/dist/web/static/js/editor/tools/lasso.js +65 -0
  424. package/dist/web/static/js/editor/tools/move.js +79 -0
  425. package/dist/web/static/js/editor/tools/stroke.js +123 -0
  426. package/dist/web/static/js/editor/tools/transform-drag.js +174 -0
  427. package/dist/web/static/js/editor/tools/transform-handles.js +271 -0
  428. package/dist/web/static/js/editor/tools/transform-session.js +381 -0
  429. package/dist/web/static/js/editor/tools/wand.js +46 -0
  430. package/dist/web/static/js/editor/wire-import.js +148 -0
  431. package/dist/web/static/js/editor/wire-inpaint-controls.js +164 -0
  432. package/dist/web/static/js/editor/wire-merge-buttons.js +103 -0
  433. package/dist/web/static/js/editor/wire-selection-controls.js +170 -0
  434. package/dist/web/static/js/editor/wire-topbar-menus.js +174 -0
  435. package/dist/web/static/js/editor/wire-topbar-overflow.js +49 -0
  436. package/dist/web/static/js/editor/wire-topbar.js +188 -0
  437. package/dist/web/static/js/emailInbox.js +1245 -0
  438. package/dist/web/static/js/emailLibrary/replyRecipients.js +27 -0
  439. package/dist/web/static/js/emailLibrary/signatureFold.js +339 -0
  440. package/dist/web/static/js/emailLibrary/state.js +34 -0
  441. package/dist/web/static/js/emailLibrary/utils.js +202 -0
  442. package/dist/web/static/js/emailLibrary.js +5217 -0
  443. package/dist/web/static/js/emojiPicker.js +311 -0
  444. package/dist/web/static/js/escMenuStack.js +102 -0
  445. package/dist/web/static/js/fileHandler.js +295 -0
  446. package/dist/web/static/js/gallery.js +2835 -0
  447. package/dist/web/static/js/galleryEditor.js +3798 -0
  448. package/dist/web/static/js/group.js +955 -0
  449. package/dist/web/static/js/init.js +405 -0
  450. package/dist/web/static/js/keyboard-shortcuts.js +292 -0
  451. package/dist/web/static/js/langIcons.js +187 -0
  452. package/dist/web/static/js/markdown/tableRow.js +19 -0
  453. package/dist/web/static/js/markdown.js +806 -0
  454. package/dist/web/static/js/memory.js +1418 -0
  455. package/dist/web/static/js/modalManager.js +1552 -0
  456. package/dist/web/static/js/modalSnap.js +782 -0
  457. package/dist/web/static/js/model/matchKey.js +19 -0
  458. package/dist/web/static/js/modelPicker.js +695 -0
  459. package/dist/web/static/js/modelSort.js +33 -0
  460. package/dist/web/static/js/models.js +642 -0
  461. package/dist/web/static/js/notes.js +5054 -0
  462. package/dist/web/static/js/package.json +1 -0
  463. package/dist/web/static/js/platform.js +47 -0
  464. package/dist/web/static/js/presets.js +1110 -0
  465. package/dist/web/static/js/providers.js +93 -0
  466. package/dist/web/static/js/rag.js +177 -0
  467. package/dist/web/static/js/research/jobs.js +335 -0
  468. package/dist/web/static/js/research/panel.js +1233 -0
  469. package/dist/web/static/js/researchSynapse.js +225 -0
  470. package/dist/web/static/js/search-chat.js +201 -0
  471. package/dist/web/static/js/search.js +52 -0
  472. package/dist/web/static/js/section-management.js +260 -0
  473. package/dist/web/static/js/sessions.js +3108 -0
  474. package/dist/web/static/js/settings.js +4991 -0
  475. package/dist/web/static/js/sidebar-layout.js +573 -0
  476. package/dist/web/static/js/signature.js +504 -0
  477. package/dist/web/static/js/skills.js +1908 -0
  478. package/dist/web/static/js/slashAutocomplete.js +267 -0
  479. package/dist/web/static/js/slashCommands.js +6099 -0
  480. package/dist/web/static/js/spinner.js +406 -0
  481. package/dist/web/static/js/storage.js +123 -0
  482. package/dist/web/static/js/tasks.js +2699 -0
  483. package/dist/web/static/js/theme.js +2086 -0
  484. package/dist/web/static/js/tileManager.js +386 -0
  485. package/dist/web/static/js/tourAutoplay.js +133 -0
  486. package/dist/web/static/js/tourHints.js +179 -0
  487. package/dist/web/static/js/tts-ai.js +521 -0
  488. package/dist/web/static/js/ui.js +1288 -0
  489. package/dist/web/static/js/util/ordinal.js +13 -0
  490. package/dist/web/static/js/voiceRecorder.js +283 -0
  491. package/dist/web/static/js/windowDrag.js +326 -0
  492. package/dist/web/static/js/windowResize.js +233 -0
  493. package/dist/web/static/landing.html +824 -0
  494. package/dist/web/static/lib/docx.umd.min.js +20116 -0
  495. package/dist/web/static/lib/highlight.min.js +1213 -0
  496. package/dist/web/static/lib/html2pdf.bundle.min.js +3 -0
  497. package/dist/web/static/lib/mammoth.browser.min.js +21 -0
  498. package/dist/web/static/lib/qrcode.min.js +8 -0
  499. package/dist/web/static/lib/xlsx.full.min.js +24 -0
  500. package/dist/web/static/login.html +582 -0
  501. package/dist/web/static/manifest.json +15 -0
  502. package/dist/web/static/style.css +35733 -0
  503. package/dist/web/static/sw.js +144 -0
  504. package/dist/workflows/engine.d.ts +162 -0
  505. package/dist/workflows/engine.d.ts.map +1 -0
  506. package/dist/workflows/engine.js +625 -0
  507. package/dist/workflows/engine.js.map +1 -0
  508. package/dist/workflows/index.d.ts +8 -0
  509. package/dist/workflows/index.d.ts.map +1 -0
  510. package/dist/workflows/index.js +14 -0
  511. package/dist/workflows/index.js.map +1 -0
  512. package/package.json +112 -0
@@ -0,0 +1,4121 @@
1
+ // ============================================
2
+ // Zero UI — Main Application Orchestrator
3
+ // ES6 module — entry point, no exports (wires all modules together)
4
+ // ============================================
5
+ import Storage from './js/storage.js';
6
+ import uiModule from './js/ui.js';
7
+ import fileHandlerModule from './js/fileHandler.js';
8
+ import modelsModule from './js/models.js';
9
+ import ragModule from './js/rag.js';
10
+ import presetsModule from './js/presets.js';
11
+ import searchModule from './js/search.js';
12
+ import chatModule from './js/chat.js';
13
+ import compareModule from './js/compare/index.js';
14
+ import documentModule from './js/document.js';
15
+ import searchChatModule from './js/search-chat.js';
16
+ import markdownModule from './js/markdown.js';
17
+ import chatRenderer from './js/chatRenderer.js';
18
+ import sessionModule from './js/sessions.js';
19
+ import memoryModule from './js/memory.js';
20
+ import voiceRecorderModule from './js/voiceRecorder.js';
21
+ import censorModule from './js/censor.js';
22
+ import galleryModule from './js/gallery.js';
23
+ import tasksModule from './js/tasks.js';
24
+ import calendarModule from './js/calendar.js';
25
+ import notesModule from './js/notes.js';
26
+ import adminModule from './js/admin.js';
27
+ import settingsModule from './js/settings.js';
28
+ // Eagerly bind unified minimize/restore behavior across all tool modals.
29
+ import './js/modalManager.js';
30
+ // Desktop window tiling — drag a modal near an edge/corner to snap.
31
+ import './js/tileManager.js';
32
+ import themeModule from './js/theme.js';
33
+ // IMPORTANT: import cookbook.js with NO ?v= query — the same plain specifier
34
+ // every other importer (cookbook-hwfit.js / cookbook-diagnosis.js) uses. A query
35
+ // mismatch makes the browser load cookbook.js twice as separate modules (two
36
+ // _envState objects), which broke server selection. Keep all cookbook imports
37
+ // unversioned so this can't recur.
38
+ import cookbookModule from './js/cookbook.js';
39
+ import groupModule from './js/group.js';
40
+ import * as researchPanelModule from './js/research/panel.js';
41
+ import ttsModule from './js/tts-ai.js';
42
+ import spinnerModule from './js/spinner.js';
43
+ import { initKeyboardShortcuts } from './js/keyboard-shortcuts.js';
44
+ import { initSidebarLayout, syncRailSide } from './js/sidebar-layout.js';
45
+ import { initSectionCollapse, initSectionDrag } from './js/section-management.js';
46
+
47
+ const API_BASE = window.location.origin;
48
+ window.themeModule = themeModule;
49
+ window.sessionModule = sessionModule;
50
+ window.uiModule = uiModule;
51
+ window.adminModule = adminModule;
52
+ window.cookbookModule = cookbookModule;
53
+
54
+ // Redirect to login on 401 from any fetch
55
+ const _origFetch = window.fetch;
56
+ window.fetch = async function(...args) {
57
+ const res = await _origFetch.apply(this, args);
58
+ if (res.status === 401 && !String(args[0]).includes('/api/auth/')) {
59
+ window.location.href = '/login';
60
+ }
61
+ return res;
62
+ };
63
+
64
+ // Search settings
65
+
66
+
67
+ const el = uiModule.el;
68
+
69
+ // Default chat config — refreshed on every new-chat action so settings
70
+ // changes take effect immediately (previously cached once at page load and
71
+ // went stale when the user changed their default model).
72
+ let _defaultChat = null;
73
+ async function _refreshDefaultChat() {
74
+ try {
75
+ const d = await (await fetch('/api/default-chat')).json();
76
+ if (d && d.endpoint_url && d.model) {
77
+ _defaultChat = d;
78
+ try { window.__zeroDefaultChat = d; } catch (_) {}
79
+ return d;
80
+ }
81
+ } catch (_) {}
82
+ return null;
83
+ }
84
+ // Prime the cache once at load for initial paint paths that read _defaultChat
85
+ // synchronously; later reads should call _refreshDefaultChat() first.
86
+ _refreshDefaultChat();
87
+
88
+ async function _createDirectChatFromPreferredModel() {
89
+ if (!sessionModule) return false;
90
+
91
+ const pending = sessionModule.getPendingChat && sessionModule.getPendingChat();
92
+ if (pending && pending.url && pending.modelId) {
93
+ sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId);
94
+ return true;
95
+ }
96
+
97
+ const sessions = sessionModule.getSessions();
98
+ const currentId = sessionModule.getCurrentSessionId();
99
+ const current = sessions.find(s => s.id === currentId);
100
+ if (current && current.endpoint_url && current.model) {
101
+ sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id);
102
+ return true;
103
+ }
104
+
105
+ const dc = await _refreshDefaultChat();
106
+ if (dc) {
107
+ sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id);
108
+ return true;
109
+ }
110
+
111
+ const withModel = sessions.filter(s => s.endpoint_url && s.model);
112
+ if (withModel.length > 0) {
113
+ const last = withModel[0]; // sessions are sorted by recent
114
+ sessionModule.createDirectChat(last.endpoint_url, last.model, last.endpoint_id);
115
+ return true;
116
+ }
117
+
118
+ return false;
119
+ }
120
+
121
+ // ============================================
122
+ // EVENT LISTENERS INITIALIZATION
123
+ // ============================================
124
+ function initializeEventListeners() {
125
+ // Chat form submission
126
+ // document.getElementById('chat-form').addEventListener('submit', chatModule.handleChatSubmit);
127
+
128
+ // File attachments (inside overflow menu)
129
+ const _overflowAttach = el('overflow-attach-btn');
130
+ if (_overflowAttach) _overflowAttach.addEventListener('click', fileHandlerModule.openPicker);
131
+ el('file-input').addEventListener('change', (e)=>{
132
+ for (const f of e.target.files) fileHandlerModule.addFiles([f]);
133
+ fileHandlerModule.renderAttachStrip();
134
+ // Refocus textarea after file picker closes (mobile keyboard)
135
+ const ta = el('message');
136
+ if (ta) setTimeout(() => ta.focus(), 100);
137
+ });
138
+
139
+ // Paste handler
140
+ window.addEventListener('paste', async (e)=>{
141
+ if (!e.clipboardData) return;
142
+ let changed = false;
143
+ for (const item of e.clipboardData.items){
144
+ if (item.kind === 'file'){
145
+ const f = item.getAsFile();
146
+ if (f) {
147
+ fileHandlerModule.addFiles([f]);
148
+ changed = true;
149
+ }
150
+ }
151
+ }
152
+ if (changed) fileHandlerModule.renderAttachStrip();
153
+ });
154
+
155
+ // Message count in the header — recount on any DOM change in
156
+ // #chat-history and write "· N msgs" next to the title. Counts top-
157
+ // level .msg elements (one per user/assistant turn); excludes the
158
+ // welcome screen since it isn't inside chat-history.
159
+ const _metaCountEl = el('current-meta-count');
160
+ const _chatHistEl = el('chat-history');
161
+ if (_metaCountEl && _chatHistEl) {
162
+ let _countScheduled = false;
163
+ const _updateMsgCount = () => {
164
+ _countScheduled = false;
165
+ const n = _chatHistEl.querySelectorAll(':scope > .msg').length;
166
+ _metaCountEl.textContent = n ? `· ${n} msg${n === 1 ? '' : 's'}` : '';
167
+ };
168
+ const _scheduleCount = () => {
169
+ if (_countScheduled) return;
170
+ _countScheduled = true;
171
+ requestAnimationFrame(_updateMsgCount);
172
+ };
173
+ new MutationObserver(_scheduleCount).observe(_chatHistEl, { childList: true });
174
+ _updateMsgCount();
175
+ }
176
+
177
+ // Scrolling
178
+ el('chat-history').addEventListener('scroll', uiModule.debounce(() => {
179
+ const box = el('chat-history');
180
+ const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 80;
181
+ uiModule.setAutoScroll(atBottom);
182
+ }, 100));
183
+ // Close all footer popups immediately on any scroll
184
+ el('chat-history').addEventListener('scroll', () => {
185
+ document.querySelectorAll('.ctx-popup, .memory-used-detail, .msg-overflow-menu').forEach(p => p.remove());
186
+ document.querySelectorAll('.memory-used-pill').forEach(p => { p._openDetail = null; });
187
+ }, { passive: true });
188
+
189
+ el('chat-history').addEventListener('wheel', (e) => {
190
+ // Only disable auto-scroll when user scrolls UP (deltaY < 0)
191
+ if (e.deltaY < 0) uiModule.setAutoScroll(false);
192
+ });
193
+ let _touchThrottled = false;
194
+ el('chat-history').addEventListener('touchmove', () => {
195
+ if (_touchThrottled) return;
196
+ _touchThrottled = true;
197
+ uiModule.setAutoScroll(false);
198
+ requestAnimationFrame(() => { _touchThrottled = false; });
199
+ }, { passive: true });
200
+
201
+ // Internal #session-id links from AI search results
202
+ el('chat-history').addEventListener('click', (e) => {
203
+ const link = e.target.closest('a.chat-link');
204
+ if (!link) return;
205
+ const href = link.getAttribute('href');
206
+ if (href && href.startsWith('#') && sessionModule) {
207
+ e.preventDefault();
208
+ sessionModule.selectSession(href.slice(1));
209
+ }
210
+ });
211
+
212
+ // Export dropdown button
213
+ const exportDlBtn = el('export-dl-btn');
214
+ // ── Unified popup dismissal ──
215
+ // Lightweight popups (header dropdowns, kebab menus, pickers) should vanish
216
+ // on any "other action" — opening the sidebar, opening a tool window, etc.
217
+ // Each popup used to wire its own outside-click/Escape close but missed
218
+ // non-click actions. closeAllPopups() centralizes it: toggled menus drop
219
+ // their `.open`; ephemeral body-appended menus are removed. Full modals/
220
+ // windows are deliberately NOT touched here — those close via their own
221
+ // controls.
222
+ window.closeAllPopups = function closeAllPopups(except) {
223
+ document.querySelectorAll(
224
+ '.export-dropdown-menu.open, .overflow-menu.open, .model-picker-menu.open, .doc-overflow-menu.open'
225
+ ).forEach(m => { if (m !== except) m.classList.remove('open'); });
226
+ document.querySelectorAll(
227
+ '.skill-kebab-menu, .note-reminder-menu, .task-dropdown, .doclib-card-dropdown, .email-card-dropdown, .msg-overflow-menu'
228
+ ).forEach(m => { if (m !== except) m.remove(); });
229
+ };
230
+ // Window-opening / nav controls (rail buttons, sidebar tool rows + session
231
+ // rows, section headers) count as "other actions" — dismiss popups when one
232
+ // is clicked. Bubble phase so it runs after the control's own handler (the
233
+ // window is already opening; we just clear stray popups). Popup triggers
234
+ // themselves aren't these selectors, so toggles aren't broken.
235
+ document.addEventListener('click', (e) => {
236
+ if (e.target.closest('.icon-rail-btn, #sidebar .list-item, .section-header-flex')) {
237
+ window.closeAllPopups();
238
+ }
239
+ });
240
+
241
+ const exportMenu = el('export-dropdown-menu');
242
+ if (exportDlBtn && exportMenu) {
243
+ exportDlBtn.addEventListener('click', (e) => {
244
+ e.stopPropagation();
245
+ if (exportMenu.classList.contains('open')) {
246
+ exportMenu.classList.remove('open');
247
+ } else {
248
+ // Move menu to body so it's not affected by ancestor transforms
249
+ if (exportMenu.parentElement !== document.body) document.body.appendChild(exportMenu);
250
+ const rect = exportDlBtn.getBoundingClientRect();
251
+ exportMenu.style.top = (rect.bottom + 4) + 'px';
252
+ exportMenu.style.left = 'auto';
253
+ exportMenu.style.right = (window.innerWidth - rect.right) + 'px';
254
+ exportMenu.classList.add('open');
255
+ }
256
+ });
257
+ document.addEventListener('click', () => exportMenu.classList.remove('open'));
258
+ document.addEventListener('keydown', (e) => {
259
+ if (e.key === 'Escape' && exportMenu.classList.contains('open')) {
260
+ exportMenu.classList.remove('open');
261
+ }
262
+ });
263
+ // Opening the sidebar should dismiss any open popup. Many code paths open
264
+ // the sidebar (toggle button, swipe, keyboard, rail), so watch its class
265
+ // for a hidden→visible transition rather than hooking each one.
266
+ const _sidebarEl = el('sidebar');
267
+ if (_sidebarEl) {
268
+ let _wasHidden = _sidebarEl.classList.contains('hidden');
269
+ new MutationObserver(() => {
270
+ const nowHidden = _sidebarEl.classList.contains('hidden');
271
+ if (_wasHidden && !nowHidden) window.closeAllPopups();
272
+ _wasHidden = nowHidden;
273
+ }).observe(_sidebarEl, { attributes: true, attributeFilter: ['class'] });
274
+ }
275
+ // Clicking session name also opens dropdown
276
+ const currentMeta = el('current-meta');
277
+ if (currentMeta) {
278
+ currentMeta.style.cursor = 'pointer';
279
+ currentMeta.addEventListener('click', (e) => {
280
+ e.stopPropagation();
281
+ exportDlBtn.click();
282
+ });
283
+ }
284
+ }
285
+
286
+ // Serialize the current chat history into a plain-text transcript.
287
+ // Includes user messages, assistant rounds, and agent tool calls in DOM order.
288
+ function _serializeChatTranscript() {
289
+ const box = document.getElementById('chat-history');
290
+ if (!box) return '';
291
+ const parts = [];
292
+ for (const child of box.children) {
293
+ if (child.classList?.contains('msg')) {
294
+ const isUser = child.classList.contains('msg-user');
295
+ let label;
296
+ if (isUser) {
297
+ label = 'User';
298
+ } else {
299
+ const roleEl = child.querySelector('.role');
300
+ const ts = roleEl?.querySelector('.role-timestamp');
301
+ let raw = roleEl ? roleEl.textContent : '';
302
+ if (ts) raw = raw.replace(ts.textContent, '');
303
+ label = (raw || '').trim() || 'Assistant';
304
+ }
305
+ const body = child.querySelector('.body');
306
+ // Prefer dataset.raw (original markdown) over innerText (rendered HTML as text)
307
+ // to avoid extra newlines and formatting artifacts.
308
+ const text = body ? (body.dataset.raw || body.innerText || body.textContent || '').trim() : '';
309
+ if (text) parts.push(`${label}: ${text}`);
310
+ } else if (child.classList?.contains('agent-thread')) {
311
+ const lines = ['[Tool calls]'];
312
+ for (const n of child.querySelectorAll('.agent-thread-node')) {
313
+ const tool = n.querySelector('.agent-thread-tool')?.textContent?.trim() || 'tool';
314
+ const cmd = n.querySelector('.agent-thread-cmd')?.textContent?.trim() || '';
315
+ const output = n.querySelector('.agent-tool-output pre')?.textContent?.trim() || '';
316
+ const status = n.classList.contains('error') ? 'failed' : 'done';
317
+ let line = `- ${tool} [${status}]`;
318
+ if (cmd) line += `\n cmd: ${cmd}`;
319
+ if (output) {
320
+ const truncated = output.length > 2000 ? output.slice(0, 2000) + '…' : output;
321
+ line += `\n out: ${truncated}`;
322
+ }
323
+ lines.push(line);
324
+ }
325
+ parts.push(lines.join('\n'));
326
+ }
327
+ }
328
+ return parts.join('\n\n');
329
+ }
330
+
331
+ // Export: Copy all messages
332
+ const exportCopyBtn = el('export-copy-btn');
333
+ if (exportCopyBtn) {
334
+ exportCopyBtn.addEventListener('click', async (e) => {
335
+ e.stopPropagation();
336
+ exportMenu.classList.remove('open');
337
+ const transcript = _serializeChatTranscript();
338
+ // A new/empty chat has nothing to copy — don't write an empty string and
339
+ // falsely report "Copied".
340
+ if (!transcript.trim()) { uiModule.showToast('Nothing to copy yet'); return; }
341
+ await uiModule.copyToClipboard(transcript);
342
+ });
343
+ }
344
+
345
+ // Export: PDF
346
+ const exportPdfBtn = el('export-pdf-btn');
347
+ if (exportPdfBtn) {
348
+ exportPdfBtn.addEventListener('click', (e) => {
349
+ e.stopPropagation();
350
+ exportMenu.classList.remove('open');
351
+ const meta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
352
+ const sessionName = meta ? meta.name : 'Zero Chat';
353
+ const originalTitle = document.title;
354
+ document.title = sessionName;
355
+ const chatHistory = document.getElementById('chat-history');
356
+ if (chatHistory) chatHistory.dataset.printTitle = sessionName;
357
+ document.querySelectorAll('#chat-history details:not([open])').forEach(d => {
358
+ d.setAttribute('open', '');
359
+ d.dataset.printOpened = '1';
360
+ });
361
+ window.print();
362
+ document.title = originalTitle;
363
+ document.querySelectorAll('#chat-history details[data-print-opened]').forEach(d => {
364
+ d.removeAttribute('open');
365
+ d.removeAttribute('data-print-opened');
366
+ });
367
+ });
368
+ }
369
+
370
+ // Export: Save to Docs
371
+ const exportDocBtn = el('export-doc-btn');
372
+ if (exportDocBtn) {
373
+ exportDocBtn.addEventListener('click', async (e) => {
374
+ e.stopPropagation();
375
+ exportMenu.classList.remove('open');
376
+ try {
377
+ const sessionId = sessionModule.getCurrentSessionId();
378
+ const texts = _serializeChatTranscript();
379
+ const meta = sessionModule.getSessions().find(s => s.id === sessionId);
380
+ const title = meta?.name || 'Untitled';
381
+ const res = await fetch(`${API_BASE}/api/document`, {
382
+ method: 'POST',
383
+ headers: { 'Content-Type': 'application/json' },
384
+ body: JSON.stringify({ session_id: sessionId, title, content: texts }),
385
+ });
386
+ if (!res.ok) throw new Error('Failed');
387
+ const doc = await res.json();
388
+ if (documentModule) documentModule.loadDocument(doc.id);
389
+ uiModule.showToast('Saved to documents');
390
+ } catch (err) {
391
+ console.error('Save to docs failed:', err);
392
+ uiModule.showError('Failed to save to documents');
393
+ }
394
+ });
395
+ }
396
+
397
+ // Rename session from top bar
398
+ const exportRenameBtn = el('export-rename-btn');
399
+ if (exportRenameBtn) {
400
+ exportRenameBtn.addEventListener('click', (e) => {
401
+ e.stopPropagation();
402
+ exportMenu.classList.remove('open');
403
+ let sid = sessionModule.getCurrentSessionId();
404
+ // A brand-new chat has no session id yet — still allow renaming if there's
405
+ // a pending chat (we materialize it on commit so the name sticks).
406
+ const hasPending = sessionModule.hasPendingChat && sessionModule.hasPendingChat();
407
+ if (!sid && !hasPending) return;
408
+ const meta = sid ? sessionModule.getSessions().find(s => s.id === sid) : null;
409
+ const currentName = meta?.name || '';
410
+ const metaEl = el('current-meta');
411
+ if (!metaEl) return;
412
+
413
+ // Replace title with an input
414
+ const input = document.createElement('input');
415
+ input.type = 'text';
416
+ input.value = currentName;
417
+ input.className = 'session-rename-input';
418
+ input.style.cssText = 'font-size:inherit;background:transparent;border:none;border-bottom:1px solid var(--accent, var(--red));color:var(--fg);outline:none;width:100%;padding:0;';
419
+ const origText = metaEl.textContent;
420
+ metaEl.textContent = '';
421
+ metaEl.appendChild(input);
422
+ input.focus();
423
+ input.select();
424
+
425
+ const commit = async () => {
426
+ const newName = input.value.trim();
427
+ if (newName && newName !== currentName) {
428
+ // Materialize a pending (new) chat first so it has an id to rename.
429
+ if (!sid && sessionModule.materializePendingSession) {
430
+ try { await sessionModule.materializePendingSession(); sid = sessionModule.getCurrentSessionId(); } catch (_) {}
431
+ }
432
+ if (!sid) { metaEl.textContent = newName; return; }
433
+ const fd = new FormData();
434
+ fd.append('name', newName);
435
+ await fetch(`${API_BASE}/api/session/${sid}`, { method: 'PATCH', body: fd });
436
+ const _m = sessionModule.getSessions().find(s => s.id === sid);
437
+ if (_m) _m.name = newName;
438
+ metaEl.textContent = newName;
439
+ uiModule.showToast('Renamed');
440
+ sessionModule.loadSessions();
441
+ } else {
442
+ metaEl.textContent = origText;
443
+ }
444
+ };
445
+ input.addEventListener('blur', commit);
446
+ input.addEventListener('keydown', (ev) => {
447
+ if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); }
448
+ if (ev.key === 'Escape') { input.removeEventListener('blur', commit); metaEl.textContent = origText; }
449
+ });
450
+ });
451
+ }
452
+
453
+ // Custom preset modal handlers
454
+ const closeCustomPreset = el('close-custom-preset');
455
+ const cancelCustomPreset = el('cancel-custom-preset');
456
+ const saveCustomPreset = el('save-custom-preset');
457
+
458
+ if (closeCustomPreset) {
459
+ closeCustomPreset.addEventListener('click', () => {
460
+ el('custom-preset-modal').classList.add('hidden');
461
+ });
462
+ }
463
+
464
+ if (cancelCustomPreset) {
465
+ cancelCustomPreset.addEventListener('click', () => {
466
+ el('custom-preset-modal').classList.add('hidden');
467
+ });
468
+ }
469
+
470
+ if (saveCustomPreset) {
471
+ saveCustomPreset.addEventListener('click', async () => {
472
+ // Skip character save when Group tab is active — group.js handles it
473
+ const activeTab = document.querySelector('.preset-tab.active');
474
+ if (activeTab && activeTab.dataset.chartab === 'group') return;
475
+ await presetsModule.saveCustomPreset(uiModule.showToast, uiModule.showError);
476
+ });
477
+ }
478
+
479
+ // Settings dropdown removed — items are now inline in sidebar section
480
+
481
+
482
+
483
+
484
+ // Close popups one by one with Escape key (topmost first)
485
+ document.addEventListener('keydown', (e) => {
486
+ if (e.key === 'Escape') {
487
+ // If a confirm dialog is open, let it handle the Escape
488
+ const confirmOverlay = document.getElementById('styled-confirm-overlay');
489
+ if (confirmOverlay && !confirmOverlay.classList.contains('hidden')) return;
490
+
491
+ // If editing a memory inline, cancel the edit instead of closing the modal
492
+ const editingMemory = document.querySelector('.memory-item-editing');
493
+ if (editingMemory) {
494
+ if (window.memoryModule) window.memoryModule.renderMemoryList();
495
+ return;
496
+ }
497
+
498
+ // Priority order: topmost overlay first. Close exactly one per press
499
+ // so a window stacked on another (e.g. scoreboard over compare) only
500
+ // dismisses the top one, not both.
501
+
502
+ // Scoreboard sits on top of the compare window — close it first.
503
+ const scoreboardOverlay = document.getElementById('scoreboard-overlay');
504
+ if (scoreboardOverlay) {
505
+ scoreboardOverlay.remove();
506
+ return;
507
+ }
508
+
509
+ if (searchChatModule && searchChatModule.isOpen()) {
510
+ searchChatModule.closeSearch();
511
+ return;
512
+ }
513
+
514
+ // Compare model selector
515
+ const cmpOverlay = document.getElementById('compare-model-overlay');
516
+ if (cmpOverlay) {
517
+ cmpOverlay.remove();
518
+ return;
519
+ }
520
+
521
+ // Theme popup
522
+ const themeModal = document.getElementById('theme-modal');
523
+ if (themeModal && !themeModal.classList.contains('hidden')) {
524
+ themeModule.closePopup();
525
+ return;
526
+ }
527
+
528
+ // Calendar owns a few inner Escape layers (settings panel, event form,
529
+ // then the calendar modal itself). Let calendar.js handle those instead
530
+ // of falling through to unrelated page-level fallbacks like document
531
+ // panel minimize.
532
+ const calendarModal = document.getElementById('calendar-modal');
533
+ if (calendarModal && !calendarModal.classList.contains('hidden') && getComputedStyle(calendarModal).display !== 'none') {
534
+ return;
535
+ }
536
+
537
+ // Model picker popup — close before opening any modals
538
+ const modelPickerMenu = document.getElementById('model-picker-menu');
539
+ if (modelPickerMenu && modelPickerMenu.classList.contains('open')) {
540
+ modelPickerMenu.classList.remove('open');
541
+ return;
542
+ }
543
+
544
+ // Close one modal at a time (last in DOM = topmost)
545
+ // Map modal id → sidebar list-item id to clear active state
546
+ const modalItemMap = {
547
+ 'cookbook-modal': null,
548
+ 'rename-session-modal': null,
549
+ 'rename-ai-modal': null,
550
+ 'custom-preset-modal': null,
551
+ 'memory-modal': null,
552
+ };
553
+
554
+ // Dynamic modals (removed from DOM on close)
555
+ const dynamicModals = ['library-modal', 'archive-modal', 'doclib-modal', 'gallery-modal', 'tasks-modal', 'email-lib-modal'];
556
+ for (const id of dynamicModals) {
557
+ const m = document.getElementById(id);
558
+ if (id === 'gallery-modal') {
559
+ const editor = document.getElementById('gallery-editor-container');
560
+ const editing = !!window.__galleryEditLive || !!(
561
+ editor &&
562
+ getComputedStyle(editor).display !== 'none' &&
563
+ editor.querySelector('.gallery-editor')
564
+ );
565
+ if (editing) {
566
+ e.preventDefault();
567
+ e.stopImmediatePropagation();
568
+ return;
569
+ }
570
+ }
571
+ if (m) { dismissModal(m); return; }
572
+ }
573
+
574
+ for (const modalId of Object.keys(modalItemMap)) {
575
+ const modal = el(modalId);
576
+ if (modal && !modal.classList.contains('hidden')) {
577
+ dismissModal(modal);
578
+ return;
579
+ }
580
+ }
581
+
582
+ // No modals/popups open — minimize the document panel if open.
583
+ // Esc should tab the doc down to a dock chip (same as the chevron),
584
+ // NOT fully close it — closePanel('down') registers the chip +
585
+ // Modals.minimize so the doc is preserved and restorable.
586
+ if (documentModule && documentModule.isPanelOpen()) {
587
+ // If there's a text selection in the document editor, let Escape clear that first
588
+ const docTextarea = document.getElementById('doc-editor-textarea');
589
+ if (docTextarea && docTextarea.selectionStart !== docTextarea.selectionEnd) {
590
+ return;
591
+ }
592
+ documentModule.closePanel('down');
593
+ return;
594
+ }
595
+ }
596
+ });
597
+
598
+ // ── Shared modal dismiss helper ──
599
+ const _modalSidebarMap = {
600
+ 'memory-modal': null,
601
+ 'theme-modal': null,
602
+ };
603
+ const _dynamicModalIds = ['library-modal', 'archive-modal', 'doclib-modal', 'gallery-modal', 'tasks-modal'];
604
+ function dismissModal(modal) {
605
+ if (!modal || modal.classList.contains('hidden')) return;
606
+ if (modal.id === 'gallery-modal') {
607
+ const editor = document.getElementById('gallery-editor-container');
608
+ const editing = !!window.__galleryEditLive || !!(
609
+ editor &&
610
+ getComputedStyle(editor).display !== 'none' &&
611
+ editor.querySelector('.gallery-editor')
612
+ );
613
+ if (editing) return;
614
+ }
615
+ const content = modal.querySelector('.modal-content') || modal.querySelector('#theme-popup');
616
+ if (content && !content.classList.contains('modal-closing')) {
617
+ content.classList.remove('sheet-ready');
618
+ content.style.transform = '';
619
+ content.style.transition = '';
620
+ content.classList.add('modal-closing');
621
+ content.addEventListener('animationend', () => {
622
+ if (_dynamicModalIds.includes(modal.id)) {
623
+ modal.remove();
624
+ } else {
625
+ modal.classList.add('hidden');
626
+ content.classList.remove('modal-closing');
627
+ }
628
+ }, { once: true });
629
+ // Fallback in case animationend doesn't fire
630
+ setTimeout(() => {
631
+ if (modal.parentElement && !modal.classList.contains('hidden')) {
632
+ if (_dynamicModalIds.includes(modal.id)) modal.remove();
633
+ else { modal.classList.add('hidden'); content.classList.remove('modal-closing'); }
634
+ }
635
+ }, 250);
636
+ } else {
637
+ if (content) content.classList.remove('sheet-ready');
638
+ if (_dynamicModalIds.includes(modal.id)) modal.remove();
639
+ else modal.classList.add('hidden');
640
+ }
641
+ }
642
+
643
+ // Click outside modal content → close modal
644
+ document.addEventListener('click', (e) => {
645
+ if (uiModule.isTouchInsideModal()) return; // suppress synthetic events from touch scrolling
646
+ const modal = e.target.closest('.modal');
647
+ if (!modal || modal.classList.contains('hidden')) return;
648
+ if (e.target.closest('.modal-content')) return;
649
+ dismissModal(modal);
650
+ });
651
+
652
+ // Mobile bottom-sheet swipe-to-dismiss is handled by ui.js (header-only)
653
+
654
+ // ── Helper: start a fresh chat (deselect current, clear history, show welcome) ──
655
+ function _startFreshChat() {
656
+ try {
657
+ const prevId = sessionModule && sessionModule.getCurrentSessionId ? sessionModule.getCurrentSessionId() : null;
658
+ if (chatModule && chatModule.detachCurrentStream) chatModule.detachCurrentStream(prevId);
659
+ else if (chatModule && chatModule.abortCurrentRequest) chatModule.abortCurrentRequest();
660
+ } catch (e) {
661
+ console.warn('fresh chat stream detach failed:', e);
662
+ }
663
+ if (sessionModule) sessionModule.setCurrentSessionId(null);
664
+ const box = el('chat-history');
665
+ if (box) box.innerHTML = '';
666
+ if (chatModule && chatModule.showWelcomeScreen) {
667
+ chatModule.showWelcomeScreen();
668
+ }
669
+ // Close document panel if open
670
+ if (documentModule && documentModule.closePanel) documentModule.closePanel();
671
+ if (researchPanelModule && researchPanelModule.isOpen()) researchPanelModule.closePanel();
672
+ // Reset research overflow dot (but don't touch research state — caller manages that)
673
+ const _overflowRes = el('overflow-research-btn');
674
+ if (_overflowRes) _overflowRes.classList.remove('active');
675
+ if (typeof updatePlusDot === 'function') updatePlusDot();
676
+ // Reset agent mode to Chat
677
+ const modeToggle = el('agent-mode-toggle');
678
+ if (modeToggle && modeToggle.checked) { modeToggle.checked = false; modeToggle.dispatchEvent(new Event('change')); }
679
+ // Clear character/persona
680
+ if (presetsModule && presetsModule.deactivateCharacter) presetsModule.deactivateCharacter();
681
+ }
682
+
683
+ /** Sync Research indicator button + overflow + tool sidebar active state. */
684
+ function _syncResearchIndicator(active) {
685
+ const btn = el('research-toggle-btn');
686
+ const overflow = el('overflow-research-btn');
687
+ const toolBtn = el('tool-research-btn');
688
+ const chk = el('research-toggle');
689
+ if (btn) {
690
+ btn.style.display = active ? '' : 'none';
691
+ btn.classList.toggle('active', active);
692
+ }
693
+ // Hide from overflow menu when showing in chatbox (avoid duplicate)
694
+ if (overflow) {
695
+ overflow.classList.toggle('active', active);
696
+ overflow.style.display = active ? 'none' : '';
697
+ }
698
+ if (toolBtn) toolBtn.classList.toggle('active', active);
699
+ if (chk) chk.checked = active;
700
+ // Research disables shell access
701
+ const bashChk = el('bash-toggle');
702
+ const bashBtn = el('bash-toggle-btn');
703
+ if (active) {
704
+ if (bashChk && bashChk.checked) {
705
+ bashChk.checked = false;
706
+ if (bashBtn) bashBtn.classList.remove('active');
707
+ saveToolPref('bash', (loadToggleState().mode || 'chat'), false);
708
+ }
709
+ }
710
+ const s = loadToggleState(); s.research = active; saveToggleState(s);
711
+ updatePlusDot();
712
+ document.dispatchEvent(new CustomEvent('overflow-state-change'));
713
+ }
714
+
715
+ /** Sync Group Chat indicator button + overflow. */
716
+ function _syncGroupIndicator(active) {
717
+ const btn = el('group-toggle-btn');
718
+ const overflow = el('overflow-group-btn');
719
+ const chk = el('group-toggle');
720
+ if (btn) {
721
+ btn.style.display = active ? '' : 'none';
722
+ btn.classList.toggle('active', active);
723
+ }
724
+ if (overflow) {
725
+ overflow.classList.toggle('active', active);
726
+ overflow.style.display = active ? 'none' : '';
727
+ }
728
+ if (chk) chk.checked = active;
729
+ // Hide/show model picker
730
+ const _mpw = el('model-picker-wrap');
731
+ if (_mpw) _mpw.style.display = active ? 'none' : '';
732
+ // Mutual exclusion: group disables research + web search
733
+ if (active) {
734
+ _syncResearchIndicator(false);
735
+ const _webChk = el('web-toggle');
736
+ if (_webChk && _webChk.checked) {
737
+ _webChk.checked = false;
738
+ saveToolPref('web', (loadToggleState().mode || 'chat'), false);
739
+ }
740
+ }
741
+ const s = loadToggleState(); s.group = active; saveToggleState(s);
742
+ updatePlusDot();
743
+ document.dispatchEvent(new CustomEvent('overflow-state-change'));
744
+
745
+ // Update welcome screen for research mode
746
+ const ws = el('welcome-screen');
747
+ const welcomeName = document.querySelector('.welcome-name');
748
+ const welcomeSub = el('welcome-sub');
749
+ const tipEl = el('welcome-tip');
750
+ const _resIco = '<svg class="welcome-boat" style="position:relative;top:0.5px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>';
751
+ if (active) {
752
+ if (welcomeName) {
753
+ if (!welcomeName.dataset.researchOrigHtml) welcomeName.dataset.researchOrigHtml = welcomeName.innerHTML;
754
+ welcomeName.innerHTML = _resIco + 'Deep Research';
755
+ }
756
+ if (welcomeSub) {
757
+ if (!welcomeSub.dataset.researchOrigText) welcomeSub.dataset.researchOrigText = welcomeSub.textContent;
758
+ welcomeSub.textContent = 'Deep multi-step research with source gathering and synthesis.';
759
+ }
760
+ if (tipEl) {
761
+ if (!tipEl.dataset.researchOrigTip) tipEl.dataset.researchOrigTip = tipEl.textContent;
762
+ tipEl.textContent = '';
763
+ tipEl.style.display = 'none';
764
+ }
765
+ // Hide Nobody toggle during research mode
766
+ const _incBtn = el('incognito-btn');
767
+ if (_incBtn) { _incBtn.dataset.researchOrigDisplay = _incBtn.style.display; _incBtn.style.display = 'none'; }
768
+ // Close document panel if open
769
+ if (window.documentModule && window.documentModule.isPanelOpen()) {
770
+ window.documentModule.closePanel();
771
+ }
772
+ } else {
773
+ if (welcomeName && welcomeName.dataset.researchOrigHtml) {
774
+ welcomeName.innerHTML = welcomeName.dataset.researchOrigHtml;
775
+ delete welcomeName.dataset.researchOrigHtml;
776
+ }
777
+ if (welcomeSub && welcomeSub.dataset.researchOrigText) {
778
+ welcomeSub.textContent = welcomeSub.dataset.researchOrigText;
779
+ delete welcomeSub.dataset.researchOrigText;
780
+ }
781
+ if (tipEl && tipEl.dataset.researchOrigTip) {
782
+ tipEl.textContent = tipEl.dataset.researchOrigTip;
783
+ tipEl.style.opacity = '';
784
+ tipEl.style.display = '';
785
+ delete tipEl.dataset.researchOrigTip;
786
+ }
787
+ // Restore Nobody toggle
788
+ const _incBtn2 = el('incognito-btn');
789
+ if (_incBtn2 && _incBtn2.dataset.researchOrigDisplay !== undefined) {
790
+ _incBtn2.style.display = _incBtn2.dataset.researchOrigDisplay;
791
+ delete _incBtn2.dataset.researchOrigDisplay;
792
+ }
793
+ }
794
+ if (ws) { ws.style.animation = 'none'; ws.offsetHeight; ws.style.animation = 'welcome-enter 0.3s ease-out both'; }
795
+ }
796
+
797
+ // ── Close compare if active (used by all tool/sidebar activations) ──
798
+ // Returns true if compare was active (page will reload), caller should return early
799
+ function _closeCompareIfActive() {
800
+ if (compareModule && compareModule.isActive()) {
801
+ compareModule.deactivate(true);
802
+ return true;
803
+ }
804
+ return false;
805
+ }
806
+
807
+ // ── Tools section click handlers ──
808
+ const toolCompareBtn = el('tool-compare-btn');
809
+ if (toolCompareBtn) {
810
+ toolCompareBtn.addEventListener('click', () => {
811
+ if (compareModule) {
812
+ if (compareModule.isActive()) {
813
+ // Already active — toggle off
814
+ compareModule.toggleMode();
815
+ return;
816
+ }
817
+ // Close other exclusive tools before opening compare
818
+ const resChk = el('research-toggle');
819
+ if (resChk && resChk.checked) {
820
+ _syncResearchIndicator(false);
821
+ }
822
+ _startFreshChat();
823
+ compareModule.toggleMode();
824
+ }
825
+ });
826
+ }
827
+
828
+ const toolResearchBtn = el('tool-research-btn');
829
+ if (toolResearchBtn) {
830
+ toolResearchBtn.addEventListener('click', () => {
831
+ researchPanelModule.toggle();
832
+ });
833
+ }
834
+
835
+ // ── Cookbook modal toggle ──
836
+ const toolCookbookBtn = el('tool-cookbook-btn');
837
+ if (toolCookbookBtn) {
838
+ toolCookbookBtn.addEventListener('click', async () => {
839
+ if (!cookbookModule) return;
840
+ // Try minimized→restore or open→minimize via the manager first
841
+ const Modals = await import('./js/modalManager.js');
842
+ if (!Modals.toggle('cookbook-modal')) {
843
+ // Not registered yet → fresh open
844
+ cookbookModule.open();
845
+ }
846
+ });
847
+ }
848
+
849
+ // Document library tool button
850
+ const toolDoclibBtn = el('tool-doclib-btn');
851
+ if (toolDoclibBtn) {
852
+ toolDoclibBtn.addEventListener('click', () => {
853
+ if (_closeCompareIfActive()) return;
854
+ if (documentModule) {
855
+ if (documentModule.isLibraryOpen()) {
856
+ documentModule.closeLibrary();
857
+ } else {
858
+ documentModule.openLibrary();
859
+ }
860
+ }
861
+ });
862
+ }
863
+
864
+ // Gallery tool button
865
+ const toolGalleryBtn = el('tool-gallery-btn');
866
+ if (toolGalleryBtn) {
867
+ toolGalleryBtn.addEventListener('click', async () => {
868
+ if (!galleryModule) return;
869
+ const Modals = await import('./js/modalManager.js');
870
+ if (!Modals.toggle('gallery-modal')) {
871
+ if (galleryModule.isGalleryOpen()) galleryModule.closeGallery();
872
+ else galleryModule.openGallery();
873
+ }
874
+ });
875
+ }
876
+
877
+ // Tasks tool button
878
+ const toolTasksBtn = el('tool-tasks-btn');
879
+ if (toolTasksBtn) {
880
+ // Agents buttons (sidebar + rail)
881
+ const agentsBtns = [el("rail-agents"), el("tool-agents-btn")].filter(Boolean);
882
+ agentsBtns.forEach(btn => {
883
+ btn.addEventListener("click", () => {
884
+ });
885
+ });
886
+ toolTasksBtn.addEventListener('click', () => {
887
+ if (tasksModule) {
888
+ tasksModule.isTasksOpen() ? tasksModule.closeTasks() : tasksModule.openTasks();
889
+ }
890
+ });
891
+ }
892
+
893
+ // Calendar tool button
894
+ const toolCalendarBtn = el('tool-calendar-btn');
895
+ if (toolCalendarBtn) {
896
+ toolCalendarBtn.addEventListener('click', async () => {
897
+ if (!calendarModule) return;
898
+ const Modals = await import('./js/modalManager.js');
899
+ // toggle returns true when a registered modal was minimized/restored;
900
+ // returns false when nothing is registered → open fresh.
901
+ if (!Modals.toggle('calendar-modal')) {
902
+ if (calendarModule.isCalendarOpen()) calendarModule.closeCalendar();
903
+ else calendarModule.openCalendar();
904
+ }
905
+ });
906
+ }
907
+
908
+ // Notes tool button
909
+ const toolNotesBtn = el('tool-notes-btn');
910
+ if (toolNotesBtn) {
911
+ toolNotesBtn.addEventListener('click', () => {
912
+ if (notesModule) {
913
+ notesModule.togglePanel();
914
+ }
915
+ });
916
+ }
917
+ // Refresh notes due-reminder badge on load and every 5 minutes
918
+ if (notesModule && notesModule.refreshDueBadge) {
919
+ notesModule.refreshDueBadge();
920
+ setInterval(() => notesModule.refreshDueBadge(), 5 * 60 * 1000);
921
+ }
922
+
923
+ // URL-based panel routing — bookmark /calendar, /notes, /cookbook etc
924
+ // and the matching tool opens automatically on page load.
925
+ const urlPath = window.location.pathname;
926
+ // Current width of the always-visible icon rail. The rail is resizable
927
+ // and hides on narrow viewports, so read it live each call rather than
928
+ // baking 48px in. Returns 0 when the rail isn't rendered.
929
+ const _iconRailWidth = () => {
930
+ const r = document.getElementById('icon-rail');
931
+ if (!r) return 0;
932
+ const cs = window.getComputedStyle(r);
933
+ if (cs.display === 'none' || cs.visibility === 'hidden') return 0;
934
+ return Math.round(r.getBoundingClientRect().width);
935
+ };
936
+ // Collapse the wide sidebar so the icon rail (48px mini sidebar) shows
937
+ // in its place. The two are mutually exclusive — sidebar-layout.js:57
938
+ // only displays the rail when `.sidebar.hidden` is set. Used by /email
939
+ // and /notes route openers so those fullscreen views keep the rail
940
+ // visible as the user's navigation strip. Records the prior state on
941
+ // body so a paired close-handler can restore it without overriding a
942
+ // manual toggle the user did in between.
943
+ const _collapseSidebarToRail = () => {
944
+ const sb = document.getElementById('sidebar');
945
+ const rail = document.getElementById('icon-rail');
946
+ if (!sb || !rail) return;
947
+ const wasVisible = !sb.classList.contains('hidden');
948
+ if (wasVisible) {
949
+ document.body.dataset.routeCollapsedSidebar = '1';
950
+ }
951
+ sb.classList.add('hidden');
952
+ rail.classList.remove('rail-hidden');
953
+ // syncRailSide() flips iconRail.style.display based on the classes
954
+ // we just set. Exposed by sidebar-layout.js on window.
955
+ try { window.syncRailSide && window.syncRailSide(); } catch (_) {}
956
+ };
957
+ // Paired restore: if the route opener collapsed the sidebar, re-expand
958
+ // it when the fullscreen view closes. Only restores if the user didn't
959
+ // manually toggle in between (we clear the marker on manual hamburger
960
+ // clicks via a MutationObserver on `.sidebar.hidden`).
961
+ const _restoreSidebarIfRouteCollapsed = () => {
962
+ if (document.body.dataset.routeCollapsedSidebar !== '1') return;
963
+ delete document.body.dataset.routeCollapsedSidebar;
964
+ const sb = document.getElementById('sidebar');
965
+ if (!sb) return;
966
+ sb.classList.remove('hidden');
967
+ try { window.syncRailSide && window.syncRailSide(); } catch (_) {}
968
+ };
969
+ // Expose so closeEmailLibrary / notes close can call this without
970
+ // needing to import app.js directly.
971
+ window._restoreSidebarIfRouteCollapsed = _restoreSidebarIfRouteCollapsed;
972
+ // Clear the marker the moment the sidebar becomes visible again (user
973
+ // hamburger click, or our own _restoreSidebarIfRouteCollapsed call —
974
+ // both endpoints are the same observable state change).
975
+ {
976
+ const sb = document.getElementById('sidebar');
977
+ if (sb && typeof MutationObserver !== 'undefined') {
978
+ new MutationObserver(() => {
979
+ if (!sb.classList.contains('hidden')) {
980
+ delete document.body.dataset.routeCollapsedSidebar;
981
+ }
982
+ }).observe(sb, { attributes: true, attributeFilter: ['class'] });
983
+ }
984
+ }
985
+ const _routeOpen = {
986
+ '/notes': () => {
987
+ if (!notesModule) return;
988
+ _collapseSidebarToRail();
989
+ notesModule.openPanel();
990
+ // Promote to fullscreen-with-rail-visible. The pane wires up its own
991
+ // fullscreen toggle (#notes-fullscreen-toggle); piggyback on that
992
+ // path so the button icon flips and overflow:hidden gets applied
993
+ // alongside. Retry on rAF in case the panel mounts a tick later.
994
+ const _go = () => {
995
+ const btn = document.getElementById('notes-fullscreen-toggle');
996
+ const pane = document.querySelector('.notes-pane');
997
+ if (!pane) return false;
998
+ if (!pane.classList.contains('notes-pane-fullscreen') && btn) btn.click();
999
+ return true;
1000
+ };
1001
+ if (!_go()) {
1002
+ requestAnimationFrame(_go);
1003
+ setTimeout(_go, 50);
1004
+ setTimeout(_go, 200);
1005
+ }
1006
+ },
1007
+ '/calendar': () => calendarModule && calendarModule.openCalendar(),
1008
+ '/cookbook': () => document.getElementById('tool-cookbook-btn')?.click(),
1009
+ '/email': () => {
1010
+ // Collapse the wide sidebar → icon rail (48px) so the user keeps
1011
+ // navigation visible alongside the fullscreen email view.
1012
+ _collapseSidebarToRail();
1013
+ // Spawn a fresh chat first so a reply (or any AI work the user
1014
+ // chains off the email) lives in its own session instead of grafting
1015
+ // onto whatever was last open. The rail button has the full
1016
+ // default-chat / fallback-model resolution logic baked in, so just
1017
+ // delegate to it.
1018
+ try { document.getElementById('rail-new-session')?.click(); } catch (_) {}
1019
+ // The email library is opened by clicking the email section's HEADER
1020
+ // row (.section-header-flex), not the title span. Trigger that, then
1021
+ // snap the modal to fullscreen on the next frame.
1022
+ const hdr = document.querySelector('#email-section .section-header-flex');
1023
+ if (hdr) hdr.click();
1024
+ // The modal is built synchronously inside openEmailLibrary, so a
1025
+ // single frame later it's in the DOM and ready to be flagged.
1026
+ // Fullscreen leaves the icon-rail visible on the left so navigation
1027
+ // stays one click away (per #93). Width = viewport minus rail.
1028
+ // Just add the class — the CSS rule for .email-lib-fullscreen .modal-content
1029
+ // owns all the positioning (with !important so it beats openEmailLibrary's
1030
+ // post-mount centering rAF) and reads the rail width from --icon-rail-w.
1031
+ const _goFullscreen = () => {
1032
+ const modal = document.getElementById('email-lib-modal');
1033
+ if (!modal) return false;
1034
+ modal.classList.add('email-lib-fullscreen');
1035
+ return true;
1036
+ };
1037
+ _goFullscreen();
1038
+ requestAnimationFrame(_goFullscreen);
1039
+ setTimeout(_goFullscreen, 50);
1040
+ setTimeout(_goFullscreen, 200);
1041
+ },
1042
+ '/memory': () => document.getElementById('tool-memory-btn')?.click(),
1043
+ '/gallery': () => document.getElementById('tool-gallery-btn')?.click(),
1044
+ '/tasks': () => document.getElementById('tool-tasks-btn')?.click(),
1045
+ '/library': () => sessionModule && sessionModule.openLibrary && sessionModule.openLibrary(),
1046
+ };
1047
+ const _opener = _routeOpen[urlPath];
1048
+ // Defer the opener — at this point in init, the modules whose handlers
1049
+ // we trigger (#rail-new-session click handler, the email-section header
1050
+ // click handler in emailInbox, sessionModule's loaded session list) are
1051
+ // still being wired up further down in this same function. Stash the
1052
+ // opener so it runs from sessionModule.loadSessions().finally() below.
1053
+ if (_opener) window._zeroRouteOpener = _opener;
1054
+
1055
+ // Archive browser tool button
1056
+ const toolLibraryBtn = el('tool-library-btn');
1057
+ if (toolLibraryBtn) {
1058
+ toolLibraryBtn.addEventListener('click', () => {
1059
+ if (sessionModule) sessionModule.openLibrary();
1060
+ });
1061
+ }
1062
+
1063
+ // "+" on the Library row → create a new blank document and open it in the
1064
+ // editor (mirrors the email section's compose "+"). stopPropagation so it
1065
+ // doesn't also fire the row's open-library click.
1066
+ const libraryNewDocBtn = el('library-new-doc-btn');
1067
+ if (libraryNewDocBtn) {
1068
+ libraryNewDocBtn.addEventListener('click', async (e) => {
1069
+ e.stopPropagation();
1070
+ try {
1071
+ if (documentModule && documentModule.newDocument) await documentModule.newDocument();
1072
+ } catch (err) {
1073
+ console.error('New document from Library failed:', err);
1074
+ if (uiModule && uiModule.showError) uiModule.showError('Could not create document');
1075
+ }
1076
+ });
1077
+ }
1078
+
1079
+ // Manage Chats — opens Full Library modal (decoupled from Chats accordion toggle)
1080
+ const chatsLibraryBtn = el('chats-library-btn');
1081
+ if (chatsLibraryBtn) {
1082
+ chatsLibraryBtn.addEventListener('click', (e) => {
1083
+ e.stopPropagation();
1084
+ if (sessionModule) sessionModule.openLibrary('chats');
1085
+ });
1086
+ }
1087
+
1088
+ const toolArchiveBtn = el('tool-archive-btn');
1089
+ if (toolArchiveBtn) {
1090
+ toolArchiveBtn.addEventListener('click', () => {
1091
+ if (sessionModule) sessionModule.openLibrary('archive');
1092
+ });
1093
+ }
1094
+
1095
+ const toolThemeBtn = el('tool-theme-btn');
1096
+ if (toolThemeBtn) {
1097
+ toolThemeBtn.addEventListener('click', () => {
1098
+ const tm = document.getElementById('theme-modal');
1099
+ if (tm) tm.classList.remove('hidden');
1100
+ });
1101
+ }
1102
+
1103
+ // Sidebar toggle
1104
+ const toggleSidebarOption = el('toggle-sidebar-option');
1105
+ if (toggleSidebarOption) {
1106
+ toggleSidebarOption.addEventListener('click', () => {
1107
+ const sidebar = el('sidebar');
1108
+ sidebar.classList.toggle('hidden');
1109
+ });
1110
+ }
1111
+
1112
+ // Sidebar user bar — settings, admin, profile
1113
+ const userBarSettings = el('user-bar-settings');
1114
+ const userBarProfile = el('user-bar-profile');
1115
+ const userBarAdmin = el('user-bar-admin');
1116
+
1117
+ if (userBarSettings) {
1118
+ userBarSettings.addEventListener('click', () => settingsModule.open());
1119
+ }
1120
+ if (userBarProfile) {
1121
+ // Clicking the user (avatar + name) jumps straight to the Account tab
1122
+ // instead of landing on whatever was last selected.
1123
+ userBarProfile.addEventListener('click', () => settingsModule.open('account'));
1124
+ }
1125
+ if (userBarAdmin) {
1126
+ userBarAdmin.addEventListener('click', () => adminModule.open());
1127
+ }
1128
+
1129
+ // Fetch auth status — populate user bar and show admin button if admin
1130
+ fetch(`${API_BASE}/api/auth/status`, { credentials: 'same-origin' })
1131
+ .then(r => r.json())
1132
+ .then(d => {
1133
+ window._isAdmin = !!d.is_admin;
1134
+ if (d.is_admin && userBarAdmin) userBarAdmin.style.display = '';
1135
+ const userBarName = el('user-bar-name');
1136
+ const userBarAvatar = el('user-bar-avatar');
1137
+ if (userBarName && d.username) {
1138
+ let displayName = d.username;
1139
+ // Mask email addresses
1140
+ if (displayName.includes('@')) {
1141
+ const [local, domain] = displayName.split('@');
1142
+ const ext = domain.includes('.') ? domain.slice(domain.lastIndexOf('.')) : '';
1143
+ displayName = local.charAt(0) + '•••@••••' + ext;
1144
+ }
1145
+ userBarName.textContent = displayName;
1146
+ if (userBarAvatar) userBarAvatar.textContent = d.username.charAt(0).toUpperCase();
1147
+ }
1148
+ // Apply per-user privilege restrictions
1149
+ if (d.privileges) {
1150
+ window._userPrivileges = d.privileges;
1151
+ const p = d.privileges;
1152
+ // Hide agent mode toggle
1153
+ if (!p.can_use_agent) {
1154
+ const modeToggle = document.getElementById('mode-toggle');
1155
+ if (modeToggle) modeToggle.closest('.chat-input-toggle')?.style.setProperty('display', 'none');
1156
+ }
1157
+ // Hide bash toggle
1158
+ if (!p.can_use_bash) {
1159
+ const bashToggle = document.getElementById('bash-toggle');
1160
+ if (bashToggle) bashToggle.closest('.chat-input-toggle')?.style.setProperty('display', 'none');
1161
+ const bashBtn = document.getElementById('tool-bash-btn');
1162
+ if (bashBtn) bashBtn.style.display = 'none';
1163
+ }
1164
+ // Hide document button
1165
+ if (!p.can_use_documents) {
1166
+ const docBtn = document.getElementById('overflow-doc-btn');
1167
+ if (docBtn) docBtn.style.display = 'none';
1168
+ const docInd = document.getElementById('doc-indicator-btn');
1169
+ if (docInd) docInd.style.display = 'none';
1170
+ }
1171
+ // Hide research toggle
1172
+ if (!p.can_use_research) {
1173
+ const resBtn = document.getElementById('research-toggle-btn');
1174
+ if (resBtn) resBtn.style.display = 'none';
1175
+ const resOverflow = document.getElementById('overflow-research-btn');
1176
+ if (resOverflow) resOverflow.style.display = 'none';
1177
+ }
1178
+ // Hide image generation options
1179
+ if (!p.can_generate_images) {
1180
+ const imgBtn = document.getElementById('tool-image-btn');
1181
+ if (imgBtn) imgBtn.style.display = 'none';
1182
+ }
1183
+ }
1184
+ })
1185
+ .catch(() => {});
1186
+
1187
+ // Session sort dropdown
1188
+ const sortBtn = el('session-sort-btn');
1189
+ const sortDropdown = el('session-sort-dropdown');
1190
+ if (sortBtn && sortDropdown) {
1191
+ sortBtn.addEventListener('click', (e) => {
1192
+ e.stopPropagation();
1193
+ sortDropdown.style.display = sortDropdown.style.display === 'block' ? 'none' : 'block';
1194
+ });
1195
+ document.addEventListener('click', () => { sortDropdown.style.display = 'none'; });
1196
+ sortDropdown.addEventListener('click', (e) => e.stopPropagation());
1197
+
1198
+ // Sort mode options (newest, oldest, last active) — toggleable
1199
+ sortDropdown.querySelectorAll('.sort-option').forEach(opt => {
1200
+ opt.addEventListener('click', () => {
1201
+ const mode = opt.dataset.sort;
1202
+ const current = sessionModule.getSortMode();
1203
+ // Toggle: clicking the active sort reverts to manual
1204
+ if (current === mode) {
1205
+ sessionModule.setSortMode(null);
1206
+ sortDropdown.style.display = 'none';
1207
+ uiModule.showToast('Manual order');
1208
+ } else {
1209
+ sessionModule.setSortMode(mode);
1210
+ sortDropdown.style.display = 'none';
1211
+ uiModule.showToast(`Sorted: ${opt.textContent.trim().toLowerCase()}`);
1212
+ }
1213
+ _syncSortChecks();
1214
+ });
1215
+ });
1216
+
1217
+ // Sync checkmarks on sort options
1218
+ function _syncSortChecks() {
1219
+ const current = sessionModule.getSortMode();
1220
+ sortDropdown.querySelectorAll('.sort-option').forEach(o => {
1221
+ const check = o.querySelector('.sort-check') || document.createElement('span');
1222
+ check.className = 'sort-check';
1223
+ check.style.cssText = 'float:right;font-size:20px;line-height:1;position:relative;top:3px;color:var(--accent, var(--red));opacity:' + (o.dataset.sort === current ? '1' : '0');
1224
+ check.textContent = '\u2022';
1225
+ if (!o.querySelector('.sort-check')) o.appendChild(check);
1226
+ });
1227
+ // Highlight filter icon when a sort is active
1228
+ if (sortBtn) sortBtn.classList.toggle('active', !!current);
1229
+ }
1230
+ // Sync on dropdown open + initial load
1231
+ sortBtn.addEventListener('click', _syncSortChecks);
1232
+ _syncSortChecks();
1233
+
1234
+ // AI auto-sort — spinner on the sort button itself. Used by both
1235
+ // the main "★ Tidy" button (AI) and the sub-row "Tidy" button
1236
+ // (no AI, Phase 1 cleanup only) via the skipLlm flag.
1237
+ async function _runTidy(skipLlm) {
1238
+ const btnIcon = sortBtn.querySelector('.sort-icon');
1239
+ if (btnIcon) btnIcon.style.display = 'none';
1240
+ const wp = spinnerModule.create('', 'clean', 'whirlpool');
1241
+ const wpEl = wp.createElement();
1242
+ wpEl.style.cssText = 'width:13px;height:13px;display:inline-block;vertical-align:middle;margin-top:-5px;';
1243
+ sortBtn.appendChild(wpEl);
1244
+ wp.start();
1245
+ sortDropdown.style.display = 'none';
1246
+ try {
1247
+ const url = `${API_BASE}/api/sessions/auto-sort${skipLlm ? '?skip_llm=true' : ''}`;
1248
+ const res = await fetch(url, { method: 'POST' });
1249
+ const data = await res.json();
1250
+ if (!res.ok) throw new Error(data.detail || 'Auto-sort failed');
1251
+ if (data.status === 'ok') {
1252
+ sessionModule.setSortMode(null); // clear sort — tidy creates manual folder order
1253
+ _syncSortChecks();
1254
+ if (skipLlm) {
1255
+ // No-AI path: just report what got cleaned. No "unfiled
1256
+ // remaining" prompt because we never tried to file anything.
1257
+ const cleaned = (data.deleted_empty || 0) + (data.deleted_throwaway || 0);
1258
+ uiModule.showToast(cleaned ? `Cleaned ${cleaned} empty/throwaway chat${cleaned === 1 ? '' : 's'}` : 'Already clean');
1259
+ } else {
1260
+ // Tidy now works in batches (15 most-recent unfiled per click)
1261
+ // so the user gets fast feedback and a manageable LLM call
1262
+ // even with hundreds of chats. Tell them what's left.
1263
+ const remaining = data.unfiled_remaining || 0;
1264
+ let msg;
1265
+ if (data.updated > 0) {
1266
+ msg = `Sorted ${data.updated} into ${data.folders.length} folder${data.folders.length === 1 ? '' : 's'}`;
1267
+ if (remaining > 0) msg += ` — ${remaining} unfiled left, hit Tidy again`;
1268
+ } else if (remaining > 0) {
1269
+ msg = `${remaining} unfiled chats — hit Tidy again`;
1270
+ } else {
1271
+ msg = 'All sorted';
1272
+ }
1273
+ uiModule.showToast(msg);
1274
+ }
1275
+ if (sessionModule) await sessionModule.loadSessions();
1276
+ } else {
1277
+ uiModule.showToast(data.reason || 'Nothing to sort');
1278
+ }
1279
+ } catch (e) {
1280
+ uiModule.showError('Auto-sort: ' + e.message);
1281
+ } finally {
1282
+ wp.destroy();
1283
+ if (wpEl.parentNode) wpEl.parentNode.removeChild(wpEl);
1284
+ if (btnIcon) btnIcon.style.display = '';
1285
+ }
1286
+ }
1287
+
1288
+ const autoSortBtn = el('auto-sort-sessions-btn');
1289
+ if (autoSortBtn) autoSortBtn.addEventListener('click', () => _runTidy(false));
1290
+
1291
+ // Chevron next to the Tidy row toggles the no-AI sub-item.
1292
+ const autoSortMoreBtn = el('auto-sort-sessions-more');
1293
+ const autoSortNoaiBtn = el('auto-sort-sessions-noai-btn');
1294
+ if (autoSortMoreBtn && autoSortNoaiBtn) {
1295
+ autoSortMoreBtn.addEventListener('click', (e) => {
1296
+ e.stopPropagation();
1297
+ autoSortNoaiBtn.style.display = autoSortNoaiBtn.style.display === 'none' ? 'block' : 'none';
1298
+ });
1299
+ autoSortNoaiBtn.addEventListener('click', () => _runTidy(true));
1300
+ }
1301
+ }
1302
+
1303
+ // Model sort dropdown
1304
+ const modelSortBtn = el('model-sort-btn');
1305
+ const modelSortDropdown = el('model-sort-dropdown');
1306
+ if (modelSortBtn && modelSortDropdown) {
1307
+ modelSortBtn.addEventListener('click', (e) => {
1308
+ e.stopPropagation();
1309
+ modelSortDropdown.style.display = modelSortDropdown.style.display === 'block' ? 'none' : 'block';
1310
+ });
1311
+ document.addEventListener('click', () => { modelSortDropdown.style.display = 'none'; });
1312
+ modelSortDropdown.addEventListener('click', (e) => e.stopPropagation());
1313
+ modelSortDropdown.querySelectorAll('.sort-option').forEach(opt => {
1314
+ opt.addEventListener('click', () => {
1315
+ const mode = opt.dataset.sort;
1316
+ Storage.set('zero-model-sort', mode);
1317
+ if (modelsModule) modelsModule.refreshModels();
1318
+ modelSortDropdown.style.display = 'none';
1319
+ uiModule.showToast('Models sorted: ' + opt.textContent.trim().toLowerCase());
1320
+ });
1321
+ });
1322
+ }
1323
+
1324
+
1325
+
1326
+ // Feature visibility — hide admin-disabled features
1327
+ // Use prefetched data from login page if available
1328
+ const _prefetchedFeatures = sessionStorage.getItem('ody-prefetch-features');
1329
+ sessionStorage.removeItem('ody-prefetch-features');
1330
+ window._initFeaturesReady = (_prefetchedFeatures
1331
+ ? Promise.resolve(JSON.parse(_prefetchedFeatures))
1332
+ : fetch(`${API_BASE}/api/auth/features`, { credentials: 'same-origin' }).then(r => r.json())
1333
+ ).then(features => {
1334
+ const map = {
1335
+ web_search: ['web-toggle-btn'],
1336
+ deep_research: ['research-toggle-btn', 'tool-research-btn', 'overflow-research-btn', 'rail-research'],
1337
+ document_editor: ['overflow-doc-btn', 'rail-documents'],
1338
+ gallery: ['tool-gallery-btn', 'rail-gallery'],
1339
+ };
1340
+ Object.entries(map).forEach(([key, ids]) => {
1341
+ if (features[key] === false) {
1342
+ ids.forEach(id => { const e = el(id); if (e) e.style.display = 'none'; });
1343
+ }
1344
+ });
1345
+ // Re-apply the user's Appearance UI-vis preferences after the
1346
+ // features fetch finishes hiding things — otherwise an admin-
1347
+ // disabled feature leaves the sidebar entry hidden even when the
1348
+ // user's "Show in sidebar" toggle is on. The user has to toggle
1349
+ // off then on to trigger applyUIVis a second time, which is the
1350
+ // bug they report as "deep research only shows after I toggle".
1351
+ try { if (window.applyUIVis && window.loadUIVis) window.applyUIVis(window.loadUIVis()); } catch (_) {}
1352
+ })
1353
+ .catch(() => {});
1354
+
1355
+ // Hide Gallery when image generation is disabled in settings
1356
+ const _prefetchedSettings = sessionStorage.getItem('ody-prefetch-settings');
1357
+ sessionStorage.removeItem('ody-prefetch-settings');
1358
+ window._initSettingsReady = (_prefetchedSettings
1359
+ ? Promise.resolve(JSON.parse(_prefetchedSettings))
1360
+ : fetch(`${API_BASE}/api/auth/settings`, { credentials: 'same-origin' }).then(r => r.json())
1361
+ ).then(settings => {
1362
+ // NOTE: image_gen_enabled only governs *generating* images in chat — the
1363
+ // tool is blocked server-side (chat_routes / agent_loop). The Gallery
1364
+ // holds uploads and past images too, so it stays visible regardless;
1365
+ // use the `gallery` feature flag to hide the Gallery entirely.
1366
+ // Hide TTS overflow button when TTS is disabled or no provider configured
1367
+ const ttsOff = settings.tts_enabled === false || !settings.tts_provider || settings.tts_provider === 'disabled';
1368
+ const overflowTts = el('overflow-tts-btn');
1369
+ if (overflowTts) {
1370
+ overflowTts.style.display = ttsOff ? 'none' : '';
1371
+ }
1372
+ })
1373
+ .catch(() => {});
1374
+
1375
+ // (Logout handler moved to sidebar user bar above)
1376
+
1377
+ // Rename AI modal
1378
+ const renameAiOption = el('rename-ai-option');
1379
+ const renameAiModal = el('rename-ai-modal');
1380
+ const closeRenameAi = el('close-rename-ai');
1381
+ const cancelRenameAi = el('cancel-rename-ai');
1382
+ const saveAiName = el('save-ai-name');
1383
+ const aiNameInput = el('ai-name-input');
1384
+
1385
+ if (renameAiOption) {
1386
+ renameAiOption.addEventListener('click', () => {
1387
+ const currentName = aiNameInput.value;
1388
+ renameAiModal.classList.remove('hidden');
1389
+ });
1390
+ }
1391
+
1392
+ if (closeRenameAi) {
1393
+ closeRenameAi.addEventListener('click', () => {
1394
+ renameAiModal.classList.add('hidden');
1395
+ });
1396
+ }
1397
+
1398
+ if (cancelRenameAi) {
1399
+ cancelRenameAi.addEventListener('click', () => {
1400
+ renameAiModal.classList.add('hidden');
1401
+ });
1402
+ }
1403
+
1404
+ if (saveAiName) {
1405
+ saveAiName.addEventListener('click', async () => {
1406
+ const newName = aiNameInput.value.trim();
1407
+
1408
+ if (!newName) {
1409
+ uiModule.showError('Please enter a name for the AI');
1410
+ return;
1411
+ }
1412
+
1413
+ try {
1414
+ const response = await fetch(`${API_BASE}/api/ai/name`, {
1415
+ method: 'POST',
1416
+ headers: {'Content-Type': 'application/json'},
1417
+ body: JSON.stringify({ name: newName })
1418
+ });
1419
+
1420
+ const result = await response.json();
1421
+ if (result.success) {
1422
+ uiModule.showToast(`AI renamed to ${newName}`);
1423
+ renameAiModal.classList.add('hidden');
1424
+ aiNameInput.value = '';
1425
+ }
1426
+ } catch (e) {
1427
+ uiModule.showError('Failed to rename AI: ' + e.message);
1428
+ }
1429
+ });
1430
+ }
1431
+
1432
+ // Memory management
1433
+ const memoryModal = el('memory-modal');
1434
+ const closeMemoryBtn = el('close-memory-modal');
1435
+
1436
+ // Theme popup close button
1437
+ const closeThemeBtn = el('close-theme-popup');
1438
+ if (closeThemeBtn && themeModule) {
1439
+ closeThemeBtn.addEventListener('click', () => {
1440
+ themeModule.closePopup();
1441
+ });
1442
+ }
1443
+
1444
+ // Rename session modal
1445
+ const renameSessionModal = el('rename-session-modal');
1446
+ const closeRenameSession = el('close-rename-session');
1447
+ const cancelRenameSession = el('cancel-rename-session');
1448
+ const saveSessionName = el('save-session-name');
1449
+ const sessionNameInput = el('session-name-input');
1450
+
1451
+ // Close handlers for rename session modal
1452
+ if (closeRenameSession) {
1453
+ closeRenameSession.addEventListener('click', () => {
1454
+ renameSessionModal.classList.add('hidden');
1455
+ });
1456
+ }
1457
+
1458
+ if (cancelRenameSession) {
1459
+ cancelRenameSession.addEventListener('click', () => {
1460
+ renameSessionModal.classList.add('hidden');
1461
+ });
1462
+ }
1463
+
1464
+ if (saveSessionName) {
1465
+ saveSessionName.addEventListener('click', async () => {
1466
+ const newName = sessionNameInput.value.trim();
1467
+
1468
+ if (!newName) {
1469
+ uiModule.showError('Please enter a name for the session');
1470
+ return;
1471
+ }
1472
+
1473
+ try {
1474
+ const response = await fetch(`${API_BASE}/api/session/${sessionModule.getCurrentSessionId()}`, {
1475
+ method: 'PATCH',
1476
+ headers: {'Content-Type': 'application/json'},
1477
+ body: JSON.stringify({ name: newName })
1478
+ });
1479
+
1480
+ const result = await response.json();
1481
+ if (response.ok) {
1482
+ uiModule.showToast(`Session renamed to ${newName}`);
1483
+ renameSessionModal.classList.add('hidden');
1484
+ sessionNameInput.value = '';
1485
+ // Update the current session name in the UI
1486
+ const meta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
1487
+ if (meta) {
1488
+ meta.name = newName;
1489
+ const ver = window._appVersion ? ` v${window._appVersion}` : '';
1490
+ el('current-meta').textContent = `Session: ${meta.name}${meta.model ? ' ' + meta.model.split('/').pop() : ''}${meta.rag ? ' [RAG]' : ''}${ver}`;
1491
+ }
1492
+ // Refresh the sessions list
1493
+ await sessionModule.loadSessions();
1494
+ } else {
1495
+ throw new Error(result.detail || 'Failed to rename session');
1496
+ }
1497
+ } catch (e) {
1498
+ uiModule.showError('Failed to rename session: ' + e.message);
1499
+ }
1500
+ });
1501
+ }
1502
+
1503
+ if (closeMemoryBtn) {
1504
+ closeMemoryBtn.addEventListener('click', () => {
1505
+ dismissModal(memoryModal);
1506
+ });
1507
+ }
1508
+
1509
+ // Sidebar Memory button
1510
+ const toolMemoryBtn = el('tool-memory-btn');
1511
+ if (toolMemoryBtn && memoryModal) {
1512
+ toolMemoryBtn.addEventListener('click', () => {
1513
+ memoryModal.classList.remove('hidden');
1514
+ if (memoryModule && memoryModule.renderMemoryList) memoryModule.renderMemoryList();
1515
+ if (memoryModule && memoryModule.updateMemoryCount) memoryModule.updateMemoryCount();
1516
+ });
1517
+ }
1518
+
1519
+ const addMemBtn = el('add-memory-btn');
1520
+ if (addMemBtn) {
1521
+ addMemBtn.addEventListener('click', memoryModule.addNewMemory);
1522
+ }
1523
+
1524
+ const memorySearchInput = el('memory-search');
1525
+ if (memorySearchInput) {
1526
+ memorySearchInput.addEventListener('input', () => {
1527
+ memoryModule.renderMemoryList();
1528
+ memoryModule.updateMemoryCount();
1529
+ });
1530
+ }
1531
+
1532
+ const newMemoryInput = el('new-memory-input');
1533
+ if (newMemoryInput) {
1534
+ newMemoryInput.addEventListener('keypress', (e) => {
1535
+ if (e.key === 'Enter') {
1536
+ memoryModule.addNewMemory();
1537
+ }
1538
+ });
1539
+ }
1540
+
1541
+ // Voice recording is handled by the dual-purpose send/mic button (see below)
1542
+
1543
+ // ── Toggle persistence — delegates to Storage module ──
1544
+ function loadToggleState() {
1545
+ return Storage.loadToggleState();
1546
+ }
1547
+ function saveToggleState(state) {
1548
+ Storage.saveToggleState(state);
1549
+ }
1550
+
1551
+ // Mode-affected tools: default ON in Agent mode, default OFF in Chat mode,
1552
+ // but the user's explicit per-mode override is persisted and honored.
1553
+ const MODE_TOOLS = [
1554
+ { btnId: 'web-toggle-btn', checkboxId: 'web-toggle', stateKey: 'web' },
1555
+ { btnId: 'bash-toggle-btn', checkboxId: 'bash-toggle', stateKey: 'bash' },
1556
+ ];
1557
+
1558
+ function _modeKey(stateKey, mode) { return `${stateKey}_${mode}`; }
1559
+
1560
+ function loadToolPref(stateKey, mode) {
1561
+ const state = loadToggleState();
1562
+ const key = _modeKey(stateKey, mode);
1563
+ if (Object.prototype.hasOwnProperty.call(state, key)) return !!state[key];
1564
+ return mode === 'agent'; // default: ON in agent, OFF in chat
1565
+ }
1566
+
1567
+ function saveToolPref(stateKey, mode, value) {
1568
+ const state = loadToggleState();
1569
+ state[_modeKey(stateKey, mode)] = value;
1570
+ saveToggleState(state);
1571
+ }
1572
+
1573
+ const TOOL_TOGGLE_TOAST_LABELS = {
1574
+ web: 'Web search',
1575
+ bash: 'Shell',
1576
+ };
1577
+
1578
+ function showToolToggleToast(stateKey, active) {
1579
+ const label = TOOL_TOGGLE_TOAST_LABELS[stateKey];
1580
+ if (!label || !uiModule?.showToast) return;
1581
+ uiModule.showToast(`${label} ${active ? 'on' : 'off'}`, 1800);
1582
+ }
1583
+
1584
+ function applyModeToToggles(mode) {
1585
+ MODE_TOOLS.forEach(({ btnId, checkboxId, stateKey }) => {
1586
+ const btn = el(btnId);
1587
+ if (!btn || btn.style.display === 'none') return;
1588
+ const on = loadToolPref(stateKey, mode);
1589
+ btn.classList.toggle('active', on);
1590
+ if (checkboxId) { const chk = el(checkboxId); if (chk) chk.checked = on; }
1591
+ });
1592
+ }
1593
+
1594
+ // ── Agent / Chat mode toggle ──
1595
+ (function initModeToggle() {
1596
+ const agentBtn = el('mode-agent-btn');
1597
+ const chatBtn = el('mode-chat-btn');
1598
+ if (!agentBtn || !chatBtn) return;
1599
+ const state = loadToggleState();
1600
+ let currentMode = state.mode || 'chat';
1601
+
1602
+ function setMode(mode) {
1603
+ currentMode = mode;
1604
+ const st = loadToggleState();
1605
+ st.mode = mode;
1606
+ saveToggleState(st);
1607
+ agentBtn.classList.toggle('active', mode === 'agent');
1608
+ chatBtn.classList.toggle('active', mode === 'chat');
1609
+ agentBtn.setAttribute('aria-pressed', String(mode === 'agent'));
1610
+ chatBtn.setAttribute('aria-pressed', String(mode === 'chat'));
1611
+ // Slide the pill to the active button
1612
+ const toggle = agentBtn.closest('.mode-toggle');
1613
+ if (toggle) toggle.classList.toggle('mode-chat', mode === 'chat');
1614
+ // Delay tool glow-up for a staggered effect
1615
+ setTimeout(() => applyModeToToggles(mode), 500);
1616
+ }
1617
+ agentBtn.addEventListener('click', () => {
1618
+ // Agent mode turns off research if active
1619
+ const resChk = el('research-toggle');
1620
+ if (resChk && resChk.checked) _syncResearchIndicator(false);
1621
+ setMode('agent');
1622
+ });
1623
+ chatBtn.addEventListener('click', () => setMode('chat'));
1624
+ setMode(currentMode);
1625
+ })();
1626
+
1627
+ // ── Tool splash explainer messages (shown first 2 times per tool) ──
1628
+ const SPLASH_COUNT_KEY = 'zero-tool-splash-counts';
1629
+ const SPLASH_MAX = 2;
1630
+ const _toolSplashes = {
1631
+ web: { role: 'Web Search', text: 'Searches the web for relevant information to include in the response. Results are fetched and summarized before the AI answers.' },
1632
+ bash: { role: 'Shell Access', text: 'Gives the AI access to a sandboxed shell for running commands, installing packages, and executing scripts. Use with caution.' },
1633
+ builder: { role: 'Tool Builder', text: 'Create custom mini-apps and tools the AI can use. Describe what you need and the AI will build a tool you can reuse across conversations.' },
1634
+ research: { role: 'Deep Research', text: 'Multi-round web search with source analysis. Takes longer but produces comprehensive, well-sourced answers. Your next message will trigger a deep research cycle.' },
1635
+ };
1636
+ function _showToolSplash(key) {
1637
+ const splash = _toolSplashes[key];
1638
+ if (!splash) return;
1639
+ // Only show the first SPLASH_MAX times per tool
1640
+ const counts = Storage.getJSON(SPLASH_COUNT_KEY, {});
1641
+ const seen = counts[key] || 0;
1642
+ if (seen >= SPLASH_MAX) return;
1643
+ counts[key] = seen + 1;
1644
+ Storage.setJSON(SPLASH_COUNT_KEY, counts);
1645
+ // Hide welcome screen so splash is visible
1646
+ if (chatModule && chatModule.hideWelcomeScreen) {
1647
+ chatModule.hideWelcomeScreen();
1648
+ }
1649
+ const chatBox = document.getElementById('chat-history');
1650
+ if (!chatBox) return;
1651
+ const div = document.createElement('div');
1652
+ div.className = 'msg msg-ai tool-splash';
1653
+ div.innerHTML = '<div class="role">' + splash.role + '</div><div class="body" style="opacity:0.7;font-size:0.92em">' + splash.text + '</div>';
1654
+ chatBox.appendChild(div);
1655
+ if (uiModule) uiModule.scrollHistory();
1656
+ }
1657
+
1658
+ // ── Checkbox-backed toggle buttons (with per-mode persistence) ──
1659
+ function setupToggle(btnId, checkboxId, stateKey) {
1660
+ const btn = el(btnId);
1661
+ if (!btn) return;
1662
+ // Restore per-mode saved state for both Agent and Chat modes.
1663
+ const mode = (loadToggleState().mode) || 'chat';
1664
+ const saved = loadToolPref(stateKey, mode);
1665
+ const chk = el(checkboxId);
1666
+ if (chk) chk.checked = saved;
1667
+ btn.classList.toggle('active', saved);
1668
+ btn.setAttribute('aria-pressed', String(saved));
1669
+ btn.addEventListener('click', () => {
1670
+ const curMode = (loadToggleState().mode) || 'chat';
1671
+ const chk = el(checkboxId);
1672
+ chk.checked = !chk.checked;
1673
+ btn.classList.toggle('active', chk.checked);
1674
+ btn.setAttribute('aria-pressed', String(chk.checked));
1675
+ saveToolPref(stateKey, curMode, chk.checked);
1676
+ showToolToggleToast(stateKey, chk.checked);
1677
+ if (chk.checked) _showToolSplash(stateKey);
1678
+ // Web search and Research are mutually exclusive — Research takes priority
1679
+ if (stateKey === 'web' && chk.checked) {
1680
+ const resChk = el('research-toggle');
1681
+ if (resChk && resChk.checked) {
1682
+ _syncResearchIndicator(false);
1683
+ }
1684
+ }
1685
+ });
1686
+ }
1687
+ setupToggle('web-toggle-btn', 'web-toggle', 'web');
1688
+ setupToggle('bash-toggle-btn', 'bash-toggle', 'bash');
1689
+
1690
+ // Document editor toggle (special: uses module panel, not a checkbox)
1691
+ const overflowDocBtn = el('overflow-doc-btn');
1692
+ if (overflowDocBtn) {
1693
+ overflowDocBtn.addEventListener('click', async () => {
1694
+ if (!documentModule) return;
1695
+ if (documentModule.isPanelOpen()) {
1696
+ documentModule.closePanel();
1697
+ overflowDocBtn.classList.remove('active');
1698
+ const st = loadToggleState(); st.doc = false; saveToggleState(st);
1699
+ } else {
1700
+ let sessionId = sessionModule.getCurrentSessionId();
1701
+ // If there's a pending "New Chat", materialize it first
1702
+ if (!sessionId && sessionModule.hasPendingChat && sessionModule.hasPendingChat()) {
1703
+ await sessionModule.materializePendingSession();
1704
+ sessionId = sessionModule.getCurrentSessionId();
1705
+ }
1706
+ if (sessionId) {
1707
+ documentModule.loadSessionDocs(sessionId, { forceOpen: true });
1708
+ } else {
1709
+ documentModule.ensureDocPanel();
1710
+ }
1711
+ overflowDocBtn.classList.add('active');
1712
+ const st = loadToggleState(); st.doc = true; saveToggleState(st);
1713
+ }
1714
+ });
1715
+ }
1716
+
1717
+ // Document indicator button (shown outside overflow when docs exist)
1718
+ const docIndicatorBtn = el('doc-indicator-btn');
1719
+ if (docIndicatorBtn) {
1720
+ docIndicatorBtn.addEventListener('click', () => {
1721
+ const ob = el('overflow-doc-btn');
1722
+ if (ob) ob.click();
1723
+ });
1724
+ }
1725
+
1726
+ // ── RAG toggle (overflow + indicator) ──
1727
+ function _syncRagIndicator(active) {
1728
+ const indicator = el('rag-indicator-btn');
1729
+ const overflow = el('overflow-rag-btn');
1730
+ const chk = el('rag-toggle');
1731
+ if (chk) chk.checked = active;
1732
+ if (indicator) {
1733
+ indicator.style.display = active ? '' : 'none';
1734
+ indicator.classList.toggle('active', active);
1735
+ }
1736
+ if (overflow) overflow.classList.toggle('active', active);
1737
+ const s = loadToggleState(); s.rag = active; saveToggleState(s);
1738
+ updatePlusDot();
1739
+ }
1740
+ window._syncRagIndicator = _syncRagIndicator;
1741
+ window._syncResearchIndicator = _syncResearchIndicator;
1742
+ // Must be assigned at module level (not inside the function body) so the very
1743
+ // first external caller — group.js / sessions.js fire it before it has ever
1744
+ // run locally — finds it instead of silently no-op'ing (the "group indicator
1745
+ // sometimes doesn't appear" bug).
1746
+ window._syncGroupIndicator = _syncGroupIndicator;
1747
+ // Init RAG state on load
1748
+ {
1749
+ const st = loadToggleState();
1750
+ const ragState = st.rag || false;
1751
+ _syncRagIndicator(ragState);
1752
+ }
1753
+
1754
+ // ── Overflow "..." menu (Research) ──
1755
+ function updatePlusDot() {
1756
+ const plusBtn = el('overflow-plus-btn');
1757
+ if (!plusBtn) return;
1758
+ const menu = el('overflow-menu');
1759
+ const anyActive = menu ? Array.from(menu.querySelectorAll('.overflow-menu-item.active')).some(item => item.style.display !== 'none') : false;
1760
+ plusBtn.classList.toggle('has-active', anyActive);
1761
+ }
1762
+ // External modules (compare) dispatch this when their overflow state changes
1763
+ document.addEventListener('overflow-state-change', () => updatePlusDot());
1764
+
1765
+ // ── Prevent toolbar buttons from stealing focus (avoids mobile keyboard bounce) ──
1766
+ const chatInputBar = document.querySelector('.chat-input-bar');
1767
+ // ── Keep textarea focused when interacting with chat bar controls (mobile keyboard fix) ──
1768
+ const _msgTextarea = el('message');
1769
+ if (chatInputBar && _msgTextarea) {
1770
+ let _refocusOnBlur = false;
1771
+ function _flagRefocus(e) {
1772
+ if (e.target.closest('textarea, input')) return;
1773
+ // Don't refocus for attach — file picker needs full focus control
1774
+ if (e.target.closest('#overflow-attach-btn')) return;
1775
+ // Don't refocus for model picker button — focus should go to picker search input
1776
+ if (e.target.closest('.model-picker-btn')) return;
1777
+ // Don't refocus when tapping the +/chevron tools button — the user
1778
+ // is explicitly trying to dismiss the keyboard and open the tools
1779
+ // menu. Without this, the textarea blurs (keyboard down), then this
1780
+ // handler re-focuses it (keyboard bounces back up).
1781
+ if (e.target.closest('#overflow-plus-btn')) return;
1782
+ if (document.activeElement === _msgTextarea) _refocusOnBlur = true;
1783
+ }
1784
+ chatInputBar.addEventListener('touchstart', _flagRefocus, { passive: true });
1785
+ // Overflow menu is position:fixed — may not bubble through chatInputBar on mobile
1786
+ const _overflowMenu = el('overflow-menu');
1787
+ if (_overflowMenu) _overflowMenu.addEventListener('touchstart', _flagRefocus, { passive: true });
1788
+ // Model picker menu too
1789
+ const _pickerMenu = document.getElementById('model-picker-menu');
1790
+ if (_pickerMenu) _pickerMenu.addEventListener('touchstart', _flagRefocus, { passive: true });
1791
+ // Attach strip (outside chat-input-bar)
1792
+ const _attachStrip = el('attach-strip');
1793
+ if (_attachStrip) _attachStrip.addEventListener('touchstart', _flagRefocus, { passive: true });
1794
+ _msgTextarea.addEventListener('blur', () => {
1795
+ if (_refocusOnBlur) {
1796
+ _refocusOnBlur = false;
1797
+ setTimeout(() => _msgTextarea.focus(), 0);
1798
+ }
1799
+ });
1800
+ // Clear flag if touch ends without causing blur
1801
+ document.addEventListener('touchend', () => { setTimeout(() => { _refocusOnBlur = false; }, 50); }, { passive: true });
1802
+ }
1803
+
1804
+ (function initOverflowMenu() {
1805
+ const plusBtn = el('overflow-plus-btn');
1806
+ const menu = el('overflow-menu');
1807
+ if (!plusBtn || !menu) return;
1808
+
1809
+ // `.chat-input-bar` has `container-type: inline-size`, which makes it the
1810
+ // containing block for `position: fixed` descendants — so this menu gets
1811
+ // trapped in the composer's stacking context and renders BEHIND the
1812
+ // attach-strip (worse the more files you add). Portal it to <body> while
1813
+ // open so its fixed position + z-index apply against the viewport, then
1814
+ // restore it to its wrapper on close.
1815
+ const ownerWrap = menu.parentElement;
1816
+ const pickerWrap = el('model-picker-wrap');
1817
+ let _vvReposition = null;
1818
+ // Pin the menu's bottom 8px above the chevron (viewport-relative, since it's
1819
+ // portaled to <body>). Only cap height + show a scrollbar when the list is
1820
+ // genuinely taller than the room above the button.
1821
+ function positionMenu() {
1822
+ const r = plusBtn.getBoundingClientRect();
1823
+ menu.style.left = r.left + 'px';
1824
+ menu.style.right = 'auto';
1825
+ menu.style.bottom = 'auto';
1826
+ menu.style.maxHeight = ''; // reset so we can measure the natural height
1827
+ menu.style.overflowY = '';
1828
+ const avail = r.top - 16; // room above the chevron
1829
+ const natural = menu.scrollHeight;
1830
+ const h = Math.min(natural, avail);
1831
+ if (natural > avail) { // only cap + scroll when it doesn't fit
1832
+ menu.style.maxHeight = avail + 'px';
1833
+ menu.style.overflowY = 'auto';
1834
+ }
1835
+ menu.style.top = (r.top - 8 - h) + 'px';
1836
+ }
1837
+ // Tapping the chevron must NOT steal focus from the message box, or the
1838
+ // mobile keyboard collapses. preventDefault on pointerdown keeps the
1839
+ // textarea focused (keyboard stays up) while click still opens the menu.
1840
+ plusBtn.addEventListener('pointerdown', (e) => { e.preventDefault(); });
1841
+ plusBtn.addEventListener('click', (e) => {
1842
+ e.stopPropagation();
1843
+ // Closing path needs to play the fold-in animation, not just flip
1844
+ // .hidden — route through closeOverflowMenu so the second-click
1845
+ // close looks the same as click-outside / Escape / item-pick.
1846
+ const isOpen = !menu.classList.contains('hidden') && !menu.classList.contains('closing');
1847
+ if (isOpen) {
1848
+ closeOverflowMenu();
1849
+ return;
1850
+ }
1851
+ // Re-opening while a fold-in is mid-animation: cancel it cleanly.
1852
+ menu.classList.remove('closing');
1853
+ menu.classList.remove('hidden');
1854
+ plusBtn.classList.add('expanded');
1855
+ document.body.appendChild(menu); // escape the composer's container-type trap
1856
+ // Hide pill bar label so it doesn't show through the menu
1857
+ if (pickerWrap) pickerWrap.style.visibility = 'hidden';
1858
+ // Keep the textarea focused so the keyboard stays up if it was open (the
1859
+ // pointerdown handler above prevents the focus-steal). Still watch
1860
+ // visualViewport so the menu follows the chevron if the viewport shifts.
1861
+ positionMenu();
1862
+ if (window.visualViewport && !_vvReposition) {
1863
+ _vvReposition = () => positionMenu();
1864
+ window.visualViewport.addEventListener('resize', _vvReposition);
1865
+ window.visualViewport.addEventListener('scroll', _vvReposition);
1866
+ }
1867
+ });
1868
+ function closeOverflowMenu() {
1869
+ if (menu.classList.contains('hidden')) return;
1870
+ if (menu.classList.contains('closing')) return;
1871
+ if (_vvReposition && window.visualViewport) {
1872
+ window.visualViewport.removeEventListener('resize', _vvReposition);
1873
+ window.visualViewport.removeEventListener('scroll', _vvReposition);
1874
+ _vvReposition = null;
1875
+ }
1876
+ // Play the fold-in animation (items peel top-down, then container
1877
+ // scales back into the chevron) before flipping to display:none.
1878
+ menu.classList.add('closing');
1879
+ plusBtn.classList.remove('expanded');
1880
+ if (pickerWrap) pickerWrap.style.visibility = '';
1881
+ // Item delays max at 0.18s + 0.20s anim = 0.38s for items, container
1882
+ // delay 0.16s + 0.22s = 0.38s. 400ms covers both with margin.
1883
+ setTimeout(() => {
1884
+ menu.classList.add('hidden');
1885
+ menu.classList.remove('closing');
1886
+ if (ownerWrap) ownerWrap.appendChild(menu); // restore from <body> portal
1887
+ }, 400);
1888
+ }
1889
+ // Close menu when clicking any item inside it. preventDefault on pointerdown
1890
+ // so tapping an item (e.g. Attach files) doesn't steal focus from the message
1891
+ // box — keeps the mobile keyboard up.
1892
+ menu.querySelectorAll('.overflow-menu-item').forEach(item => {
1893
+ item.addEventListener('pointerdown', (e) => { e.preventDefault(); });
1894
+ item.addEventListener('click', () => closeOverflowMenu());
1895
+ });
1896
+ document.addEventListener('click', (e) => {
1897
+ if (!menu.contains(e.target) && e.target !== plusBtn) closeOverflowMenu();
1898
+ });
1899
+ document.addEventListener('keydown', (e) => {
1900
+ if (e.key === 'Escape' && !menu.classList.contains('hidden')) closeOverflowMenu();
1901
+ });
1902
+
1903
+ // Research toggle
1904
+ const researchBtn = el('research-toggle-btn');
1905
+ if (researchBtn) {
1906
+ const st = loadToggleState();
1907
+ const resState = st.research || false;
1908
+ el('research-toggle').checked = resState;
1909
+ researchBtn.classList.toggle('active', resState);
1910
+ researchBtn.style.display = resState ? '' : 'none';
1911
+ // Sync overflow + tool sidebar on load
1912
+ const overflowRes = el('overflow-research-btn');
1913
+ if (overflowRes) overflowRes.classList.toggle('active', resState);
1914
+ const toolRes = el('tool-research-btn');
1915
+ if (toolRes) toolRes.classList.toggle('active', resState);
1916
+ // On load: if both research and web are ON, research wins
1917
+ if (resState) {
1918
+ const webChk = el('web-toggle');
1919
+ const webBtn = el('web-toggle-btn');
1920
+ if (webChk && webChk.checked) {
1921
+ webChk.checked = false;
1922
+ if (webBtn) webBtn.classList.remove('active');
1923
+ saveToolPref('web', (st.mode || 'chat'), false);
1924
+ }
1925
+ }
1926
+
1927
+ researchBtn.addEventListener('click', () => {
1928
+ const chk = el('research-toggle');
1929
+ const turningOn = chk ? !chk.checked : false;
1930
+ _syncResearchIndicator(turningOn);
1931
+ if (turningOn) {
1932
+ _showToolSplash('research');
1933
+ // Clear character — mutually exclusive with research
1934
+ if (presetsModule && presetsModule.deactivateCharacter) presetsModule.deactivateCharacter();
1935
+ // Research and Web search are mutually exclusive
1936
+ const webChk = el('web-toggle');
1937
+ const webBtn = el('web-toggle-btn');
1938
+ if (webChk && webChk.checked) {
1939
+ webChk.checked = false;
1940
+ if (webBtn) webBtn.classList.remove('active');
1941
+ saveToolPref('web', (loadToggleState().mode || 'chat'), false);
1942
+ }
1943
+ // Research requires chat mode — force switch from agent
1944
+ const rs = loadToggleState();
1945
+ if (rs.mode === 'agent') {
1946
+ rs.mode = 'chat';
1947
+ saveToggleState(rs);
1948
+ const ab = el('mode-agent-btn'), cb = el('mode-chat-btn');
1949
+ if (ab) ab.classList.remove('active');
1950
+ if (cb) cb.classList.add('active');
1951
+ applyModeToToggles('chat');
1952
+ }
1953
+ }
1954
+ });
1955
+ }
1956
+
1957
+ updatePlusDot();
1958
+ })();
1959
+
1960
+ // ── Auto-collapse toolbar buttons into overflow when space is tight ──
1961
+ (function initToolbarOverflow() {
1962
+ const inputLeft = document.querySelector('.chat-input-left');
1963
+ const overflowMenu = el('overflow-menu');
1964
+ const overflowWrapper = document.querySelector('.overflow-wrapper');
1965
+ if (!inputLeft || !overflowMenu || !overflowWrapper) return;
1966
+
1967
+ // Buttons that can be collapsed (in reverse priority — last collapsed first)
1968
+ const collapsibleIds = ['bash-toggle-btn', 'web-toggle-btn'];
1969
+ const collapsibleBtns = collapsibleIds.map(id => el(id)).filter(Boolean);
1970
+ // Map of toolbar btn id → overflow mirror element (created dynamically)
1971
+ const overflowMirrors = new Map();
1972
+
1973
+ // Create overflow mirror items for each collapsible button
1974
+ collapsibleBtns.forEach(btn => {
1975
+ const mirror = document.createElement('button');
1976
+ mirror.type = 'button';
1977
+ mirror.className = 'overflow-menu-item toolbar-overflow-mirror';
1978
+ mirror.dataset.mirrorOf = btn.id;
1979
+ const title = btn.title || btn.id.replace(/-/g, ' ');
1980
+ mirror.innerHTML = btn.querySelector('svg').outerHTML + '<span>' + title + '</span>' +
1981
+ '<span class="overflow-active-dot"></span>';
1982
+ mirror.style.display = 'none';
1983
+ mirror.addEventListener('click', () => btn.click());
1984
+ // Insert at top of overflow menu (before existing items)
1985
+ overflowMenu.insertBefore(mirror, overflowMenu.firstChild);
1986
+ overflowMirrors.set(btn.id, mirror);
1987
+ });
1988
+
1989
+ function syncMirrorStates() {
1990
+ overflowMirrors.forEach((mirror, btnId) => {
1991
+ const btn = el(btnId);
1992
+ if (btn) mirror.classList.toggle('active', btn.classList.contains('active'));
1993
+ });
1994
+ updatePlusDot();
1995
+ }
1996
+
1997
+ function checkToolbarOverflow() {
1998
+ const inputBottom = inputLeft.parentElement;
1999
+ if (!inputBottom) return;
2000
+ const rightEl = document.querySelector('.chat-input-right');
2001
+ const available = inputBottom.clientWidth -
2002
+ (rightEl ? rightEl.offsetWidth : 0) - 16;
2003
+
2004
+ // Uncollapse all to measure natural widths
2005
+ collapsibleBtns.forEach(btn => btn.classList.remove('toolbar-collapsed'));
2006
+ overflowMirrors.forEach(m => m.style.display = 'none');
2007
+
2008
+ // Temporarily allow overflow for accurate measurement
2009
+ const prevOverflow = inputLeft.style.overflow;
2010
+ inputLeft.style.overflow = 'visible';
2011
+ inputLeft.style.flexWrap = 'nowrap';
2012
+
2013
+ // Force reflow then measure each child
2014
+ void inputLeft.offsetWidth;
2015
+
2016
+ // Measure the overflow wrapper (always visible)
2017
+ const wrapperWidth = overflowWrapper.offsetWidth + 4;
2018
+
2019
+ // Measure each collapsible button's natural width
2020
+ const btnWidths = collapsibleBtns.map(btn => btn.offsetWidth + 4);
2021
+
2022
+ // Measure non-collapsible, non-wrapper children (tool indicators etc)
2023
+ let otherWidth = 0;
2024
+ Array.from(inputLeft.children).forEach(c => {
2025
+ if (c === overflowWrapper) return;
2026
+ if (collapsibleBtns.includes(c)) return;
2027
+ if (c.offsetWidth) otherWidth += c.offsetWidth + 4;
2028
+ });
2029
+
2030
+ let totalWidth = wrapperWidth + otherWidth + btnWidths.reduce((a, b) => a + b, 0);
2031
+
2032
+ // Force-collapse shell & search when research mode + doc panel are both active
2033
+ const _resChk = el('research-toggle');
2034
+ const _researchOn = _resChk && _resChk.checked;
2035
+ const _docViewOn = document.body.classList.contains('doc-view');
2036
+ if (_researchOn && _docViewOn) {
2037
+ collapsibleBtns.forEach(btn => {
2038
+ btn.classList.add('toolbar-collapsed');
2039
+ const mirror = overflowMirrors.get(btn.id);
2040
+ if (mirror) mirror.style.display = '';
2041
+ });
2042
+ inputLeft.style.overflow = prevOverflow;
2043
+ inputLeft.style.flexWrap = '';
2044
+ syncMirrorStates();
2045
+ return;
2046
+ }
2047
+
2048
+ // Collapse from lowest priority until it fits
2049
+ if (totalWidth > available) {
2050
+ for (let i = 0; i < collapsibleBtns.length; i++) {
2051
+ collapsibleBtns[i].classList.add('toolbar-collapsed');
2052
+ const mirror = overflowMirrors.get(collapsibleBtns[i].id);
2053
+ if (mirror) mirror.style.display = '';
2054
+ totalWidth -= btnWidths[i];
2055
+ if (totalWidth <= available) break;
2056
+ }
2057
+ }
2058
+
2059
+ // Restore
2060
+ inputLeft.style.overflow = prevOverflow;
2061
+ inputLeft.style.flexWrap = '';
2062
+ syncMirrorStates();
2063
+ }
2064
+
2065
+ // Observe active class changes to sync mirror states
2066
+ const observer = new MutationObserver(() => syncMirrorStates());
2067
+ collapsibleBtns.forEach(btn => {
2068
+ observer.observe(btn, { attributes: true, attributeFilter: ['class'] });
2069
+ });
2070
+
2071
+ // Run on resize and on load
2072
+ window.addEventListener('resize', () => requestAnimationFrame(checkToolbarOverflow));
2073
+ // Run immediately (state is already restored by this point)
2074
+ checkToolbarOverflow();
2075
+ // Re-check when sidebar toggles (changes available width)
2076
+ document.addEventListener('overflow-state-change', () =>
2077
+ requestAnimationFrame(checkToolbarOverflow));
2078
+ // Also re-check when sidebar visibility changes
2079
+ const sidebarEl = el('sidebar');
2080
+ if (sidebarEl) {
2081
+ new MutationObserver(() => requestAnimationFrame(checkToolbarOverflow))
2082
+ .observe(sidebarEl, { attributes: true, attributeFilter: ['class'] });
2083
+ }
2084
+ // Re-check when doc panel opens/closes (body.doc-view toggled)
2085
+ new MutationObserver(() => requestAnimationFrame(checkToolbarOverflow))
2086
+ .observe(document.body, { attributes: true, attributeFilter: ['class'] });
2087
+ // Re-check when input bar itself resizes (e.g. doc panel drag)
2088
+ const inputBottom = inputLeft.parentElement;
2089
+ if (inputBottom) {
2090
+ new ResizeObserver(() => requestAnimationFrame(checkToolbarOverflow)).observe(inputBottom);
2091
+ }
2092
+ })();
2093
+
2094
+ // ── Auto-hide model picker when textarea area is too narrow ──
2095
+ (function initModelPickerResponsive() {
2096
+ const inputTop = document.querySelector('.chat-input-top');
2097
+ const pickerWrap = el('model-picker-wrap');
2098
+ if (!inputTop || !pickerWrap) return;
2099
+
2100
+ const PLACEHOLDER_HIDE_WIDTH = 400;
2101
+ const PICKER_HIDE_WIDTH = 220;
2102
+ const TOOLBAR_HIDE_WIDTH = 160;
2103
+ const textarea = el('message');
2104
+ const inputBottom = document.querySelector('.chat-input-bottom');
2105
+ const _isMobile = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
2106
+
2107
+ function checkPickerOverflow() {
2108
+ // Skip responsive collapse on mobile — keyboard open/close causes flicker
2109
+ if (_isMobile) return;
2110
+ const w = inputTop.clientWidth;
2111
+ // Hide model picker
2112
+ pickerWrap.classList.toggle('picker-auto-hidden', w < PICKER_HIDE_WIDTH);
2113
+ // Hide placeholder text
2114
+ if (textarea) {
2115
+ textarea.setAttribute('placeholder', w < PLACEHOLDER_HIDE_WIDTH ? '' : 'Message Zero...');
2116
+ }
2117
+ // Hide entire bottom toolbar (tools, mode toggle) — only send button remains
2118
+ if (inputBottom) {
2119
+ inputBottom.classList.toggle('toolbar-auto-hidden', w < TOOLBAR_HIDE_WIDTH);
2120
+ }
2121
+ }
2122
+
2123
+ const ro = new ResizeObserver(() => requestAnimationFrame(checkPickerOverflow));
2124
+ ro.observe(inputTop);
2125
+ checkPickerOverflow();
2126
+ })();
2127
+
2128
+ // TTS Mode toggle (separate from overflow IIFE for safety)
2129
+ (function initTTSToggle() {
2130
+ const ttsBtn = document.getElementById('overflow-tts-btn');
2131
+ if (!ttsBtn) return;
2132
+ try {
2133
+ const st = loadToggleState();
2134
+ if (st.ttsMode) {
2135
+ ttsBtn.classList.add('active');
2136
+ if (window.aiTTSManager) window.aiTTSManager.autoPlay = true;
2137
+ }
2138
+ } catch(e) {}
2139
+
2140
+ ttsBtn.addEventListener('click', () => {
2141
+ const isActive = !ttsBtn.classList.contains('active');
2142
+ ttsBtn.classList.toggle('active', isActive);
2143
+ if (window.aiTTSManager) window.aiTTSManager.autoPlay = isActive;
2144
+ const s = loadToggleState(); s.ttsMode = isActive; saveToggleState(s);
2145
+ updatePlusDot();
2146
+ });
2147
+ })();
2148
+
2149
+
2150
+ // ── Compare indicator (sidebar only, no overflow) ──
2151
+ const compareIndicatorBtn = el('compare-indicator-btn');
2152
+ if (compareIndicatorBtn) {
2153
+ compareIndicatorBtn.addEventListener('click', () => {
2154
+ if (compareModule && compareModule.isActive()) {
2155
+ compareModule.closeCompare();
2156
+ }
2157
+ });
2158
+ }
2159
+
2160
+ // ── Overflow RAG toggle ──
2161
+ const overflowRagBtn = el('overflow-rag-btn');
2162
+ const ragIndicatorBtn = el('rag-indicator-btn');
2163
+ if (overflowRagBtn) {
2164
+ overflowRagBtn.addEventListener('click', () => {
2165
+ const chk = el('rag-toggle');
2166
+ const isActive = chk ? !chk.checked : true;
2167
+ _syncRagIndicator(isActive);
2168
+ });
2169
+ }
2170
+ if (ragIndicatorBtn) {
2171
+ ragIndicatorBtn.addEventListener('click', () => {
2172
+ _syncRagIndicator(false);
2173
+ });
2174
+ }
2175
+
2176
+ // ── Overflow Research toggle ──
2177
+ const overflowResearchBtn = el('overflow-research-btn');
2178
+ if (overflowResearchBtn) {
2179
+ overflowResearchBtn.addEventListener('click', () => {
2180
+ const chk = el('research-toggle');
2181
+ const turningOn = chk ? !chk.checked : false;
2182
+ _syncResearchIndicator(turningOn);
2183
+ if (turningOn) {
2184
+ _showToolSplash('research');
2185
+ // Clear character — mutually exclusive with research
2186
+ if (presetsModule && presetsModule.deactivateCharacter) presetsModule.deactivateCharacter();
2187
+ // Mutual exclusion with web search
2188
+ const webChk = el('web-toggle');
2189
+ const webBtn = el('web-toggle-btn');
2190
+ if (webChk && webChk.checked) {
2191
+ webChk.checked = false;
2192
+ if (webBtn) webBtn.classList.remove('active');
2193
+ saveToolPref('web', (loadToggleState().mode || 'chat'), false);
2194
+ }
2195
+ // Research requires chat mode
2196
+ const rs2 = loadToggleState();
2197
+ if (rs2.mode === 'agent') {
2198
+ rs2.mode = 'chat';
2199
+ saveToggleState(rs2);
2200
+ const ab2 = el('mode-agent-btn'), cb2 = el('mode-chat-btn');
2201
+ if (ab2) ab2.classList.remove('active');
2202
+ if (cb2) cb2.classList.add('active');
2203
+ applyModeToToggles('chat');
2204
+ }
2205
+ }
2206
+ });
2207
+ }
2208
+
2209
+ // ── Overflow Group Chat toggle ──
2210
+ const overflowGroupBtn = el('overflow-group-btn');
2211
+ if (overflowGroupBtn) {
2212
+ overflowGroupBtn.addEventListener('click', async () => {
2213
+ const chk = el('group-toggle');
2214
+ const turningOn = chk ? !chk.checked : false;
2215
+ if (turningOn) {
2216
+ const picked = await groupModule.showModelPicker();
2217
+ if (!picked || picked.length < 2) return;
2218
+ groupModule.setActive(true); // Set early so updateModelPicker sees it
2219
+ _syncGroupIndicator(true);
2220
+ _startFreshChat();
2221
+ // Clear any leftover splash screens
2222
+ const _chatBox = document.getElementById('chat-history');
2223
+ if (_chatBox) {
2224
+ _chatBox.querySelectorAll('.tool-splash').forEach(s => s.remove());
2225
+ // Also hide welcome screen
2226
+ if (chatModule && chatModule.hideWelcomeScreen) chatModule.hideWelcomeScreen();
2227
+ }
2228
+ // Start group — create participant sessions immediately
2229
+ const sid = sessionModule.getCurrentSessionId() || 'group-' + Date.now();
2230
+ await groupModule.startGroup(picked, sid);
2231
+ // Re-hide picker after everything settles
2232
+ const _mpw = el('model-picker-wrap');
2233
+ if (_mpw) _mpw.style.display = 'none';
2234
+ uiModule.showToast(`Group chat ready — ${picked.length} models`);
2235
+ } else {
2236
+ _syncGroupIndicator(false);
2237
+ groupModule.stopGroup();
2238
+ // Restore model picker
2239
+ const _mpWrap2 = el('model-picker-wrap');
2240
+ if (_mpWrap2) _mpWrap2.style.display = '';
2241
+ }
2242
+ });
2243
+ }
2244
+
2245
+ // ── Group toggle button (chatbox indicator) — click to deactivate ──
2246
+ const groupToggleBtn = el('group-toggle-btn');
2247
+ if (groupToggleBtn) {
2248
+ groupToggleBtn.addEventListener('click', () => {
2249
+ _syncGroupIndicator(false);
2250
+ groupModule.stopGroup();
2251
+ });
2252
+ }
2253
+
2254
+ // ── Incognito mode toggle (on welcome screen) ──
2255
+ const incognitoBtn = el('incognito-btn');
2256
+ const INCOGNITO_EYE_OPEN = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
2257
+ const INCOGNITO_EYE_CLOSED = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>';
2258
+ const SESSION_ICON_CHAT = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
2259
+ const SESSION_ICON_INCOGNITO = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
2260
+
2261
+ function _syncSessionIncognitoIcon(active) {
2262
+ const activeSession = document.querySelector('.list-item.active-session .session-icon');
2263
+ if (activeSession) {
2264
+ activeSession.innerHTML = active ? SESSION_ICON_INCOGNITO : SESSION_ICON_CHAT;
2265
+ activeSession.style.color = active ? 'var(--accent)' : '';
2266
+ }
2267
+ }
2268
+
2269
+ if (incognitoBtn) {
2270
+ incognitoBtn.addEventListener('mousedown', (e) => e.preventDefault());
2271
+ incognitoBtn.addEventListener('click', () => {
2272
+ // Don't toggle mid-chat — incognito only changeable from welcome screen
2273
+ const ws = el('welcome-screen');
2274
+ if (ws && ws.classList.contains('hidden')) return;
2275
+ const chk = el('incognito-toggle');
2276
+ chk.checked = !chk.checked;
2277
+ incognitoBtn.classList.toggle('active', chk.checked);
2278
+ const tipEl = el('welcome-tip');
2279
+ incognitoBtn.title = chk.checked ? 'Disable Nobody mode' : 'Enable Nobody mode — no memory, no history saved';
2280
+ const welcomeName = document.querySelector('.welcome-name');
2281
+ if (chk.checked) {
2282
+ incognitoBtn.innerHTML = INCOGNITO_EYE_CLOSED + '<span class="incognito-label">Nobody</span>';
2283
+ if (welcomeName) {
2284
+ welcomeName.dataset.originalHtml = welcomeName.innerHTML;
2285
+ welcomeName.innerHTML = '<svg class="welcome-boat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>Nobody';
2286
+ // Restart the L→R clip-wipe reveal on the new label
2287
+ welcomeName.style.animation = 'none';
2288
+ welcomeName.offsetHeight;
2289
+ welcomeName.style.animation = '';
2290
+ }
2291
+ if (ws) { ws.style.animation = 'none'; ws.offsetHeight; ws.style.animation = 'welcome-enter 0.3s ease-out both'; }
2292
+ const welcomeSub = el('welcome-sub');
2293
+ if (welcomeSub) {
2294
+ if (!welcomeSub.dataset.originalText) welcomeSub.dataset.originalText = welcomeSub.textContent;
2295
+ welcomeSub.textContent = "Who am I? I'm nobody.";
2296
+ welcomeSub.style.display = '';
2297
+ }
2298
+ if (tipEl) { tipEl.dataset.originalTip = tipEl.textContent; tipEl.textContent = 'Temporary session \u2014 won\u2019t be saved and no memory activation.'; tipEl.style.opacity = '0.5'; tipEl.style.marginTop = '8px'; }
2299
+ // Default to plain chat: disable tools visually, switch to chat mode.
2300
+ // IMPORTANT: don't overwrite the user's persisted per-mode tool prefs
2301
+ // (`web_agent`, `bash_agent`, `web_chat`, `bash_chat`). Nobody mode is
2302
+ // ephemeral — their agent-mode defaults must come back on toggle-off.
2303
+ const _offIds = ['web-toggle', 'bash-toggle', 'research-toggle'];
2304
+ _offIds.forEach(id => { const c = el(id); if (c) c.checked = false; });
2305
+ ['web-toggle-btn', 'bash-toggle-btn'].forEach(id => { const b = el(id); if (b) b.classList.remove('active'); });
2306
+ const _ab = el('mode-agent-btn'), _cb = el('mode-chat-btn');
2307
+ if (_ab) _ab.classList.remove('active');
2308
+ if (_cb) _cb.classList.add('active');
2309
+ const ts = Storage.getJSON(Storage.KEYS.TOGGLES, {});
2310
+ ts.research = false; ts.mode = 'chat';
2311
+ Storage.setJSON(Storage.KEYS.TOGGLES, ts);
2312
+ } else {
2313
+ incognitoBtn.innerHTML = INCOGNITO_EYE_OPEN + '<span class="incognito-label">Nobody</span>';
2314
+ if (welcomeName && welcomeName.dataset.originalHtml) {
2315
+ welcomeName.innerHTML = welcomeName.dataset.originalHtml;
2316
+ // Restart the L→R clip-wipe reveal on the restored label
2317
+ welcomeName.style.animation = 'none';
2318
+ welcomeName.offsetHeight;
2319
+ welcomeName.style.animation = '';
2320
+ }
2321
+ if (ws) { ws.style.animation = 'none'; ws.offsetHeight; ws.style.animation = 'welcome-enter 0.3s ease-out both'; }
2322
+ const welcomeSub2 = el('welcome-sub');
2323
+ if (welcomeSub2) {
2324
+ if (welcomeSub2.dataset.originalText) {
2325
+ welcomeSub2.textContent = welcomeSub2.dataset.originalText;
2326
+ delete welcomeSub2.dataset.originalText;
2327
+ }
2328
+ welcomeSub2.style.display = '';
2329
+ }
2330
+ if (tipEl && tipEl.dataset.originalTip) { tipEl.textContent = tipEl.dataset.originalTip; tipEl.style.opacity = ''; tipEl.style.marginTop = ''; }
2331
+ // Heal any previously-persisted false values from the old Nobody bug
2332
+ // so agent-mode defaults (web/bash ON) come back.
2333
+ const _ts = Storage.getJSON(Storage.KEYS.TOGGLES, {});
2334
+ let _dirty = false;
2335
+ ['web_agent', 'bash_agent', 'web_chat', 'bash_chat'].forEach(k => {
2336
+ if (_ts[k] === false) { delete _ts[k]; _dirty = true; }
2337
+ });
2338
+ if (_dirty) Storage.setJSON(Storage.KEYS.TOGGLES, _ts);
2339
+ // Reapply the current mode's real defaults to the visible toggles
2340
+ const _curMode = (Storage.getJSON(Storage.KEYS.TOGGLES, {}) || {}).mode || 'chat';
2341
+ try { applyModeToToggles(_curMode); } catch (_) {}
2342
+ }
2343
+ // If toggled off mid-chat (welcome screen hidden), hide the button
2344
+ if (!chk.checked && ws && ws.classList.contains('hidden')) {
2345
+ incognitoBtn.style.display = 'none';
2346
+ }
2347
+ // Show/hide persistent incognito indicator in top bar
2348
+ const _incInd = el('incognito-indicator');
2349
+ if (_incInd) _incInd.style.display = chk.checked ? '' : 'none';
2350
+ // Update active session icon in sidebar
2351
+ _syncSessionIncognitoIcon(chk.checked);
2352
+ });
2353
+ }
2354
+
2355
+ // Incognito indicator click — deactivate incognito
2356
+ const incognitoIndicator = el('incognito-indicator');
2357
+ if (incognitoIndicator) {
2358
+ incognitoIndicator.addEventListener('click', () => {
2359
+ if (incognitoBtn) incognitoBtn.click();
2360
+ else {
2361
+ const chk = el('incognito-toggle');
2362
+ if (chk) { chk.checked = false; }
2363
+ incognitoIndicator.style.display = 'none';
2364
+ }
2365
+ });
2366
+ }
2367
+
2368
+ // ── Deactivate incognito mode (called on new session) ──
2369
+ function _deactivateIncognito() {
2370
+ const chk = el('incognito-toggle');
2371
+ if (!chk || !chk.checked) return;
2372
+ if (incognitoBtn) incognitoBtn.click();
2373
+ }
2374
+
2375
+ // ── UI Visibility (Customize UI modal) ──
2376
+ const UI_VIS_KEY = 'ZERO-visibility';
2377
+
2378
+ // Selector map: key → CSS selector(s) for targets
2379
+ const UI_VIS_MAP = {
2380
+ 'sidebar-brand': '.sidebar-brand-title',
2381
+ 'sidebar-new-chat': '#sidebar-new-chat-btn',
2382
+ 'sidebar-search': '#sidebar-search-btn',
2383
+ 'sessions-section': '#sessions-section',
2384
+ 'email-section': '#email-section',
2385
+ 'models-section': '#models-section',
2386
+ 'tools-section': '#tools-section',
2387
+ // Per-tool visibility — fine-grained control over which entries show
2388
+ // inside the Tools section in the sidebar.
2389
+ 'tool-calendar': '#tool-calendar-btn',
2390
+ 'tool-compare': '#tool-compare-btn',
2391
+ 'tool-cookbook': '#tool-cookbook-btn',
2392
+ 'tool-research': '#tool-research-btn',
2393
+ 'tool-gallery': '#tool-gallery-btn',
2394
+ 'tool-library': '#tool-library-btn',
2395
+ 'tool-memory': '#tool-memory-btn',
2396
+ 'tool-notes': '#tool-notes-btn',
2397
+ 'tool-tasks': '#tool-tasks-btn',
2398
+ 'tool-theme': '#tool-theme-btn',
2399
+ 'user-bar': '#user-bar-profile',
2400
+ 'sidebar-settings-btn':'#user-bar-settings',
2401
+ 'chat-meta': '.chat-meta-overlay',
2402
+ 'welcome-text': '.welcome-name, .welcome-sub, #welcome-tip',
2403
+ 'incognito-btn': '.incognito-btn',
2404
+ 'web-toggle-btn': '#web-toggle-btn',
2405
+ 'doc-toggle-btn': '#overflow-doc-btn',
2406
+ 'rag-toggle-btn': '#overflow-rag-btn',
2407
+ 'bash-toggle-btn': '#bash-toggle-btn',
2408
+ 'overflow-plus-btn': '.overflow-wrapper',
2409
+ 'mode-toggle': '.mode-toggle',
2410
+ 'preset-mini-btn': '#overflow-preset-btn',
2411
+ 'attach-btn': '#overflow-attach-btn',
2412
+ 'research-btn': '#overflow-research-btn',
2413
+ 'rail-new-chat': '#rail-new-session',
2414
+ };
2415
+
2416
+ // Keys hidden by default on first run (no localStorage yet)
2417
+ const UI_VIS_DEFAULT_OFF = new Set(['models-section', 'rag-toggle-btn']);
2418
+
2419
+ // Keys that need admin to toggle off (reserved for future use)
2420
+ const UI_VIS_ADMIN_ONLY = new Set([]);
2421
+
2422
+ function loadUIVis() {
2423
+ return Storage.getJSON(UI_VIS_KEY, {});
2424
+ }
2425
+
2426
+ function saveUIVis(state) {
2427
+ Storage.setJSON(UI_VIS_KEY, state);
2428
+ }
2429
+
2430
+ function applyUIVis(state) {
2431
+ Object.entries(UI_VIS_MAP).forEach(([key, selector]) => {
2432
+ // section-drag-reorder uses a body class instead of inline styles
2433
+ if (key === 'section-drag-reorder') return;
2434
+ const visible = key in state ? state[key] !== false : !UI_VIS_DEFAULT_OFF.has(key);
2435
+ document.querySelectorAll(selector).forEach(el => {
2436
+ el.style.display = visible ? '' : 'none';
2437
+ });
2438
+ });
2439
+ // Drag reorder: use body class so dynamically created handles are covered
2440
+ const dragEnabled = state['section-drag-reorder'] === true;
2441
+ document.body.classList.toggle('rearrange-mode', dragEnabled);
2442
+ document.querySelectorAll('.section[draggable]').forEach(el => {
2443
+ el.setAttribute('draggable', dragEnabled ? 'true' : 'false');
2444
+ });
2445
+ // Text-only emojis toggle. Default is ON (the checkbox defaults to
2446
+ // checked because text-emojis isn't in UI_VIS_DEFAULT_OFF), so treat
2447
+ // an absent value as enabled — otherwise the toggle looked on at
2448
+ // startup but the effect only activated after the user flipped it.
2449
+ applyTextEmojis(state['text-emojis'] !== false);
2450
+ // Hide thinking sections toggle (show-thinking: checked=show, unchecked=hide)
2451
+ document.body.classList.toggle('hide-thinking', state['show-thinking'] === false);
2452
+ }
2453
+
2454
+ // Rearrange toggles in session/model sort dropdowns
2455
+ function syncRearrangeChecks() {
2456
+ const on = loadUIVis()['section-drag-reorder'] === true;
2457
+ document.querySelectorAll('.rearrange-toggle .rearrange-check').forEach(ch => {
2458
+ ch.style.opacity = on ? '1' : '0';
2459
+ });
2460
+ }
2461
+ document.querySelectorAll('.rearrange-toggle').forEach(toggle => {
2462
+ toggle.addEventListener('click', () => {
2463
+ const state = loadUIVis();
2464
+ const wasOn = state['section-drag-reorder'] === true;
2465
+ state['section-drag-reorder'] = !wasOn;
2466
+ saveUIVis(state);
2467
+ applyUIVis(state);
2468
+ syncRearrangeChecks();
2469
+ uiModule.showToast(!wasOn ? 'Rearrange enabled' : 'Rearrange disabled');
2470
+ // Close the dropdown the toggle lives in — the sort dropdown's own
2471
+ // click-stopPropagation means it won't close on its own.
2472
+ const dd = toggle.closest('[id$="-sort-dropdown"]');
2473
+ if (dd) dd.style.display = 'none';
2474
+ });
2475
+ });
2476
+
2477
+ // Esc exits rearrange mode (no matter where focus/mouse is) — matches the
2478
+ // global Esc-cancels-select pattern. Capture phase so a sort dropdown that
2479
+ // happens to be open doesn't swallow it first.
2480
+ document.addEventListener('keydown', (e) => {
2481
+ if (e.key !== 'Escape') return;
2482
+ if (!document.body.classList.contains('rearrange-mode')) return;
2483
+ e.preventDefault();
2484
+ e.stopPropagation();
2485
+ const state = loadUIVis();
2486
+ state['section-drag-reorder'] = false;
2487
+ saveUIVis(state);
2488
+ applyUIVis(state);
2489
+ syncRearrangeChecks();
2490
+ uiModule.showToast('Rearrange disabled');
2491
+ }, true);
2492
+ // Sync checkmarks when dropdowns open
2493
+ const _sessionSortBtn = el('session-sort-btn');
2494
+ const _modelSortBtn = el('model-sort-btn');
2495
+ if (_sessionSortBtn) _sessionSortBtn.addEventListener('click', syncRearrangeChecks);
2496
+ if (_modelSortBtn) _modelSortBtn.addEventListener('click', syncRearrangeChecks);
2497
+ syncRearrangeChecks();
2498
+
2499
+ // ── Text-only emoji conversion ──
2500
+ // Regex matching most emoji codepoints (Emoji_Presentation + common sequences)
2501
+ const EMOJI_RE = /(?:\p{Emoji_Presentation}|\p{Extended_Pictographic})(?:\uFE0F|\u200D(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}))*/gu;
2502
+
2503
+ // Common emoji → text description map
2504
+ const EMOJI_MAP = {
2505
+ '😀':'grinning','😃':'smiley','😄':'smile','😁':'grin','😆':'laughing','😅':'sweat smile',
2506
+ '🤣':'rofl','😂':'joy','🙂':'slightly smiling','🙃':'upside down','😉':'wink',
2507
+ '😊':'blush','😇':'innocent','🥰':'smiling hearts','😍':'heart eyes','🤩':'star struck',
2508
+ '😘':'kissing heart','😗':'kissing','😚':'kissing closed eyes','😙':'kissing smiling eyes',
2509
+ '🥲':'smiling tear','😋':'yum','😛':'tongue','😜':'winking tongue','🤪':'zany',
2510
+ '😝':'squinting tongue','🤑':'money mouth','🤗':'hugging','🤭':'hand over mouth',
2511
+ '🤫':'shushing','🤔':'thinking','🫡':'saluting','🤐':'zipper mouth','🤨':'raised eyebrow',
2512
+ '😐':'neutral','😑':'expressionless','😶':'no mouth','🫥':'dotted line face',
2513
+ '😏':'smirk','😒':'unamused','🙄':'eye roll','😬':'grimacing','🤥':'lying',
2514
+ '😌':'relieved','😔':'pensive','😪':'sleepy','🤤':'drooling','😴':'sleeping',
2515
+ '😷':'mask','🤒':'thermometer','🤕':'head bandage','🤢':'nauseated','🤮':'vomiting',
2516
+ '🥵':'hot','🥶':'cold','🥴':'woozy','😵':'dizzy','🤯':'exploding head',
2517
+ '🤠':'cowboy','🥳':'party','🥸':'disguised','😎':'sunglasses','🤓':'nerd',
2518
+ '🧐':'monocle','😕':'confused','🫤':'diagonal mouth','😟':'worried','🙁':'slightly frowning',
2519
+ '😮':'open mouth','😯':'hushed','😲':'astonished','😳':'flushed','🥺':'pleading',
2520
+ '🥹':'holding back tears','😦':'frowning open mouth','😧':'anguished','😨':'fearful',
2521
+ '😰':'anxious sweat','😥':'sad relieved','😢':'crying','😭':'sobbing','😱':'screaming',
2522
+ '😖':'confounded','😣':'persevering','😞':'disappointed','😓':'downcast sweat',
2523
+ '😩':'weary','😫':'tired','🥱':'yawning','😤':'triumph','😡':'pouting',
2524
+ '😠':'angry','🤬':'swearing','😈':'smiling devil','👿':'angry devil',
2525
+ '💀':'skull','☠️':'skull crossbones','💩':'poop','🤡':'clown','👹':'ogre','👺':'goblin',
2526
+ '👻':'ghost','👽':'alien','👾':'space invader','🤖':'robot',
2527
+ '😺':'smiling cat','😸':'grinning cat','😹':'tears of joy cat','😻':'heart eyes cat',
2528
+ '😼':'wry cat','😽':'kissing cat','🙀':'weary cat','😿':'crying cat','😾':'pouting cat',
2529
+ '🙈':'see no evil','🙉':'hear no evil','🙊':'speak no evil',
2530
+ '👋':'wave','🤚':'raised back of hand','🖐️':'hand with fingers splayed','✋':'raised hand',
2531
+ '🖖':'vulcan salute','🫱':'rightward hand','🫲':'leftward hand',
2532
+ '👌':'ok hand','🤌':'pinched fingers','🤏':'pinching hand','✌️':'victory',
2533
+ '🤞':'crossed fingers','🫰':'hand with index finger and thumb crossed',
2534
+ '🤟':'love you','🤘':'rock on','🤙':'call me','👈':'point left','👉':'point right',
2535
+ '👆':'point up','🖕':'middle finger','👇':'point down','☝️':'index up',
2536
+ '🫵':'point at viewer','👍':'thumbs up','👎':'thumbs down','✊':'raised fist',
2537
+ '👊':'fist bump','🤛':'left fist','🤜':'right fist','👏':'clap','🙌':'raising hands',
2538
+ '🫶':'heart hands','👐':'open hands','🤲':'palms up','🤝':'handshake','🙏':'pray',
2539
+ '✍️':'writing','💅':'nail polish','🤳':'selfie','💪':'flexed biceps',
2540
+ '❤️':'red heart','🧡':'orange heart','💛':'yellow heart','💚':'green heart',
2541
+ '💙':'blue heart','💜':'purple heart','🖤':'black heart','🤍':'white heart',
2542
+ '🩷':'pink heart','🩵':'light blue heart','🩶':'grey heart','🤎':'brown heart',
2543
+ '💔':'broken heart','❤️‍🔥':'heart on fire','❤️‍🩹':'mending heart',
2544
+ '💕':'two hearts','💞':'revolving hearts','💓':'heartbeat','💗':'growing heart',
2545
+ '💖':'sparkling heart','💘':'heart with arrow','💝':'heart with ribbon',
2546
+ '💟':'heart decoration','🔥':'fire','💯':'100','✨':'sparkles','⭐':'star',
2547
+ '🌟':'glowing star','💫':'dizzy star','🎉':'party popper','🎊':'confetti ball',
2548
+ '🎈':'balloon','🎁':'gift','🏆':'trophy','🥇':'1st place','🥈':'2nd place','🥉':'3rd place',
2549
+ '⚡':'zap','💡':'light bulb','🔑':'key','🔒':'locked','🔓':'unlocked',
2550
+ '🔔':'bell','🔕':'bell off','📢':'loudspeaker','📣':'megaphone',
2551
+ '💬':'speech bubble','💭':'thought bubble','🗯️':'anger bubble',
2552
+ '✅':'check mark','❌':'cross mark','❓':'question','❗':'exclamation',
2553
+ '⚠️':'warning','🚫':'prohibited','⛔':'no entry','🔴':'red circle','🟢':'green circle',
2554
+ '🔵':'blue circle','🟡':'yellow circle','⚪':'white circle','⚫':'black circle',
2555
+ '🟠':'orange circle','🟣':'purple circle','🟤':'brown circle',
2556
+ '📁':'folder','📂':'open folder','📄':'document','📝':'memo','📎':'paperclip',
2557
+ '📌':'pin','📍':'round pin','🔗':'link','📊':'bar chart','📈':'chart up','📉':'chart down',
2558
+ '🔍':'magnifying glass left','🔎':'magnifying glass right',
2559
+ '🌐':'globe','🌍':'globe europe','🌎':'globe americas','🌏':'globe asia',
2560
+ '🕐':'clock 1','🕑':'clock 2','🕒':'clock 3','🕓':'clock 4',
2561
+ '⏰':'alarm clock','⏳':'hourglass flowing','⌛':'hourglass done',
2562
+ '🚀':'rocket','✈️':'airplane','🚗':'car','🚂':'train','🚢':'ship',
2563
+ '🏠':'house','🏢':'building','🏗️':'construction','🏭':'factory',
2564
+ '🎵':'musical note','🎶':'musical notes','🎤':'microphone','🎧':'headphones',
2565
+ '📷':'camera','📸':'camera flash','🎬':'clapperboard','📺':'television',
2566
+ '💻':'laptop','🖥️':'desktop','📱':'mobile phone','☎️':'telephone',
2567
+ '🔧':'wrench','🔨':'hammer','⚙️':'gear','🧲':'magnet','🧪':'test tube','🔬':'microscope',
2568
+ '📚':'books','📖':'open book','✏️':'pencil','🖊️':'pen','🖋️':'fountain pen',
2569
+ '🎯':'bullseye','♟️':'chess pawn','🎲':'game die','🧩':'puzzle piece',
2570
+ '🍕':'pizza','🍔':'burger','🍟':'fries','🌮':'taco','🍣':'sushi','🍩':'donut',
2571
+ '☕':'coffee','🍺':'beer','🍷':'wine','🥤':'cup with straw',
2572
+ '🐶':'dog','🐱':'cat','🐭':'mouse','🐹':'hamster','🐰':'rabbit','🦊':'fox',
2573
+ '🐻':'bear','🐼':'panda','🐨':'koala','🐯':'tiger','🦁':'lion','🐮':'cow',
2574
+ '🐷':'pig','🐸':'frog','🐵':'monkey','🐔':'chicken','🐧':'penguin','🐦':'bird',
2575
+ '🦅':'eagle','🦆':'duck','🦉':'owl','🐺':'wolf','🐗':'boar','🐴':'horse',
2576
+ '🦄':'unicorn','🐝':'bee','🐛':'bug','🦋':'butterfly','🐌':'snail','🐞':'ladybug',
2577
+ '🐍':'snake','🐢':'turtle','🐙':'octopus','🦀':'crab','🐠':'tropical fish',
2578
+ '🐳':'whale','🐋':'whale','🦈':'shark','🐊':'crocodile','🦕':'sauropod','🦖':'t-rex',
2579
+ '🌸':'cherry blossom','🌹':'rose','🌻':'sunflower','🌺':'hibiscus','🌷':'tulip',
2580
+ '🌱':'seedling','🌲':'evergreen tree','🌳':'deciduous tree','🍀':'four leaf clover',
2581
+ '🍎':'red apple','🍐':'pear','🍊':'tangerine','🍋':'lemon','🍌':'banana',
2582
+ '🍉':'watermelon','🍇':'grapes','🍓':'strawberry','🫐':'blueberries','🍑':'peach',
2583
+ '🌈':'rainbow','☀️':'sun','🌤️':'sun behind cloud','⛅':'sun behind cloud','☁️':'cloud',
2584
+ '🌧️':'rain','⛈️':'thunder','❄️':'snowflake','🌊':'wave',
2585
+ '👀':'eyes','👁️':'eye','👂':'ear','👃':'nose','👄':'mouth','👅':'tongue',
2586
+ '🧠':'brain','🦴':'bone','🦷':'tooth','👶':'baby','🧒':'child','👦':'boy','👧':'girl',
2587
+ '🧑':'person','👨':'man','👩':'woman','🧓':'older person',
2588
+ '👮':'police officer','🧑‍💻':'technologist','👨‍💻':'man technologist',
2589
+ '👩‍💻':'woman technologist',
2590
+ '🎓':'graduation cap','🧢':'billed cap','👑':'crown','💎':'gem','👓':'glasses','🕶️':'sunglasses',
2591
+ '🩸':'drop of blood','💊':'pill','🩹':'bandage','🧬':'dna','🦠':'microbe',
2592
+ '☢️':'radioactive','☣️':'biohazard','♻️':'recycling',
2593
+ '🏳️':'white flag','🏴':'black flag','🚩':'red flag','🏁':'checkered flag',
2594
+ '➡️':'right arrow','⬅️':'left arrow','⬆️':'up arrow','⬇️':'down arrow',
2595
+ '↗️':'upper right arrow','↘️':'lower right arrow','↙️':'lower left arrow','↖️':'upper left arrow',
2596
+ '↩️':'left curve','↪️':'right curve','🔄':'counterclockwise','🔃':'clockwise',
2597
+ '➕':'plus','➖':'minus','➗':'division','✖️':'multiply','♾️':'infinity',
2598
+ '‼️':'double exclamation','⁉️':'exclamation question',
2599
+ '©️':'copyright','®️':'registered','™️':'trademark',
2600
+ };
2601
+
2602
+ function emojiToText(str) {
2603
+ return str.replace(EMOJI_RE, (match) => {
2604
+ const desc = EMOJI_MAP[match];
2605
+ if (desc) return ':' + desc + ':';
2606
+ // Fallback: use the emoji's Unicode name if available, or skip
2607
+ return ':emoji:';
2608
+ });
2609
+ }
2610
+
2611
+ const _DEOJ_SKIP = '.sources-section, .thinking-toggle, .memory-used-pill';
2612
+
2613
+ /** Walk all text nodes inside an element and replace emojis with text descriptions */
2614
+ function deEmojify(root) {
2615
+ if (!root || !root.querySelectorAll) return;
2616
+ // Monochrome SVG spans from svgifyEmoji — Unicode lives in aria-label only
2617
+ root.querySelectorAll('.emoji[aria-label]').forEach((span) => {
2618
+ if (span.closest(_DEOJ_SKIP)) return;
2619
+ const label = span.getAttribute('aria-label') || '';
2620
+ span.replaceWith(document.createTextNode(emojiToText(label)));
2621
+ });
2622
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
2623
+ const nodes = [];
2624
+ while (walker.nextNode()) nodes.push(walker.currentNode);
2625
+ for (const node of nodes) {
2626
+ // Skip UI elements that use unicode symbols as functional icons
2627
+ if (node.parentElement && node.parentElement.closest(_DEOJ_SKIP)) continue;
2628
+ if (EMOJI_RE.test(node.textContent)) {
2629
+ EMOJI_RE.lastIndex = 0; // reset regex state
2630
+ node.textContent = emojiToText(node.textContent);
2631
+ }
2632
+ }
2633
+ }
2634
+
2635
+ /** Apply or remove text-emoji mode on all chat messages */
2636
+ function applyTextEmojis(enabled) {
2637
+ document.body.classList.toggle('text-emojis', enabled);
2638
+ if (enabled) {
2639
+ document.querySelectorAll('.msg .body').forEach(deEmojify);
2640
+ }
2641
+ }
2642
+
2643
+ // Observe chat history for new/changed messages — de-emojify on the fly
2644
+ let _deEmojifyTimer = null;
2645
+ const _chatObs = new MutationObserver(() => {
2646
+ if (!document.body.classList.contains('text-emojis')) return;
2647
+ clearTimeout(_deEmojifyTimer);
2648
+ _deEmojifyTimer = setTimeout(() => {
2649
+ document.querySelectorAll('.msg .body').forEach(deEmojify);
2650
+ }, 150);
2651
+ });
2652
+ const _chatBox = document.getElementById('chat-history');
2653
+ if (_chatBox) _chatObs.observe(_chatBox, { childList: true, subtree: true });
2654
+
2655
+ // Migrate old toolbar visibility key if present
2656
+ (function migrateOldToolbarVis() {
2657
+ const OLD_KEY = 'zero-toolbar-visibility';
2658
+ try {
2659
+ const old = Storage.getJSON(OLD_KEY, null);
2660
+ if (old && typeof old === 'object') {
2661
+ const current = loadUIVis();
2662
+ let migrated = false;
2663
+ Object.entries(old).forEach(([btnId, val]) => {
2664
+ if (current[btnId] === undefined) {
2665
+ current[btnId] = val;
2666
+ migrated = true;
2667
+ }
2668
+ });
2669
+ if (migrated) saveUIVis(current);
2670
+ Storage.remove(OLD_KEY);
2671
+ }
2672
+ } catch {}
2673
+ })();
2674
+
2675
+ // Expose UI visibility functions for admin.js
2676
+ window.loadUIVis = loadUIVis;
2677
+ window.saveUIVis = saveUIVis;
2678
+ window.applyUIVis = applyUIVis;
2679
+ window.UI_VIS_ADMIN_ONLY = UI_VIS_ADMIN_ONLY;
2680
+ window.UI_VIS_DEFAULT_OFF = UI_VIS_DEFAULT_OFF;
2681
+
2682
+ (function initUIVisibility() {
2683
+ // Apply saved visibility on load
2684
+ applyUIVis(loadUIVis());
2685
+
2686
+ // Generic draggable for all .modal elements
2687
+ const _sharedDragModalIds = new Set(['settings-modal']);
2688
+ try { document.querySelectorAll('.modal').forEach(m => {
2689
+ if (_sharedDragModalIds.has(m.id)) return;
2690
+ const content = m.querySelector('.modal-content');
2691
+ const header = m.querySelector('.modal-header');
2692
+ if (!content || !header) return;
2693
+ let dragX, dragY, startLeft, startTop, dragging = false;
2694
+
2695
+ // Reset to flex-centered position each time modal opens
2696
+ new MutationObserver(() => {
2697
+ if (!m.classList.contains('hidden')) {
2698
+ content.style.position = '';
2699
+ content.style.left = '';
2700
+ content.style.top = '';
2701
+ content.style.right = '';
2702
+ content.style.bottom = '';
2703
+ content.style.margin = '';
2704
+ }
2705
+ }).observe(m, { attributes: true, attributeFilter: ['class'] });
2706
+
2707
+ function startDrag(clientX, clientY) {
2708
+ dragging = true;
2709
+ const rect = content.getBoundingClientRect();
2710
+ dragX = clientX; dragY = clientY;
2711
+ startLeft = rect.left; startTop = rect.top;
2712
+ // Switch to fixed so it can be freely positioned
2713
+ content.style.position = 'fixed';
2714
+ content.style.left = startLeft + 'px';
2715
+ content.style.top = startTop + 'px';
2716
+ content.style.margin = '0';
2717
+ }
2718
+
2719
+ header.addEventListener('mousedown', (e) => {
2720
+ if (e.target.closest('.close-btn')) return;
2721
+ e.preventDefault();
2722
+ startDrag(e.clientX, e.clientY);
2723
+ document.addEventListener('mousemove', onDrag);
2724
+ document.addEventListener('mouseup', stopDrag);
2725
+ });
2726
+ function onDrag(e) {
2727
+ if (!dragging) return;
2728
+ content.style.left = (startLeft + e.clientX - dragX) + 'px';
2729
+ content.style.top = (startTop + e.clientY - dragY) + 'px';
2730
+ }
2731
+ function stopDrag() {
2732
+ dragging = false;
2733
+ document.removeEventListener('mousemove', onDrag);
2734
+ document.removeEventListener('mouseup', stopDrag);
2735
+ }
2736
+
2737
+ // Touch drag is desktop-only — on mobile, modals are bottom sheets and
2738
+ // ui.js handles swipe-down-to-dismiss. Attaching this listener fights
2739
+ // the swipe-dismiss gesture.
2740
+ if (window.innerWidth > 768) {
2741
+ header.addEventListener('touchstart', (e) => {
2742
+ if (e.target.closest('.close-btn')) return;
2743
+ const t = e.touches[0];
2744
+ startDrag(t.clientX, t.clientY);
2745
+ document.addEventListener('touchmove', onTouchDrag, { passive: false });
2746
+ document.addEventListener('touchend', stopTouchDrag);
2747
+ });
2748
+ }
2749
+ function onTouchDrag(e) {
2750
+ if (!dragging) return;
2751
+ e.preventDefault();
2752
+ const t = e.touches[0];
2753
+ content.style.left = (startLeft + t.clientX - dragX) + 'px';
2754
+ content.style.top = (startTop + t.clientY - dragY) + 'px';
2755
+ }
2756
+ function stopTouchDrag() {
2757
+ dragging = false;
2758
+ document.removeEventListener('touchmove', onTouchDrag);
2759
+ document.removeEventListener('touchend', stopTouchDrag);
2760
+ }
2761
+ }); } catch(e) { console.error('Modal drag init error:', e); }
2762
+ })();
2763
+
2764
+ // ── Modal minimize → dock ──
2765
+ // Adds a "_" button next to every modal's close button. Clicking it hides
2766
+ // the modal and adds an entry to a fixed bottom dock; clicking the dock
2767
+ // entry restores the modal. Works for hand-rolled and dynamically-created
2768
+ // modals via a MutationObserver on document.body.
2769
+ (function initModalMinimize() {
2770
+ // custom-preset-modal (the Prompt window) is handled by the new
2771
+ // modalManager dock (registered in _AUTO_WIRE), so the legacy dock must
2772
+ // not also inject a `_`/chip for it.
2773
+ const SKIP_IDS = new Set(['styled-confirm-overlay', 'custom-preset-modal']);
2774
+ const dockEntries = new Map(); // modal element -> dock entry element
2775
+
2776
+ let dock = document.getElementById('modal-dock');
2777
+ if (!dock) {
2778
+ dock = document.createElement('div');
2779
+ dock.id = 'modal-dock';
2780
+ document.body.appendChild(dock);
2781
+ }
2782
+
2783
+ // Keep the dock clear of the sidebar (which can be collapsed, resized,
2784
+ // hidden, or flipped to the right side).
2785
+ function updateDockOffset() {
2786
+ const sidebar = document.getElementById('sidebar');
2787
+ const iconRail = document.getElementById('icon-rail');
2788
+ let leftPx = 0;
2789
+ let rightPx = 0;
2790
+ const sidebarRight = sidebar && sidebar.classList.contains('right-side');
2791
+ const sidebarVisible = sidebar &&
2792
+ !sidebar.classList.contains('hidden') &&
2793
+ sidebar.offsetWidth > 0;
2794
+ const railVisible = iconRail && iconRail.offsetWidth > 0;
2795
+ const sidebarW = sidebarVisible ? sidebar.offsetWidth : 0;
2796
+ const railW = railVisible ? iconRail.offsetWidth : 0;
2797
+ if (sidebarRight) {
2798
+ rightPx = sidebarW + railW;
2799
+ } else {
2800
+ leftPx = sidebarW + railW;
2801
+ }
2802
+ dock.style.left = leftPx + 'px';
2803
+ dock.style.right = rightPx + 'px';
2804
+ }
2805
+ updateDockOffset();
2806
+ // Recompute when sidebar resizes, collapses, or moves sides
2807
+ if (window.ResizeObserver) {
2808
+ const ro = new ResizeObserver(updateDockOffset);
2809
+ const sb = document.getElementById('sidebar');
2810
+ const ir = document.getElementById('icon-rail');
2811
+ if (sb) ro.observe(sb);
2812
+ if (ir) ro.observe(ir);
2813
+ }
2814
+ window.addEventListener('resize', updateDockOffset);
2815
+ // Side-flip / collapse toggles class names on body or sidebar
2816
+ new MutationObserver(updateDockOffset).observe(document.body, {
2817
+ attributes: true, attributeFilter: ['class'],
2818
+ });
2819
+ const sbEl = document.getElementById('sidebar');
2820
+ if (sbEl) {
2821
+ new MutationObserver(updateDockOffset).observe(sbEl, {
2822
+ attributes: true, attributeFilter: ['class', 'style'],
2823
+ });
2824
+ }
2825
+
2826
+ function modalTitle(modal) {
2827
+ const h = modal.querySelector('.modal-header h4, .modal-header h3, .modal-header h2');
2828
+ if (h && h.textContent.trim()) return h.textContent.trim();
2829
+ if (modal.id) return modal.id.replace(/-modal$|-overlay$|-popup$/, '').replace(/-/g, ' ');
2830
+ return 'Window';
2831
+ }
2832
+
2833
+ function removeDockEntry(modal) {
2834
+ const entry = dockEntries.get(modal);
2835
+ if (entry) {
2836
+ entry.remove();
2837
+ dockEntries.delete(modal);
2838
+ }
2839
+ }
2840
+
2841
+ function restoreModal(modal) {
2842
+ modal.classList.remove('minimized');
2843
+ modal.classList.remove('hidden');
2844
+ removeDockEntry(modal);
2845
+ // Bring to front (matches existing focus-on-click behavior)
2846
+ modal.style.zIndex = '';
2847
+ }
2848
+
2849
+ function minimizeModal(modal) {
2850
+ if (modal.classList.contains('hidden')) return;
2851
+ modal.classList.add('minimized');
2852
+ if (dockEntries.has(modal)) return;
2853
+
2854
+ const entry = document.createElement('div');
2855
+ entry.className = 'modal-dock-item';
2856
+ entry.title = `Restore ${modalTitle(modal)}`;
2857
+
2858
+ const label = document.createElement('span');
2859
+ label.className = 'modal-dock-label';
2860
+ label.textContent = modalTitle(modal);
2861
+
2862
+ const closeX = document.createElement('button');
2863
+ closeX.className = 'modal-dock-close';
2864
+ closeX.textContent = '×';
2865
+ closeX.title = 'Close';
2866
+ closeX.addEventListener('click', (e) => {
2867
+ e.stopPropagation();
2868
+ modal.classList.remove('minimized');
2869
+ modal.classList.add('hidden');
2870
+ modal.style.display = '';
2871
+ removeDockEntry(modal);
2872
+ });
2873
+
2874
+ entry.appendChild(label);
2875
+ entry.appendChild(closeX);
2876
+ entry.addEventListener('click', () => restoreModal(modal));
2877
+ dock.appendChild(entry);
2878
+ dockEntries.set(modal, entry);
2879
+ }
2880
+
2881
+ function injectMinimizeButton(modal) {
2882
+ if (!modal || !modal.classList || !modal.classList.contains('modal')) return;
2883
+ if (modal.id && SKIP_IDS.has(modal.id)) return;
2884
+ // Modals managed by the new modalManager (Modals.register) get their own
2885
+ // .modal-minimize-btn and chips via the .minimized-dock-chip system.
2886
+ // Skip them entirely so we don't double-up minimize buttons or chips.
2887
+ if (modal.id && /^email-reader-/.test(modal.id)) return;
2888
+ if (modal.id && window.Modals && window.Modals.isRegistered && window.Modals.isRegistered(modal.id)) return;
2889
+ const header = modal.querySelector('.modal-header');
2890
+ if (!header) return;
2891
+ if (header.querySelector('.minimize-btn, .modal-minimize-btn')) return;
2892
+ const closeBtn = header.querySelector('.close-btn, .modal-close');
2893
+ if (!closeBtn) return;
2894
+
2895
+ const minBtn = document.createElement('button');
2896
+ minBtn.className = 'minimize-btn';
2897
+ minBtn.type = 'button';
2898
+ minBtn.title = 'Minimize';
2899
+ minBtn.textContent = '_';
2900
+ minBtn.addEventListener('mousedown', (e) => e.stopPropagation()); // don't start drag
2901
+ minBtn.addEventListener('click', (e) => {
2902
+ e.stopPropagation();
2903
+ minimizeModal(modal);
2904
+ });
2905
+ closeBtn.parentElement.insertBefore(minBtn, closeBtn);
2906
+
2907
+ // Watch this modal's class so close-from-elsewhere clears the dock entry
2908
+ new MutationObserver(() => {
2909
+ if (modal.classList.contains('hidden') && !modal.classList.contains('minimized')) {
2910
+ removeDockEntry(modal);
2911
+ }
2912
+ }).observe(modal, { attributes: true, attributeFilter: ['class'] });
2913
+ }
2914
+
2915
+ // Initial pass over existing modals
2916
+ document.querySelectorAll('.modal').forEach(injectMinimizeButton);
2917
+
2918
+ // Watch for dynamically-created modals
2919
+ new MutationObserver((mutations) => {
2920
+ for (const m of mutations) {
2921
+ for (const n of m.addedNodes) {
2922
+ if (n.nodeType !== 1) continue;
2923
+ if (n.classList && n.classList.contains('modal')) {
2924
+ injectMinimizeButton(n);
2925
+ }
2926
+ if (n.querySelectorAll) {
2927
+ n.querySelectorAll('.modal').forEach(injectMinimizeButton);
2928
+ }
2929
+ }
2930
+ }
2931
+ }).observe(document.body, { childList: true, subtree: true });
2932
+ })();
2933
+
2934
+ // Preset button (in overflow menu)
2935
+ const overflowPresetBtn = el('overflow-preset-btn');
2936
+ if (overflowPresetBtn) {
2937
+ overflowPresetBtn.addEventListener('click', () => {
2938
+ if (presetsModule && presetsModule.openCustomPresetModal) {
2939
+ presetsModule.openCustomPresetModal();
2940
+ }
2941
+ });
2942
+ }
2943
+
2944
+ // RAG directory
2945
+ const addDirBtn = el('add-directory-btn');
2946
+ if (addDirBtn) {
2947
+ addDirBtn.addEventListener('click', () => {
2948
+ ragModule.addRagDirectory(uiModule.showToast, uiModule.showError);
2949
+ });
2950
+ }
2951
+
2952
+ const directoryInput = el('rag-directory');
2953
+ if (directoryInput) {
2954
+ directoryInput.addEventListener('keypress', (e) => {
2955
+ if (e.key === 'Enter') {
2956
+ ragModule.addRagDirectory(uiModule.showToast, uiModule.showError);
2957
+ }
2958
+ });
2959
+
2960
+ }
2961
+
2962
+ // Sidebar layout (extracted to js/sidebar-layout.js)
2963
+ initSidebarLayout(Storage, {
2964
+ documentModule, _closeCompareIfActive, _deactivateIncognito,
2965
+ presetsModule, sessionModule, el, _defaultChat, _syncResearchIndicator
2966
+ });
2967
+
2968
+ // Mobile: horizontal swipe on a tabbed window switches tabs. Works for any
2969
+ // tab bar whose buttons are siblings and switch on click (Prompt, Library,
2970
+ // Brain, Theme) — we just click the prev/next tab so the existing switch
2971
+ // logic runs. Swipes that start on interactive controls (sliders, inputs,
2972
+ // the chip dock) are ignored so they don't fight text selection / dragging.
2973
+ (function initTabSwipe() {
2974
+ if (window.innerWidth > 768) return;
2975
+ // [tab-bar selector, tab-button selector] for each tabbed window.
2976
+ const SYSTEMS = [
2977
+ ['.preset-tabs', '.preset-tab'],
2978
+ ['.lib-tabs', '.lib-tab'],
2979
+ ['.memory-tabs', '.memory-tab'],
2980
+ ['.admin-tabs', '.admin-tab'],
2981
+ ];
2982
+ const _IGNORE = 'input, textarea, select, [contenteditable="true"], .preset-range, ' +
2983
+ '.note-cl-row, .minimized-dock-chip, canvas, .email-card-reader';
2984
+ let sx = 0, sy = 0, tracking = false;
2985
+
2986
+ document.addEventListener('touchstart', (e) => {
2987
+ if (window.innerWidth > 768 || e.touches.length !== 1) { tracking = false; return; }
2988
+ if (e.target.closest && e.target.closest(_IGNORE)) { tracking = false; return; }
2989
+ sx = e.touches[0].clientX; sy = e.touches[0].clientY; tracking = true;
2990
+ }, { passive: true });
2991
+
2992
+ document.addEventListener('touchend', (e) => {
2993
+ if (!tracking) return;
2994
+ tracking = false;
2995
+ const t = e.changedTouches[0];
2996
+ if (!t) return;
2997
+ const dx = t.clientX - sx, dy = t.clientY - sy;
2998
+ // Require a deliberate, mostly-horizontal swipe.
2999
+ if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
3000
+ for (const [barSel, tabSel] of SYSTEMS) {
3001
+ const bar = document.querySelector(barSel);
3002
+ if (!bar || bar.offsetParent === null) continue; // not the visible window
3003
+ // Only act if the swipe happened inside this bar's window (not some
3004
+ // other on-screen element).
3005
+ const host = bar.closest('.modal, #notes-pane, .preset-modal-content, .admin-card') || bar.parentElement;
3006
+ const startEl = document.elementFromPoint(sx, sy);
3007
+ if (host && startEl && !host.contains(startEl)) continue;
3008
+ const tabs = [...bar.querySelectorAll(tabSel)];
3009
+ if (tabs.length < 2) continue;
3010
+ let idx = tabs.findIndex(tb => tb.classList.contains('active'));
3011
+ if (idx < 0) idx = 0;
3012
+ // Swipe left (dx<0) → next tab; swipe right (dx>0) → previous.
3013
+ const nextIdx = dx < 0 ? idx + 1 : idx - 1;
3014
+ if (nextIdx < 0 || nextIdx >= tabs.length) return; // at an edge
3015
+ tabs[nextIdx].click();
3016
+ return;
3017
+ }
3018
+ }, { passive: true });
3019
+ })();
3020
+
3021
+ // Elastic overscroll (rubber-band bounce) — desktop wheel only, on chat-history not container
3022
+ (function initElasticScroll() {
3023
+ const hist = el('chat-history');
3024
+ if (!hist) return;
3025
+ const SNAP_BACK = 'transform 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
3026
+
3027
+ let wheelPull = 0;
3028
+ let wheelTimer = null;
3029
+ hist.addEventListener('wheel', (e) => {
3030
+ const atTop = hist.scrollTop <= 0 && e.deltaY < 0;
3031
+ const atBottom = hist.scrollTop + hist.clientHeight >= hist.scrollHeight - 1 && e.deltaY > 0;
3032
+ if (!atTop && !atBottom) { wheelPull = 0; return; }
3033
+
3034
+ wheelPull += e.deltaY * -0.03;
3035
+ wheelPull = Math.max(-7, Math.min(7, wheelPull));
3036
+ hist.style.transition = 'none';
3037
+ hist.style.transform = `translateY(${wheelPull}px)`;
3038
+
3039
+ clearTimeout(wheelTimer);
3040
+ wheelTimer = setTimeout(() => {
3041
+ wheelPull = 0;
3042
+ hist.style.transition = SNAP_BACK;
3043
+ hist.style.transform = '';
3044
+ }, 120);
3045
+ }, { passive: true });
3046
+ })();
3047
+
3048
+ // New session button on icon rail
3049
+ const railNewSession = el('rail-new-session');
3050
+ if (railNewSession) {
3051
+ railNewSession.addEventListener('click', async () => {
3052
+ if (!sessionModule) return;
3053
+ if (_closeCompareIfActive()) return;
3054
+ _deactivateIncognito();
3055
+ // Clear character on new chat
3056
+ if (presetsModule && presetsModule.deactivateCharacter) presetsModule.deactivateCharacter();
3057
+ // Clear research mode if active
3058
+ const _resChk = el('research-toggle');
3059
+ if (_resChk && _resChk.checked) _syncResearchIndicator(false);
3060
+ if (await _createDirectChatFromPreferredModel()) return;
3061
+ // No models at all — show welcome screen
3062
+ sessionModule.setCurrentSessionId(null);
3063
+ if (documentModule && documentModule.isPanelOpen && documentModule.isPanelOpen()) documentModule.closePanel();
3064
+ const docBtn3 = el('overflow-doc-btn');
3065
+ if (docBtn3) docBtn3.classList.remove('active', 'has-docs');
3066
+ const box = el('chat-history');
3067
+ if (box) box.innerHTML = '';
3068
+ if (chatModule && chatModule.showWelcomeScreen) {
3069
+ chatModule.showWelcomeScreen();
3070
+ }
3071
+ document.querySelectorAll('.session-item.active').forEach(s => s.classList.remove('active'));
3072
+ });
3073
+ }
3074
+
3075
+ // Mobile new chat button — always go to blank welcome screen.
3076
+ // The send path at chat.js:354 will auto-create a session using /api/default-chat
3077
+ // on first submit, so users can start typing before models finish loading and
3078
+ // the default model attaches when they hit send.
3079
+ const mobileNewChat = el('mobile-new-chat-btn');
3080
+ if (mobileNewChat) {
3081
+ mobileNewChat.addEventListener('click', () => {
3082
+ if (!sessionModule) return;
3083
+ if (_closeCompareIfActive()) return;
3084
+ _deactivateIncognito();
3085
+ _startFreshChat();
3086
+ document.querySelectorAll('.session-item.active').forEach(s => s.classList.remove('active'));
3087
+ // Focus the composer synchronously so mobile keyboards pop open.
3088
+ // iOS Safari only honours programmatic focus inside the original click
3089
+ // callback — a setTimeout breaks the user-gesture chain.
3090
+ const _input = el('message-input');
3091
+ if (_input) { try { _input.focus(); } catch (_) {} }
3092
+ });
3093
+ }
3094
+
3095
+ // Logo click → new chat (same logic as rail new-session button)
3096
+ const brandBtn = el('sidebar-brand-btn');
3097
+ if (brandBtn) {
3098
+ brandBtn.addEventListener('click', async () => {
3099
+ if (!sessionModule) return;
3100
+ if (_closeCompareIfActive()) return;
3101
+ _deactivateIncognito();
3102
+ if (presetsModule && presetsModule.deactivateCharacter) presetsModule.deactivateCharacter();
3103
+ // Clear research toggle when starting a fresh chat (not via research button)
3104
+ _syncResearchIndicator(false);
3105
+ if (await _createDirectChatFromPreferredModel()) return;
3106
+ // No models at all — show welcome screen
3107
+ sessionModule.setCurrentSessionId(null);
3108
+ if (documentModule && documentModule.isPanelOpen && documentModule.isPanelOpen()) documentModule.closePanel();
3109
+ const docBtn2 = el('overflow-doc-btn');
3110
+ if (docBtn2) docBtn2.classList.remove('active', 'has-docs');
3111
+ const box = el('chat-history');
3112
+ if (box) box.innerHTML = '';
3113
+ if (chatModule && chatModule.showWelcomeScreen) chatModule.showWelcomeScreen();
3114
+ document.querySelectorAll('.session-item.active').forEach(s => s.classList.remove('active'));
3115
+ });
3116
+ }
3117
+
3118
+ const sidebarNewChatBtn = el('sidebar-new-chat-btn');
3119
+ if (sidebarNewChatBtn) {
3120
+ sidebarNewChatBtn.addEventListener('click', () => {
3121
+ const brandBtn = el('sidebar-brand-btn');
3122
+ if (brandBtn) brandBtn.click();
3123
+ });
3124
+ }
3125
+
3126
+ // Delete session button on icon rail
3127
+ const railDelete = el('rail-delete-session');
3128
+ if (railDelete) {
3129
+ railDelete.addEventListener('click', async () => {
3130
+ if (!sessionModule) return;
3131
+ const currentId = sessionModule.getCurrentSessionId();
3132
+ if (!currentId) return;
3133
+ const sessions = sessionModule.getSessions();
3134
+ const current = sessions.find(s => s.id === currentId);
3135
+ const name = current ? current.name : 'this session';
3136
+ if (!await uiModule.styledConfirm(`Delete "${name}"?`, { confirmText: 'Delete', danger: true })) return;
3137
+ try {
3138
+ // Find the next session below the current one before deleting
3139
+ const idx = sessions.findIndex(s => s.id === currentId);
3140
+ const nextSession = sessions.filter(s => !s.archived && s.id !== currentId)[Math.max(0, idx)] ||
3141
+ sessions.find(s => !s.archived && s.id !== currentId);
3142
+ const res = await fetch(`${API_BASE}/api/session/${currentId}`, { method: 'DELETE' });
3143
+ if (res.ok) {
3144
+ await sessionModule.loadSessions();
3145
+ if (nextSession) {
3146
+ await sessionModule.selectSession(nextSession.id);
3147
+ }
3148
+ uiModule.showToast('Session deleted');
3149
+ } else {
3150
+ uiModule.showError('Failed to delete session');
3151
+ }
3152
+ } catch (e) {
3153
+ uiModule.showError('Failed to delete session: ' + e);
3154
+ }
3155
+ });
3156
+ }
3157
+
3158
+ // Textarea auto-resize
3159
+ const textarea = el('message');
3160
+ if (textarea) {
3161
+ uiModule.autoResize(textarea);
3162
+ textarea.addEventListener('input', () => {
3163
+ uiModule.autoResize(textarea);
3164
+ });
3165
+ textarea.addEventListener('paste', () => {
3166
+ setTimeout(() => uiModule.autoResize(textarea), 1);
3167
+ });
3168
+ textarea.addEventListener('keydown', (e) => {
3169
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
3170
+ // If ghost autocomplete is active, accept the suggestion instead of submitting
3171
+ if (window._ghostAutocomplete && window._ghostAutocomplete.isActive()) {
3172
+ e.preventDefault();
3173
+ e.stopPropagation();
3174
+ window._ghostAutocomplete.accept();
3175
+ return;
3176
+ }
3177
+ e.preventDefault();
3178
+ e.stopPropagation();
3179
+ // Check if already submitting before triggering form submission
3180
+ const form = el('chat-form');
3181
+ if (form) {
3182
+ const submitBtn = form.querySelector('button[type="submit"]');
3183
+ if (submitBtn) submitBtn.click();
3184
+ }
3185
+ }
3186
+ });
3187
+ }
3188
+
3189
+ // ── Ghost text autocomplete for /new and /create commands ──
3190
+ (function initGhostAutocomplete() {
3191
+ const textarea = el('message');
3192
+ const ghost = document.getElementById('message-ghost');
3193
+ if (!textarea || !ghost) return;
3194
+
3195
+ let modelCache = null; // { models: [{ mid, url, endpointId, displayName }], ts }
3196
+ let filtered = []; // currently matching models
3197
+ let cycleIdx = 0; // index into filtered[]
3198
+ let active = false; // is ghost visible?
3199
+ const CACHE_TTL = 60000; // re-fetch after 60s
3200
+ const CMD_RE = /^\/(new|create)\s/i;
3201
+
3202
+ async function fetchModels() {
3203
+ if (modelCache && Date.now() - modelCache.ts < CACHE_TTL) return modelCache.models;
3204
+ try {
3205
+ const res = await fetch(`${API_BASE}/api/models`, { credentials: 'same-origin' });
3206
+ const data = await res.json();
3207
+ const models = [];
3208
+ (data.items || []).forEach(ep => {
3209
+ const displayNames = ep.models_display || ep.models || [];
3210
+ (ep.models || []).forEach((mid, i) => {
3211
+ models.push({
3212
+ mid,
3213
+ url: ep.url,
3214
+ endpointId: ep.endpoint_id || null,
3215
+ displayName: displayNames[i] || mid,
3216
+ });
3217
+ });
3218
+ });
3219
+ modelCache = { models, ts: Date.now() };
3220
+ return models;
3221
+ } catch (e) {
3222
+ console.warn('Ghost autocomplete: failed to fetch models', e);
3223
+ return modelCache ? modelCache.models : [];
3224
+ }
3225
+ }
3226
+
3227
+ function hide() {
3228
+ active = false;
3229
+ filtered = [];
3230
+ cycleIdx = 0;
3231
+ ghost.textContent = '';
3232
+ ghost.style.display = 'none';
3233
+ }
3234
+
3235
+ function show(typed, suggestion) {
3236
+ active = true;
3237
+ ghost.innerHTML = '';
3238
+ // Invisible portion matches what user typed (keeps alignment)
3239
+ const span1 = document.createElement('span');
3240
+ span1.style.visibility = 'hidden';
3241
+ span1.textContent = typed;
3242
+ // Visible faded suggestion portion
3243
+ const span2 = document.createElement('span');
3244
+ span2.className = 'ghost-suggestion';
3245
+ span2.textContent = suggestion;
3246
+ ghost.appendChild(span1);
3247
+ ghost.appendChild(span2);
3248
+ ghost.style.display = 'block';
3249
+ }
3250
+
3251
+ function syncSize() {
3252
+ // Match ghost overlay dimensions to textarea
3253
+ const cs = getComputedStyle(textarea);
3254
+ ghost.style.width = cs.width;
3255
+ ghost.style.height = cs.height;
3256
+ }
3257
+
3258
+ async function update() {
3259
+ const val = textarea.value;
3260
+ const match = val.match(CMD_RE);
3261
+ if (!match) { hide(); return; }
3262
+
3263
+ const prefix = val.slice(match[0].length); // text after "/new " or "/create "
3264
+ const models = await fetchModels();
3265
+ if (!models.length) { hide(); return; }
3266
+
3267
+ // Filter models whose mid or displayName starts with the typed prefix (case-insensitive)
3268
+ const lp = prefix.toLowerCase();
3269
+ filtered = models.filter(m =>
3270
+ m.mid.toLowerCase().startsWith(lp) || m.displayName.toLowerCase().startsWith(lp)
3271
+ );
3272
+
3273
+ if (!filtered.length) { hide(); return; }
3274
+
3275
+ // Clamp cycle index
3276
+ cycleIdx = cycleIdx % filtered.length;
3277
+ const chosen = filtered[cycleIdx];
3278
+ // Determine which name matched for completion
3279
+ const name = chosen.mid.toLowerCase().startsWith(lp) ? chosen.mid : chosen.displayName;
3280
+ const remainder = name.slice(prefix.length);
3281
+ if (!remainder && filtered.length <= 1) { hide(); return; }
3282
+
3283
+ syncSize();
3284
+ show(val, remainder);
3285
+ }
3286
+
3287
+ // --- Event listeners ---
3288
+
3289
+ textarea.addEventListener('input', () => {
3290
+ cycleIdx = 0;
3291
+ update();
3292
+ });
3293
+
3294
+ textarea.addEventListener('keydown', (e) => {
3295
+ if (!active) return;
3296
+
3297
+ if (e.key === 'Tab') {
3298
+ // Tab fills the current suggestion into the textarea
3299
+ e.preventDefault();
3300
+ e.stopPropagation();
3301
+ const val = textarea.value;
3302
+ const match = val.match(CMD_RE);
3303
+ if (match && filtered.length) {
3304
+ const prefix = val.slice(match[0].length);
3305
+ const chosen = filtered[cycleIdx % filtered.length];
3306
+ const lp = prefix.toLowerCase();
3307
+ const name = chosen.mid.toLowerCase().startsWith(lp) ? chosen.mid : chosen.displayName;
3308
+ textarea.value = match[0] + name;
3309
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
3310
+ }
3311
+ return;
3312
+ }
3313
+
3314
+ if (e.key === 'ArrowDown') {
3315
+ e.preventDefault();
3316
+ e.stopPropagation();
3317
+ cycleIdx = (cycleIdx + 1) % filtered.length;
3318
+ update();
3319
+ return;
3320
+ }
3321
+
3322
+ if (e.key === 'ArrowUp') {
3323
+ e.preventDefault();
3324
+ e.stopPropagation();
3325
+ cycleIdx = (cycleIdx - 1 + filtered.length) % filtered.length;
3326
+ update();
3327
+ return;
3328
+ }
3329
+
3330
+ if (e.key === 'Escape') {
3331
+ e.preventDefault();
3332
+ hide();
3333
+ return;
3334
+ }
3335
+ });
3336
+
3337
+ textarea.addEventListener('blur', hide);
3338
+
3339
+ // Observe textarea resize (from autoResize) to keep ghost in sync
3340
+ const ro = new ResizeObserver(() => { if (active) syncSize(); });
3341
+ ro.observe(textarea);
3342
+
3343
+ // Public API for the Enter handler above
3344
+ window._ghostAutocomplete = {
3345
+ isActive() { return active && filtered.length > 0; },
3346
+ accept() {
3347
+ if (!active || !filtered.length) return;
3348
+ const val = textarea.value;
3349
+ const match = val.match(CMD_RE);
3350
+ if (!match) { hide(); return; }
3351
+ const prefix = val.slice(match[0].length);
3352
+ const chosen = filtered[cycleIdx % filtered.length];
3353
+ const lp = prefix.toLowerCase();
3354
+ const name = chosen.mid.toLowerCase().startsWith(lp) ? chosen.mid : chosen.displayName;
3355
+ textarea.value = match[0] + name;
3356
+ hide();
3357
+ // Trigger input event so autoResize fires
3358
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
3359
+ // Now submit the form (the /new command handler will process it)
3360
+ setTimeout(() => {
3361
+ const form = el('chat-form');
3362
+ if (form) form.querySelector('button[type="submit"]').click();
3363
+ }, 0);
3364
+ }
3365
+ };
3366
+ })();
3367
+
3368
+ // Keyboard shortcuts (extracted to js/keyboard-shortcuts.js)
3369
+ initKeyboardShortcuts({
3370
+ el, Storage, sessionModule, uiModule, chatModule,
3371
+ adminModule, settingsModule, searchChatModule,
3372
+ _closeCompareIfActive, _deactivateIncognito, API_BASE
3373
+ });
3374
+
3375
+ }
3376
+
3377
+ // ============================================
3378
+ // INITIALIZATION ON PAGE LOAD
3379
+ // ============================================
3380
+ function startZeroApp() {
3381
+ if (window.__zeroAppStarted) return;
3382
+ window.__zeroAppStarted = true;
3383
+ // Set CSS variables
3384
+ document.documentElement.style.setProperty('--line-height', '20px');
3385
+
3386
+ // Smooth keyboard open/close on mobile — keep chat scrolled to bottom
3387
+ if (window.visualViewport && 'ontouchstart' in window) {
3388
+ let _prevVPH = visualViewport.height;
3389
+ visualViewport.addEventListener('resize', () => {
3390
+ const delta = visualViewport.height - _prevVPH;
3391
+ _prevVPH = visualViewport.height;
3392
+ // Keyboard opened (viewport shrank significantly)
3393
+ if (delta < -50) {
3394
+ const hist = document.getElementById('chat-history');
3395
+ if (hist) {
3396
+ hist.style.scrollBehavior = 'smooth';
3397
+ hist.scrollTop = hist.scrollHeight;
3398
+ // Reset after animation
3399
+ setTimeout(() => { hist.style.scrollBehavior = ''; }, 300);
3400
+ }
3401
+ }
3402
+ });
3403
+ }
3404
+
3405
+ // Initialize all event listeners
3406
+ try { initializeEventListeners(); } catch(e) { console.error('Event init error:', e); }
3407
+
3408
+ // Reveal the toolbar now that all toggle/overflow state is resolved
3409
+ // (hidden via inline style="visibility:hidden" in HTML to prevent FOUC)
3410
+ const _inputBottom = document.querySelector('.chat-input-bottom');
3411
+ if (_inputBottom) _inputBottom.style.visibility = '';
3412
+
3413
+ fileHandlerModule.init(API_BASE);
3414
+ modelsModule.init(API_BASE);
3415
+ ragModule.init(API_BASE);
3416
+ presetsModule.init(API_BASE);
3417
+ searchModule.init(API_BASE);
3418
+ chatModule.init(API_BASE);
3419
+ chatModule.initListeners();
3420
+ groupModule.init(API_BASE);
3421
+ // Initialize compare module
3422
+ if (compareModule) {
3423
+ compareModule.init(API_BASE);
3424
+ }
3425
+ researchPanelModule.init(API_BASE, markdownModule, sessionModule);
3426
+ // Initialize document editor module
3427
+ if (documentModule) {
3428
+ documentModule.init(API_BASE);
3429
+ // Restore document panel if it was open before refresh
3430
+ const _curSession = sessionModule && sessionModule.getCurrentSessionId();
3431
+ if (_curSession && localStorage.getItem('zero-doc-open-' + _curSession) === '1') {
3432
+ documentModule.loadSessionDocs(_curSession);
3433
+ }
3434
+ }
3435
+ // Initialize search chat module
3436
+ if (searchChatModule) {
3437
+ searchChatModule.init(API_BASE);
3438
+ }
3439
+
3440
+ // Search buttons — icon rail + sidebar
3441
+ const railSearchBtn = el('rail-search-btn');
3442
+ if (railSearchBtn) {
3443
+ railSearchBtn.addEventListener('click', () => {
3444
+ if (searchChatModule) searchChatModule.openSearch();
3445
+ });
3446
+ }
3447
+
3448
+ // Rail tool buttons — delegate to sidebar tool buttons
3449
+ const _railToolMap = {
3450
+ 'rail-compare': 'tool-compare-btn',
3451
+ 'rail-research': 'tool-research-btn',
3452
+ 'rail-cookbook': 'tool-cookbook-btn',
3453
+ 'rail-archive': 'tool-library-btn',
3454
+ 'rail-gallery': 'tool-gallery-btn',
3455
+ 'rail-tasks': 'tool-tasks-btn',
3456
+ 'rail-calendar': 'tool-calendar-btn',
3457
+ 'rail-notes': 'tool-notes-btn',
3458
+ 'rail-memory': 'tool-memory-btn',
3459
+ 'rail-theme': 'tool-theme-btn',
3460
+ 'rail-email': 'email-section-title',
3461
+ };
3462
+ Object.entries(_railToolMap).forEach(([railId, toolId]) => {
3463
+ const railBtn = el(railId);
3464
+ if (railBtn) {
3465
+ railBtn.addEventListener('click', () => {
3466
+ const toolBtn = el(toolId);
3467
+ if (toolBtn) toolBtn.click();
3468
+ });
3469
+ }
3470
+ });
3471
+
3472
+ // Rail chats — click to open the completed background session
3473
+ const _railChatsBtn = el('rail-chats');
3474
+ if (_railChatsBtn) {
3475
+ _railChatsBtn.addEventListener('click', () => {
3476
+ const targetSid = _railChatsBtn.dataset.targetSession;
3477
+ if (targetSid && window.sessionModule) {
3478
+ window.sessionModule.selectSession(targetSid);
3479
+ }
3480
+ // Clear notification — session will call clearStreamComplete on load
3481
+ _railChatsBtn.classList.remove('rail-notify', 'rail-notify-success');
3482
+ delete _railChatsBtn.dataset.targetSession;
3483
+ _syncRailDynamic();
3484
+ });
3485
+ }
3486
+
3487
+ // Rail documents — toggle doc panel on/off (not library)
3488
+ const _railDocsBtn = el('rail-documents');
3489
+ if (_railDocsBtn) {
3490
+ _railDocsBtn.addEventListener('click', () => {
3491
+ const ob = el('overflow-doc-btn');
3492
+ if (ob) ob.click();
3493
+ });
3494
+ }
3495
+
3496
+ // Rail: settings button
3497
+ const _railSettings = el('rail-settings');
3498
+ if (_railSettings) {
3499
+ _railSettings.addEventListener('click', () => {
3500
+ const sidebar = document.getElementById('sidebar');
3501
+ if (sidebar) sidebar.classList.remove('hidden');
3502
+ syncRailSide();
3503
+ // Scroll to bottom where settings typically are
3504
+ const sidebarInner = document.querySelector('.sidebar-inner');
3505
+ if (sidebarInner) sidebarInner.scrollTo({ top: sidebarInner.scrollHeight, behavior: 'smooth' });
3506
+ });
3507
+ }
3508
+
3509
+ // Rail: admin button
3510
+ const _railAdmin = el('rail-admin');
3511
+ if (_railAdmin) {
3512
+ _railAdmin.addEventListener('click', () => {
3513
+ // Try to open admin modal
3514
+ const adminBtn = document.querySelector('[data-modal="admin-modal"]') || el('tool-admin-btn');
3515
+ if (adminBtn) adminBtn.click();
3516
+ });
3517
+ }
3518
+
3519
+ // Sync the contextual rail icons. Tool launchers (calendar/compare/cookbook/
3520
+ // research/gallery/tasks/archive/memory/notes/theme/email) are now
3521
+ // always-visible launchers, so only the doc + background-chat indicators
3522
+ // are shown/hidden dynamically here.
3523
+ function _syncRailDynamic() {
3524
+ // Show doc icon if panel is open OR session has documents
3525
+ const docPanelOpen = window.documentModule && window.documentModule.isPanelOpen();
3526
+ const docIndicator = el('doc-indicator-btn');
3527
+ const hasDocs = docIndicator && docIndicator.classList.contains('visible');
3528
+ const docOpen = docPanelOpen || hasDocs;
3529
+ const hasChatNotif = el('rail-chats')?.classList.contains('rail-notify');
3530
+
3531
+ const _show = (id, visible) => { const b = el(id); if (b) b.style.display = visible ? '' : 'none'; };
3532
+ _show('rail-documents', docOpen);
3533
+ _show('rail-chats', !!hasChatNotif);
3534
+ }
3535
+ window._syncRailDynamic = _syncRailDynamic;
3536
+ // Sync periodically and on key events
3537
+ setInterval(_syncRailDynamic, 1000);
3538
+ document.addEventListener('overflow-state-change', _syncRailDynamic);
3539
+
3540
+ const sidebarSearchBtn = el('sidebar-search-btn');
3541
+ if (sidebarSearchBtn) {
3542
+ sidebarSearchBtn.addEventListener('click', () => {
3543
+ if (searchChatModule) searchChatModule.openSearch();
3544
+ });
3545
+ }
3546
+ // Modify form submit to handle special modes
3547
+ const chatForm = document.getElementById('chat-form');
3548
+ const originalSubmit = chatModule.handleChatSubmit;
3549
+ let _submitting = false;
3550
+
3551
+ function handleSubmit(e) {
3552
+ if (e) e.preventDefault();
3553
+ // Debounce: prevent double-submit while a request is being initiated
3554
+ if (_submitting) return;
3555
+ _submitting = true;
3556
+ // Release after a short delay (stream start sets its own isStreaming guard)
3557
+ setTimeout(() => { _submitting = false; }, 300);
3558
+
3559
+ // Compare mode: route submit to compare handler (same message to all panes)
3560
+ if (compareModule && compareModule.isActive()) {
3561
+ return compareModule.handleCompareSubmit(e);
3562
+ }
3563
+
3564
+ // Group chat: route to group module
3565
+ if (groupModule && groupModule.isActive()) {
3566
+ console.log('[group] Submit intercepted');
3567
+ const msgInput = document.getElementById('message');
3568
+ const msg = msgInput ? msgInput.value.trim() : '';
3569
+ if (!msg) { console.log('[group] Empty message, skipping'); return; }
3570
+ console.log('[group] Sending:', msg);
3571
+ chatRenderer.hideWelcomeScreen();
3572
+ chatRenderer.addMessage('user', msg);
3573
+ msgInput.value = '';
3574
+ groupModule.sendMessage(msg);
3575
+ return;
3576
+ }
3577
+
3578
+ return originalSubmit.call(chatModule, e);
3579
+ }
3580
+
3581
+ chatForm.onsubmit = handleSubmit;
3582
+
3583
+ // ── Dual-purpose send/mic button ──
3584
+ const sendBtn = document.querySelector('.send-btn');
3585
+ const messageInput = el('message');
3586
+ const modelPickerWrap = document.getElementById('model-picker-wrap');
3587
+
3588
+ const _sendIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
3589
+ const _micIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
3590
+ const _stopIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
3591
+ const _newChatIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
3592
+
3593
+ // Expose icons globally so chat.js updateSubmitButton can use them
3594
+ window._zeroBtnIcons = { send: _sendIcon, mic: _micIcon, stop: _stopIcon, newChat: _newChatIcon };
3595
+
3596
+ function _isSttEnabled() {
3597
+ return voiceRecorderModule._sttProvider && voiceRecorderModule._sttProvider !== 'disabled';
3598
+ }
3599
+
3600
+ function _hasAttachments() {
3601
+ return fileHandlerModule.getPendingCount && fileHandlerModule.getPendingCount() > 0;
3602
+ }
3603
+
3604
+ function _updateSendBtnIcon() {
3605
+ if (!sendBtn) return;
3606
+ // Don't override if streaming (stop button) or recording
3607
+ if (sendBtn.dataset.mode === 'streaming' || sendBtn.dataset.mode === 'recording') return;
3608
+ const prevMode = sendBtn.dataset.mode || '';
3609
+ const hasText = messageInput && messageInput.value.trim().length > 0;
3610
+ const hasFiles = _hasAttachments();
3611
+ let newMode;
3612
+ if (!hasText && !hasFiles && _isSttEnabled()) {
3613
+ clearTimeout(sendBtn._collapseTimer);
3614
+ sendBtn.innerHTML = _micIcon;
3615
+ sendBtn.title = 'Record voice';
3616
+ newMode = 'mic';
3617
+ sendBtn.classList.add('mic-mode');
3618
+ sendBtn.classList.remove('newchat-mode', 'newchat-expanded');
3619
+ } else if (!hasText && !hasFiles && !_isSttEnabled()) {
3620
+ clearTimeout(sendBtn._collapseTimer);
3621
+ // Group chat: always show send button, never newchat mode
3622
+ if (groupModule && groupModule.isActive()) {
3623
+ sendBtn.innerHTML = _sendIcon;
3624
+ sendBtn.title = 'Send to group';
3625
+ newMode = 'idle';
3626
+ sendBtn.classList.remove('mic-mode', 'newchat-mode', 'newchat-expanded');
3627
+ } else {
3628
+ // Check if we're already on a fresh empty session (welcome screen visible)
3629
+ const isEmptySession = document.getElementById('chat-container')?.classList.contains('welcome-active');
3630
+ if (isEmptySession) {
3631
+ // Already on new chat — show arrow in muted style (ready to type)
3632
+ sendBtn.innerHTML = _sendIcon;
3633
+ sendBtn.title = 'Send message';
3634
+ newMode = 'idle';
3635
+ sendBtn.classList.add('newchat-mode'); // muted gray style
3636
+ sendBtn.classList.remove('mic-mode', 'newchat-expanded');
3637
+ clearTimeout(sendBtn._expandTimer);
3638
+ } else {
3639
+ sendBtn.innerHTML = _newChatIcon + '<span class="send-btn-label">+ New</span>';
3640
+ sendBtn.title = 'New chat';
3641
+ newMode = 'newchat';
3642
+ sendBtn.classList.add('newchat-mode');
3643
+ sendBtn.classList.remove('mic-mode');
3644
+ // The button stays a 32px compact icon (no auto-expand to label —
3645
+ // the "+ New" label inside is for screen readers only; sighted users
3646
+ // see the spinning + on hover + the title tooltip).
3647
+ clearTimeout(sendBtn._expandTimer);
3648
+ sendBtn.classList.remove('newchat-expanded');
3649
+ }
3650
+ } // close group-else
3651
+ } else {
3652
+ newMode = 'send';
3653
+ clearTimeout(sendBtn._expandTimer);
3654
+ const wasExpanded = sendBtn.classList.contains('newchat-expanded');
3655
+ const wasNewchat = prevMode === 'newchat' || prevMode === 'mic';
3656
+ if (wasExpanded || wasNewchat) {
3657
+ // Collapse pill if expanded, then spin arrow in (same as + spin-in)
3658
+ if (wasExpanded) sendBtn.classList.remove('newchat-expanded');
3659
+ const delay = wasExpanded ? 300 : 0;
3660
+ setTimeout(() => {
3661
+ if (sendBtn.dataset.mode !== 'send') return;
3662
+ sendBtn.innerHTML = _sendIcon;
3663
+ sendBtn.title = 'Send message';
3664
+ sendBtn.classList.remove('mic-mode', 'newchat-mode', 'anim-spin-swap');
3665
+ sendBtn.classList.add('anim-spin');
3666
+ sendBtn.addEventListener('animationend', () => sendBtn.classList.remove('anim-spin'), { once: true });
3667
+ }, delay);
3668
+ } else {
3669
+ sendBtn.innerHTML = _sendIcon;
3670
+ sendBtn.title = 'Send message';
3671
+ sendBtn.classList.remove('mic-mode', 'newchat-mode', 'newchat-expanded', 'anim-spin', 'anim-launch', 'anim-land');
3672
+ }
3673
+ }
3674
+ // Animate icon spin — when switching TO newchat or mic (the + or mic
3675
+ // appearing). The previous `prevMode && ...` guard skipped this after
3676
+ // streaming ended (dataset.mode is reset to '' there, an empty falsy
3677
+ // string), which let the lingering anim-land class from the stop icon's
3678
+ // entry replay on the +, making it look like the + comes from below.
3679
+ // Never animate into send mode (arrow) — it should just appear instantly.
3680
+ if (newMode !== prevMode && (newMode === 'newchat' || newMode === 'mic')) {
3681
+ if (!sendBtn.classList.contains('anim-spin')) {
3682
+ sendBtn.classList.remove('anim-launch', 'anim-land');
3683
+ sendBtn.classList.add('anim-spin');
3684
+ sendBtn.addEventListener('animationend', () => sendBtn.classList.remove('anim-spin'), { once: true });
3685
+ }
3686
+ }
3687
+ sendBtn.dataset.mode = newMode;
3688
+ }
3689
+
3690
+ if (sendBtn) {
3691
+ sendBtn.addEventListener('click', (e) => {
3692
+ e.preventDefault();
3693
+
3694
+ // If recording, stop recording
3695
+ if (sendBtn.dataset.mode === 'recording' || voiceRecorderModule.getIsRecording()) {
3696
+ voiceRecorderModule.stopRecording();
3697
+ return;
3698
+ }
3699
+
3700
+ const hasText = messageInput && messageInput.value.trim().length > 0;
3701
+ const hasFiles = _hasAttachments();
3702
+
3703
+ // New chat mode — empty input, no attachments, no STT
3704
+ if (!hasText && !hasFiles && sendBtn.dataset.mode === 'newchat') {
3705
+ if (sessionModule) {
3706
+ const sessions = sessionModule.getSessions();
3707
+ const currentId = sessionModule.getCurrentSessionId();
3708
+ const current = sessions.find(s => s.id === currentId);
3709
+ if (current && current.endpoint_url && current.model) {
3710
+ sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id);
3711
+ } else {
3712
+ // Fallback to rail button
3713
+ const railNew = el('rail-new-session');
3714
+ if (railNew) railNew.click();
3715
+ }
3716
+ }
3717
+ return;
3718
+ }
3719
+
3720
+ // If input is empty and STT is enabled, start recording
3721
+ if (!hasText && !hasFiles && _isSttEnabled()) {
3722
+ sendBtn.innerHTML = _stopIcon;
3723
+ sendBtn.title = 'Stop recording';
3724
+ sendBtn.dataset.mode = 'recording';
3725
+ sendBtn.classList.add('recording');
3726
+ voiceRecorderModule.startRecording(
3727
+ (audioFile) => fileHandlerModule.addFiles([audioFile]),
3728
+ uiModule.showToast,
3729
+ uiModule.showError
3730
+ );
3731
+ return;
3732
+ }
3733
+
3734
+ // Otherwise, send message
3735
+ handleSubmit(e);
3736
+ });
3737
+ }
3738
+
3739
+ // Enter to send (shift+enter for newline), or new chat when empty
3740
+ if (messageInput) {
3741
+ messageInput.addEventListener('keydown', (e) => {
3742
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
3743
+ e.preventDefault();
3744
+ // Flush the debounced icon update so dataset.mode reflects the current
3745
+ // text state. Without this, a fast type-and-Enter would still see the
3746
+ // stale 'newchat' mode and open a new chat instead of sending.
3747
+ try { _updateSendBtnIcon(); } catch {}
3748
+ if (sendBtn && sendBtn.dataset.mode === 'newchat') {
3749
+ const railNew = el('rail-new-session');
3750
+ if (railNew) railNew.click();
3751
+ return;
3752
+ }
3753
+ handleSubmit(e);
3754
+ }
3755
+ });
3756
+ }
3757
+
3758
+ // Toggle mic/send icon on input change + hide model picker after enough text
3759
+ if (messageInput) {
3760
+ const _debouncedUpdateIcon = uiModule.debounce(_updateSendBtnIcon, 50);
3761
+ const _MODEL_PICKER_HIDE_CHARS = 10;
3762
+ const _syncModelPickerAutohide = () => {
3763
+ const hidePicker = (messageInput.value || '').replace(/\s/g, '').length >= _MODEL_PICKER_HIDE_CHARS;
3764
+ if (modelPickerWrap) {
3765
+ modelPickerWrap.classList.toggle('model-picker-autohide', hidePicker);
3766
+ }
3767
+ };
3768
+ window._syncModelPickerAutohide = _syncModelPickerAutohide;
3769
+ _syncModelPickerAutohide();
3770
+ messageInput.addEventListener('input', () => {
3771
+ _syncModelPickerAutohide();
3772
+ _debouncedUpdateIcon();
3773
+ }, { passive: true });
3774
+ }
3775
+
3776
+ // Collapse "New Session" label on scroll
3777
+ const _chatScroll = document.getElementById('chat-container');
3778
+ if (_chatScroll && sendBtn) {
3779
+ _chatScroll.addEventListener('scroll', () => {
3780
+ if (sendBtn.classList.contains('newchat-expanded')) {
3781
+ sendBtn.classList.remove('newchat-expanded');
3782
+ }
3783
+ }, { passive: true });
3784
+ }
3785
+
3786
+ // Expose globally so voiceRecorder can trigger update after async fetch
3787
+ window._updateSendBtnIcon = _updateSendBtnIcon;
3788
+
3789
+ // Initial icon state
3790
+ _updateSendBtnIcon();
3791
+
3792
+ // Auto-focus input on load
3793
+ if (messageInput) {
3794
+ setTimeout(() => messageInput.focus(), 100);
3795
+ }
3796
+
3797
+ // Add drag and drop handlers for the chat container
3798
+ const chatContainer = el('chat-container');
3799
+
3800
+ // Prevent default to allow drop
3801
+ const chatInputBar = chatContainer.querySelector('.chat-input-bar');
3802
+ function _showDropHighlight() {
3803
+ chatContainer.style.backgroundColor = 'rgba(0, 170, 255, 0.1)';
3804
+ chatContainer.style.transition = 'background-color 0.2s ease';
3805
+ if (chatInputBar) {
3806
+ chatInputBar.style.outline = '2px dashed color-mix(in srgb, var(--accent, #0af) 50%, transparent)';
3807
+ chatInputBar.style.outlineOffset = '-2px';
3808
+ chatInputBar.style.background = 'color-mix(in srgb, var(--accent, #0af) 8%, var(--bg))';
3809
+ chatInputBar.style.transition = 'outline 0.2s ease, background 0.2s ease';
3810
+ }
3811
+ }
3812
+ function _hideDropHighlight() {
3813
+ chatContainer.style.backgroundColor = '';
3814
+ if (chatInputBar) {
3815
+ chatInputBar.style.outline = '';
3816
+ chatInputBar.style.outlineOffset = '';
3817
+ chatInputBar.style.background = '';
3818
+ }
3819
+ }
3820
+
3821
+ chatContainer.addEventListener('dragover', (e) => {
3822
+ e.preventDefault();
3823
+ e.stopPropagation();
3824
+ _showDropHighlight();
3825
+ });
3826
+
3827
+ chatContainer.addEventListener('drop', (e) => {
3828
+ e.preventDefault();
3829
+ e.stopPropagation();
3830
+ _hideDropHighlight();
3831
+ const files = Array.from(e.dataTransfer.files);
3832
+ if (files.length === 0) return;
3833
+ fileHandlerModule.addFiles(files);
3834
+ fileHandlerModule.renderAttachStrip();
3835
+ uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to chat`);
3836
+ });
3837
+
3838
+ chatContainer.addEventListener('dragleave', (e) => {
3839
+ e.preventDefault();
3840
+ _hideDropHighlight();
3841
+ });
3842
+
3843
+ // Make the attachment strip also a drop target
3844
+ const attachStrip = el('attach-strip');
3845
+ attachStrip.addEventListener('dragover', (e) => {
3846
+ e.preventDefault();
3847
+ attachStrip.style.backgroundColor = 'rgba(0, 170, 255, 0.1)';
3848
+ attachStrip.style.borderRadius = '4px';
3849
+ });
3850
+
3851
+ attachStrip.addEventListener('drop', (e) => {
3852
+ e.preventDefault();
3853
+ attachStrip.style.backgroundColor = '';
3854
+
3855
+ const files = Array.from(e.dataTransfer.files);
3856
+ if (files.length === 0) return;
3857
+
3858
+ uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to chat`);
3859
+
3860
+ });
3861
+
3862
+ attachStrip.addEventListener('dragleave', (e) => {
3863
+ e.preventDefault();
3864
+ attachStrip.style.backgroundColor = '';
3865
+ });
3866
+
3867
+ // ── Compare-mode file drop shield ──────────────────────────────────────────
3868
+ // Compare reuses #chat-container, but each pane renders into a sandboxed
3869
+ // <iframe>. Iframes swallow drag-and-drop events: a file dropped on a pane is
3870
+ // handled by the iframe, not the parent, so the browser loads the file *inside
3871
+ // the pane* ("behind" the app) instead of attaching it. The chatContainer drop
3872
+ // handler above never sees it because the event doesn't bubble out of the frame.
3873
+ //
3874
+ // Fix: while a file drag is active in Compare, raise a single full-window shield
3875
+ // that sits above every pane/iframe and becomes the drop target. The drop then
3876
+ // lands on the parent document and we route the files into the shared composer
3877
+ // (the same pending-files pipeline the picker and paste use). Scoped to Compare
3878
+ // via the .compare-active class, so normal chat and the tool dropzones (gallery,
3879
+ // RAG, document editor, …) are unaffected.
3880
+ let _cmpDropShield = null;
3881
+ const _isFileDrag = (e) => {
3882
+ const types = e.dataTransfer && e.dataTransfer.types;
3883
+ return !!types && Array.prototype.indexOf.call(types, 'Files') !== -1;
3884
+ };
3885
+ const _compareActive = () => {
3886
+ const c = el('chat-container');
3887
+ return !!c && c.classList.contains('compare-active');
3888
+ };
3889
+ const _showCmpShield = () => {
3890
+ if (!_cmpDropShield) {
3891
+ _cmpDropShield = document.createElement('div');
3892
+ _cmpDropShield.id = 'compare-drop-shield';
3893
+ _cmpDropShield.setAttribute('aria-hidden', 'true');
3894
+ _cmpDropShield.style.cssText = 'position:fixed;inset:0;z-index:2147483646;' +
3895
+ 'display:none;align-items:center;justify-content:center;' +
3896
+ 'background:color-mix(in srgb, var(--accent, #0af) 16%, rgba(0,0,0,0.5));' +
3897
+ 'backdrop-filter:blur(2px);';
3898
+ const _box = document.createElement('div');
3899
+ _box.style.cssText = 'pointer-events:none;border:2px dashed rgba(255,255,255,0.9);' +
3900
+ 'border-radius:14px;padding:20px 28px;background:rgba(0,0,0,0.4);' +
3901
+ 'font:600 16px/1.4 system-ui,sans-serif;color:#fff;';
3902
+ _box.textContent = 'Drop files to attach';
3903
+ _cmpDropShield.appendChild(_box);
3904
+ document.body.appendChild(_cmpDropShield);
3905
+ }
3906
+ _cmpDropShield.style.display = 'flex';
3907
+ };
3908
+ const _hideCmpShield = () => { if (_cmpDropShield) _cmpDropShield.style.display = 'none'; };
3909
+ // Capture phase so we raise the shield before the pointer reaches an iframe.
3910
+ window.addEventListener('dragenter', (e) => {
3911
+ if (_isFileDrag(e) && _compareActive()) _showCmpShield();
3912
+ }, true);
3913
+ window.addEventListener('dragover', (e) => {
3914
+ if (!_isFileDrag(e) || !_compareActive()) return;
3915
+ e.preventDefault(); // mark as a valid drop target
3916
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
3917
+ _showCmpShield();
3918
+ }, true);
3919
+ window.addEventListener('dragleave', (e) => {
3920
+ // Hide only when the drag actually leaves the window (no relatedTarget).
3921
+ if (_compareActive() && !e.relatedTarget) _hideCmpShield();
3922
+ }, true);
3923
+ window.addEventListener('dragend', _hideCmpShield, true);
3924
+ window.addEventListener('drop', (e) => {
3925
+ if (!_isFileDrag(e) || !_compareActive()) return;
3926
+ e.preventDefault();
3927
+ _hideCmpShield();
3928
+ const files = Array.from(e.dataTransfer.files || []);
3929
+ if (!files.length) return;
3930
+ fileHandlerModule.addFiles(files);
3931
+ fileHandlerModule.renderAttachStrip();
3932
+ uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to attach`);
3933
+ }, true);
3934
+
3935
+ // Load initial data
3936
+ presetsModule.loadPresets(uiModule.showError);
3937
+
3938
+ if (sessionModule) {
3939
+ sessionModule.initDependencies({
3940
+ API_BASE: API_BASE,
3941
+ el: el,
3942
+ showToast: uiModule.showToast,
3943
+ showError: uiModule.showError,
3944
+ addMessage: chatModule.addMessage,
3945
+ renderContent: markdownModule.renderContent,
3946
+ scrollHistory: uiModule.scrollHistoryInstant
3947
+ });
3948
+
3949
+ // Load sessions first (critical path) — remove loader when done
3950
+ sessionModule.loadSessions()
3951
+ .catch(e => console.warn('loadSessions error:', e))
3952
+ .finally(() => {
3953
+ const loader = document.getElementById('app-loader');
3954
+ if (loader) { loader.style.opacity = '0'; setTimeout(() => loader.remove(), 300); }
3955
+ // Fire any URL route opener now that sessions + module wiring are
3956
+ // ready. Deferred from up top of init for exactly this reason.
3957
+ if (window._zeroRouteOpener) {
3958
+ try { window._zeroRouteOpener(); } catch (_) {}
3959
+ window._zeroRouteOpener = null;
3960
+ }
3961
+ });
3962
+ } else {
3963
+ console.error('Session module not loaded!');
3964
+ }
3965
+
3966
+ // Non-critical: load in parallel, resolve silently
3967
+ modelsModule.refreshModels(true).then(() => {
3968
+ const modelsBox = document.getElementById('models');
3969
+ const hasModels = modelsBox && modelsBox.querySelector('.models-row');
3970
+ if (!hasModels) {
3971
+ const tip = document.getElementById('welcome-tip');
3972
+ if (tip) tip.textContent = 'Add an AI endpoint from Settings in the sidebar, or paste an endpoint/API key into the chat.';
3973
+ }
3974
+ }).catch(() => {});
3975
+ modelsModule.refreshProviders();
3976
+ ragModule.loadPersonalDocs();
3977
+ memoryModule.loadMemories(); // Ensure memories are loaded on page load
3978
+
3979
+ // Ensure the memory list is rendered after loading
3980
+ setTimeout(async () => {
3981
+ await memoryModule.loadMemories();
3982
+ }, 1000);
3983
+
3984
+ // Ensure proper initial state
3985
+ voiceRecorderModule.init();
3986
+ if (censorModule) censorModule.init();
3987
+
3988
+ // Auto-focus message input on load
3989
+ const msgEl = document.getElementById('message');
3990
+ if (msgEl) msgEl.focus();
3991
+
3992
+ // Initialize mouse-based drag for sidebar sections
3993
+ const sidebar = document.getElementById('sidebar');
3994
+ const sidebarInner = sidebar ? sidebar.querySelector('.sidebar-inner') : sidebar;
3995
+
3996
+ // ── Subtle elastic overscroll for sidebar ──
3997
+ if (sidebarInner) {
3998
+ const MAX_PULL = 8;
3999
+ let _overscroll = 0;
4000
+ let _resetTimer = null;
4001
+ sidebarInner.addEventListener('wheel', (e) => {
4002
+ const el = sidebarInner;
4003
+ const atTop = el.scrollTop <= 0 && e.deltaY < 0;
4004
+ const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1 && e.deltaY > 0;
4005
+ if (!atTop && !atBottom) { _overscroll = 0; return; }
4006
+ // Accumulate overscroll with diminishing returns
4007
+ _overscroll += Math.abs(e.deltaY) * 0.15;
4008
+ const pull = Math.min(_overscroll, MAX_PULL);
4009
+ const dir = atTop ? 1 : -1;
4010
+ el.style.transition = 'none';
4011
+ el.style.transform = `translateY(${dir * pull}px)`;
4012
+ // Reset after scrolling stops
4013
+ clearTimeout(_resetTimer);
4014
+ _resetTimer = setTimeout(() => {
4015
+ el.style.transition = 'transform 0.3s cubic-bezier(0.25, 1, 0.5, 1)';
4016
+ el.style.transform = '';
4017
+ _overscroll = 0;
4018
+ }, 120);
4019
+ }, { passive: true });
4020
+ }
4021
+
4022
+ // ── Global touch-scroll guard for sidebar ──
4023
+ // Suppress click events when the user was scrolling (finger moved).
4024
+ // This prevents accidental session/model/setting selection while swiping.
4025
+ if (sidebarInner && 'ontouchstart' in window) {
4026
+ let _sidebarTouchMoved = false;
4027
+ let _sidebarTouchStartY = 0;
4028
+ sidebarInner.addEventListener('touchstart', (e) => {
4029
+ _sidebarTouchMoved = false;
4030
+ _sidebarTouchStartY = e.touches[0].clientY;
4031
+ }, { passive: true });
4032
+ sidebarInner.addEventListener('touchmove', (e) => {
4033
+ // Only flag as scroll if finger moved more than 8px vertically
4034
+ if (Math.abs(e.touches[0].clientY - _sidebarTouchStartY) > 8) {
4035
+ _sidebarTouchMoved = true;
4036
+ }
4037
+ }, { passive: true });
4038
+ sidebarInner.addEventListener('click', (e) => {
4039
+ if (_sidebarTouchMoved) {
4040
+ e.stopPropagation();
4041
+ e.preventDefault();
4042
+ _sidebarTouchMoved = false;
4043
+ }
4044
+ }, true); // capture phase — intercepts before any child handlers
4045
+ }
4046
+
4047
+ // Section collapse/expand + drag reorder (extracted to js/section-management.js)
4048
+ initSectionCollapse(Storage);
4049
+ initSectionDrag(Storage, loadUIVis);
4050
+
4051
+ // Handle drag over and out for individual sections
4052
+ const sections = document.querySelectorAll('.section[draggable="true"]');
4053
+ sections.forEach(section => {
4054
+ section.addEventListener('dragover', (e) => {
4055
+ e.preventDefault();
4056
+ e.dataTransfer.dropEffect = 'move';
4057
+
4058
+ // Only show visual feedback if we're not dragging over the active element
4059
+ const activeId = e.dataTransfer.getData('text/plain');
4060
+ if (activeId && activeId !== section.id) {
4061
+ section.setAttribute('dnd-over', 'true');
4062
+ }
4063
+ });
4064
+
4065
+ section.addEventListener('dragleave', (e) => {
4066
+ // Check if we're actually leaving the element
4067
+ const rect = section.getBoundingClientRect();
4068
+ if (e.clientY < rect.top || e.clientY > rect.bottom ||
4069
+ e.clientX < rect.left || e.clientX > rect.right) {
4070
+ section.setAttribute('dnd-over', 'false');
4071
+ }
4072
+ });
4073
+ });
4074
+
4075
+ // Restore saved order on load
4076
+ const savedOrder = Storage.get(Storage.KEYS.SECTION_ORDER);
4077
+ if (savedOrder) {
4078
+ try {
4079
+ const order = JSON.parse(savedOrder);
4080
+ const innerContainer = sidebarInner || document.getElementById('sidebar');
4081
+
4082
+ // Create a document fragment to minimize reflows
4083
+ const fragment = document.createDocumentFragment();
4084
+
4085
+ // First, collect all sections in the desired order
4086
+ for (const id of order) {
4087
+ const section = document.getElementById(id);
4088
+ if (section) {
4089
+ fragment.appendChild(section);
4090
+ }
4091
+ }
4092
+
4093
+ // Append any remaining sections (in case new ones were added)
4094
+ sections.forEach(section => {
4095
+ if (!order.includes(section.id)) {
4096
+ fragment.appendChild(section);
4097
+ }
4098
+ });
4099
+
4100
+ // Finally, add all sections back to the container
4101
+ innerContainer.appendChild(fragment);
4102
+ } catch (e) {
4103
+ console.error('Failed to restore sidebar order:', e);
4104
+ }
4105
+ }
4106
+
4107
+
4108
+
4109
+ if (window.hljs) {
4110
+ console.log('Highlighting all code blocks on page load');
4111
+ document.querySelectorAll('pre code:not(.hljs)').forEach(block => {
4112
+ window.hljs.highlightElement(block);
4113
+ });
4114
+ }
4115
+ }
4116
+
4117
+ if (document.readyState === 'loading') {
4118
+ document.addEventListener('DOMContentLoaded', startZeroApp, { once: true });
4119
+ } else {
4120
+ startZeroApp();
4121
+ }