@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,3376 @@
1
+ // static/js/documentLibrary.js
2
+ /**
3
+ * Document Library — modal with Chats / Documents / Research / Archive tabs.
4
+ * Extracted from document.js to reduce file size.
5
+ */
6
+
7
+ import uiModule from './ui.js';
8
+ import sessionModule from './sessions.js';
9
+ import spinnerModule from './spinner.js';
10
+ import markdownModule from './markdown.js';
11
+ import { makeWindowDraggable } from './windowDrag.js';
12
+ import { langIcon } from './langIcons.js';
13
+ import { registerMenuDismiss, dismissOrRemove } from './escMenuStack.js';
14
+
15
+ // ── Injected references from documentModule ──
16
+ let API_BASE = '';
17
+ let _esc; // HTML-escape function
18
+ let _getDocs; // () => Map of open docs
19
+ let _isOpenFn; // () => boolean — is doc panel open
20
+ let _createDocument;
21
+ let _loadDocument;
22
+ let _switchToDoc;
23
+ let _openPanel;
24
+ let _addDocToTabs;
25
+ let _syncDocIndicator;
26
+
27
+ export function initLibrary(config) {
28
+ API_BASE = config.apiBase;
29
+ _esc = config.esc;
30
+ _getDocs = config.getDocs;
31
+ _isOpenFn = config.isOpen;
32
+ _createDocument = config.createDocument;
33
+ _loadDocument = config.loadDocument;
34
+ _switchToDoc = config.switchToDoc;
35
+ _openPanel = config.openPanel;
36
+ _addDocToTabs = config.addDocToTabs;
37
+ _syncDocIndicator = config.syncDocIndicator;
38
+ }
39
+
40
+ // ── Library state ──
41
+ let _libraryOpen = false;
42
+ // Track which tabs have already played their domino-in cascade so we only
43
+ // animate the *first* time content loads per page session — tab swaps and
44
+ // re-renders after that are instant.
45
+ const _libraryCascadedTabs = new Set();
46
+ function _maybeCascadeGrid(grid, tabKey) {
47
+ if (!grid || !tabKey || _libraryCascadedTabs.has(tabKey)) return;
48
+ _libraryCascadedTabs.add(tabKey);
49
+ grid.classList.add('doclib-just-opened');
50
+ setTimeout(() => grid.classList.remove('doclib-just-opened'), 900);
51
+ }
52
+ let _libraryDocs = [];
53
+ let _libraryTotal = 0;
54
+ let _libraryOffset = 0;
55
+ let _docsVisibleLimit = 20; // chunked reveal (matches the Chats tab's 20)
56
+ let _libraryLanguages = {};
57
+ let _librarySessionCount = 0;
58
+ let _libraryActiveLanguage = null;
59
+ let _librarySort = 'recent';
60
+ let _librarySearch = '';
61
+ let _librarySearchDebounce = null;
62
+
63
+ // Highlight the active search terms inside a plain string. Escapes first,
64
+ // then wraps each whitespace-separated term in <mark>. Multi-term, matching
65
+ // the backend's per-term search, so every word that matched is marked.
66
+ function _hlSearch(text) {
67
+ const esc = _esc(text || '');
68
+ const q = (_librarySearch || '').trim();
69
+ if (!q) return esc;
70
+ const toks = [...new Set(q.split(/\s+/).filter(Boolean))]
71
+ .sort((a, b) => b.length - a.length) // prefer longer matches
72
+ .map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
73
+ if (!toks.length) return esc;
74
+ try {
75
+ return esc.replace(new RegExp(`(${toks.join('|')})`, 'gi'),
76
+ '<mark class="doclib-search-hl">$1</mark>');
77
+ } catch { return esc; }
78
+ }
79
+ let _libraryEscHandler = null;
80
+ let _librarySelectMode = false;
81
+ let _librarySelectedIds = new Set();
82
+ let _libraryImportMode = false;
83
+ let _libScrollBound = false; // infinite-scroll listener attached once
84
+ let _libraryArchivedView = false; // Documents tab showing archived docs?
85
+
86
+ // ---- Library animation helpers ----
87
+
88
+ /** Collapse an expanded card */
89
+ function _collapseExpandedCard(card) {
90
+ const grid = card.closest('.doclib-grid');
91
+ const instant = card?.dataset?.spaceToggle === '1';
92
+ card.classList.remove('doclib-card-expanded');
93
+ // Release the height lock so grid returns to natural size
94
+ if (grid) {
95
+ grid.style.minHeight = '';
96
+ grid.style.maxHeight = '';
97
+ }
98
+ const reader = card.querySelector('.doclib-card-reader');
99
+ if (reader) reader.remove();
100
+
101
+ // Fade siblings back in
102
+ if (grid && !instant) {
103
+ const siblings = [...grid.querySelectorAll('.doclib-card')].filter(c => c !== card);
104
+ siblings.forEach(s => { s.style.opacity = '0'; });
105
+ requestAnimationFrame(() => {
106
+ siblings.forEach(s => {
107
+ s.style.transition = 'opacity 0.15s ease';
108
+ s.style.opacity = '1';
109
+ });
110
+ setTimeout(() => { siblings.forEach(s => { s.style.transition = ''; s.style.opacity = ''; }); }, 200);
111
+ });
112
+ }
113
+ }
114
+
115
+ // Fetch a chat's full history and serialize as plain-text transcript,
116
+ // then write to the clipboard. Same User: / Assistant: format the chat
117
+ // header's "Copy Chat" button uses, but works for any session ID — the
118
+ // library doesn't need the chat to be loaded in the UI first.
119
+ async function _copyChatById(sessionId) {
120
+ try {
121
+ const res = await fetch(`${API_BASE}/api/history/${sessionId}`, { credentials: 'same-origin' });
122
+ if (!res.ok) throw new Error(res.statusText);
123
+ const data = await res.json();
124
+ const history = Array.isArray(data) ? data : (data.history || []);
125
+ const lines = [];
126
+ for (const m of history) {
127
+ if (m.role !== 'user' && m.role !== 'assistant') continue;
128
+ const label = m.role === 'user' ? 'User' : 'Assistant';
129
+ const body = (m.content || '')
130
+ .replace(/<think>[\s\S]*?<\/think>/g, '')
131
+ .replace(/<think>[\s\S]*$/, '')
132
+ .trim();
133
+ if (body) lines.push(`${label}: ${body}`);
134
+ }
135
+ const text = lines.join('\n\n');
136
+ if (uiModule && uiModule.copyToClipboard) {
137
+ await uiModule.copyToClipboard(text);
138
+ } else {
139
+ await navigator.clipboard.writeText(text);
140
+ }
141
+ } catch (err) {
142
+ if (uiModule && uiModule.showError) uiModule.showError('Failed to copy chat');
143
+ }
144
+ }
145
+
146
+ // Long-press a list card to open its actions menu. `menuSelector` resolves
147
+ // the existing ••• button on the card; on hold we trigger its click so the
148
+ // dropdown opens in its usual spot. Moved finger >10px or release before
149
+ // 500ms cancels.
150
+ function _attachLongPressMenu(card, menuSelector) {
151
+ let hold = null;
152
+ let start = null;
153
+ const cancel = () => { if (hold) { clearTimeout(hold); hold = null; } start = null; };
154
+ card.addEventListener('pointerdown', (e) => {
155
+ if (e.target.closest(menuSelector + ', .memory-select-cb, button')) return;
156
+ start = { x: e.clientX, y: e.clientY };
157
+ hold = setTimeout(() => {
158
+ hold = null;
159
+ card._suppressNextClick = true;
160
+ setTimeout(() => { card._suppressNextClick = false; }, 400);
161
+ if (navigator.vibrate) try { navigator.vibrate(15); } catch {}
162
+ const btn = card.querySelector(menuSelector);
163
+ if (btn) btn.click();
164
+ }, 500);
165
+ });
166
+ card.addEventListener('pointermove', (e) => {
167
+ if (!start) return;
168
+ if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > 10) cancel();
169
+ });
170
+ card.addEventListener('pointerup', cancel);
171
+ card.addEventListener('pointercancel', cancel);
172
+ }
173
+
174
+ // Inline icons used by the chats/archive/research dropdown rows. Match the
175
+ // ones used by the documents-tab card menu so the visual language stays
176
+ // consistent across tabs.
177
+ const _LIB_DD_ICONS = {
178
+ open: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>',
179
+ archive: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>',
180
+ restore: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9"/><polyline points="3 4 3 9 8 9"/></svg>',
181
+ delete: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>',
182
+ clone: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
183
+ copy: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
184
+ };
185
+
186
+ function _showLibDropdown(anchor, items, opts) {
187
+ opts = opts || {};
188
+ document.querySelectorAll('._lib-dd').forEach(dismissOrRemove);
189
+ const dd = document.createElement('div');
190
+ dd.className = 'dropdown session-dropdown-menu _lib-dd';
191
+ for (const item of items) {
192
+ const row = document.createElement('div');
193
+ row.className = 'dropdown-item-compact' + (item.danger ? ' dropdown-item-danger' : '');
194
+ const iconKey = item.icon || item.label.toLowerCase();
195
+ const iconSvg = _LIB_DD_ICONS[iconKey] || '';
196
+ row.innerHTML = (iconSvg ? '<span class="dropdown-icon">' + iconSvg + '</span>' : '') + '<span>' + item.label + '</span>';
197
+ row.addEventListener('click', (e) => { e.stopPropagation(); teardown(); item.action(); });
198
+ dd.appendChild(row);
199
+ }
200
+ if (typeof opts.onSelect === 'function') {
201
+ const sel = document.createElement('div');
202
+ sel.className = 'dropdown-item-compact';
203
+ sel.innerHTML =
204
+ '<span class="dropdown-icon"><span style="font-size:16px;line-height:1;position:relative;top:-2px;">●</span></span>'
205
+ + '<span>Select</span>';
206
+ sel.addEventListener('click', (e) => { e.stopPropagation(); teardown(); opts.onSelect(); });
207
+ dd.appendChild(sel);
208
+ }
209
+ const cancel = document.createElement('div');
210
+ cancel.className = 'dropdown-item-compact dropdown-cancel-mobile';
211
+ cancel.innerHTML =
212
+ '<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>'
213
+ + '<span>Cancel</span>';
214
+ cancel.addEventListener('click', (e) => { e.stopPropagation(); teardown(); if (typeof opts.onCancel === 'function') opts.onCancel(); });
215
+ dd.appendChild(cancel);
216
+ document.body.appendChild(dd);
217
+ const rect = anchor.getBoundingClientRect();
218
+ dd.style.right = (window.innerWidth - rect.right) + 'px';
219
+ dd.style.top = (rect.bottom + 2) + 'px';
220
+ dd.style.display = 'block';
221
+ dd.style.zIndex = '100000';
222
+ requestAnimationFrame(() => {
223
+ const mr = dd.getBoundingClientRect();
224
+ if (mr.bottom > window.innerHeight - 8) {
225
+ dd.style.top = (rect.top - mr.height - 2) + 'px';
226
+ }
227
+ if (mr.left < 8) { dd.style.left = '8px'; dd.style.right = 'auto'; }
228
+ });
229
+ // Single idempotent teardown shared by every dismissal path (item click,
230
+ // outside click, swipe, and the Escape arbiter via registerMenuDismiss).
231
+ let _unreg = () => {};
232
+ const teardown = () => {
233
+ _unreg(); _unreg = () => {};
234
+ document.removeEventListener('click', close);
235
+ dd.remove();
236
+ };
237
+ const close = (e) => { if (!dd.contains(e.target)) teardown(); };
238
+ setTimeout(() => document.addEventListener('click', close), 0);
239
+ _unreg = registerMenuDismiss(teardown);
240
+ dd._dismiss = teardown; // let bulk removers (reopen sweep) tear down cleanly
241
+
242
+ // Swipe-down-to-dismiss (mobile). Mirrors the bottom-sheet feel — drag the
243
+ // popup down and release past the threshold to close. Below threshold,
244
+ // snap back. Vertical-only; horizontal flicks fall through to scrolling.
245
+ let _swipeStart = null;
246
+ let _swipeDy = 0;
247
+ dd.addEventListener('touchstart', (e) => {
248
+ if (e.touches.length !== 1) return;
249
+ _swipeStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
250
+ _swipeDy = 0;
251
+ dd.style.transition = '';
252
+ }, { passive: true });
253
+ dd.addEventListener('touchmove', (e) => {
254
+ if (!_swipeStart || e.touches.length !== 1) return;
255
+ const dx = e.touches[0].clientX - _swipeStart.x;
256
+ const dy = e.touches[0].clientY - _swipeStart.y;
257
+ if (Math.abs(dy) < Math.abs(dx)) { _swipeStart = null; return; }
258
+ if (dy > 0) {
259
+ _swipeDy = dy;
260
+ dd.style.transform = 'translateY(' + dy + 'px)';
261
+ dd.style.opacity = String(Math.max(0.3, 1 - dy / 240));
262
+ }
263
+ }, { passive: true });
264
+ dd.addEventListener('touchend', () => {
265
+ if (!_swipeStart) return;
266
+ _swipeStart = null;
267
+ if (_swipeDy > 60) {
268
+ dd.style.transition = 'transform 0.15s ease, opacity 0.15s ease';
269
+ dd.style.transform = 'translateY(120px)';
270
+ dd.style.opacity = '0';
271
+ // Unregister + drop the outside-click listener now; defer the DOM
272
+ // removal so the slide-out animation can play.
273
+ _unreg(); _unreg = () => {};
274
+ document.removeEventListener('click', close);
275
+ setTimeout(() => dd.remove(), 160);
276
+ } else {
277
+ dd.style.transition = 'transform 0.18s ease, opacity 0.18s ease';
278
+ dd.style.transform = '';
279
+ dd.style.opacity = '';
280
+ }
281
+ });
282
+ }
283
+
284
+ // ---- Document Library ----
285
+
286
+ function libraryRelativeTime(isoString) {
287
+ if (!isoString) return '';
288
+ const now = Date.now();
289
+ const then = new Date(isoString).getTime();
290
+ const diffS = Math.floor((now - then) / 1000);
291
+ if (diffS < 60) return 'just now';
292
+ const diffM = Math.floor(diffS / 60);
293
+ if (diffM < 60) return diffM + 'm ago';
294
+ const diffH = Math.floor(diffM / 60);
295
+ if (diffH < 24) return diffH + 'h ago';
296
+ const diffD = Math.floor(diffH / 24);
297
+ if (diffD === 1) return 'yesterday';
298
+ if (diffD < 14) return diffD + 'd ago';
299
+ const diffW = Math.floor(diffD / 7);
300
+ if (diffW < 8) return diffW + 'w ago';
301
+ return new Date(isoString).toLocaleDateString();
302
+ }
303
+
304
+ async function libraryFetch(append) {
305
+ if (!append) _libraryOffset = 0;
306
+ // Bump page size to the backend max (50) so fullscreen doesn't leave
307
+ // empty space below the loaded rows — same idea as emailLibrary's
308
+ // limit=100, but documents_library validates `le=50` so we have to
309
+ // cap at that. Auto-fill loop below picks up any remaining gap.
310
+ const params = new URLSearchParams({
311
+ sort: _librarySort,
312
+ offset: String(_libraryOffset),
313
+ limit: '50',
314
+ });
315
+ if (_librarySearch) params.set('search', _librarySearch);
316
+ if (_libraryActiveLanguage) params.set('language', _libraryActiveLanguage);
317
+ if (_libraryArchivedView) params.set('archived', 'true');
318
+
319
+ try {
320
+ const res = await fetch(`${API_BASE}/api/documents/library?${params}`);
321
+ if (!res.ok) throw new Error(res.statusText);
322
+ const data = await res.json();
323
+
324
+ if (append) {
325
+ _libraryDocs = _libraryDocs.concat(data.documents);
326
+ } else {
327
+ _libraryDocs = data.documents;
328
+ _docsVisibleLimit = 20; // reset chunk on a fresh load / search / sort
329
+ }
330
+ _libraryTotal = data.total;
331
+ _libraryLanguages = data.languages;
332
+ _librarySessionCount = data.session_count;
333
+
334
+ libraryRenderStats();
335
+ libraryRenderLangChips();
336
+ libraryRenderGrid();
337
+ libraryRenderLoadMore();
338
+ } catch (e) {
339
+ console.error('Library fetch error:', e);
340
+ }
341
+ }
342
+
343
+ function libraryRenderStats() {
344
+ const el = document.getElementById('doclib-stats');
345
+ if (!el) return;
346
+ const totalAll = Object.values(_libraryLanguages).reduce((a, b) => a + b, 0);
347
+ if (_librarySearch || _libraryActiveLanguage) {
348
+ el.textContent = `${_libraryTotal} of ${totalAll} document${totalAll !== 1 ? 's' : ''}`;
349
+ } else {
350
+ el.textContent = `${totalAll} document${totalAll !== 1 ? 's' : ''}`;
351
+ }
352
+ }
353
+
354
+ function libraryRenderLangChips() {
355
+ const wrap = document.getElementById('doclib-chips');
356
+ if (!wrap) return;
357
+ // Remove only language chip buttons, keep sort/select elements
358
+ wrap.querySelectorAll('.memory-cat-chip').forEach(c => c.remove());
359
+ const totalAll = Object.values(_libraryLanguages).reduce((a, b) => a + b, 0);
360
+
361
+ // Hide the "all (0)" chip + lang chips entirely when there are no docs.
362
+ if (totalAll === 0) return;
363
+
364
+ const allChip = document.createElement('button');
365
+ allChip.className = 'memory-cat-chip' + (!_libraryActiveLanguage ? ' active' : '');
366
+ allChip.textContent = `all (${totalAll})`;
367
+ allChip.addEventListener('click', () => {
368
+ if (_librarySelectMode) {
369
+ _libraryDocs.forEach(d => _librarySelectedIds.add(d.id));
370
+ libraryUpdateBulkCount();
371
+ const selectAllEl = document.getElementById('doclib-select-all');
372
+ if (selectAllEl) selectAllEl.checked = true;
373
+ libraryRenderGrid();
374
+ return;
375
+ }
376
+ _libraryActiveLanguage = null;
377
+ libraryFetch(false);
378
+ });
379
+ wrap.appendChild(allChip);
380
+
381
+ const sorted = Object.entries(_libraryLanguages).sort((a, b) => b[1] - a[1]);
382
+ for (const [lang, count] of sorted) {
383
+ const chip = document.createElement('button');
384
+ chip.className = 'memory-cat-chip' + (_libraryActiveLanguage === lang ? ' active' : '');
385
+ chip.textContent = `${lang} (${count})`;
386
+ chip.addEventListener('click', () => {
387
+ _libraryActiveLanguage = lang;
388
+ libraryFetch(false);
389
+ });
390
+ wrap.appendChild(chip);
391
+ }
392
+ }
393
+
394
+ function libraryRemoveDocumentFromState(docId) {
395
+ const removed = _libraryDocs.find(d => String(d.id) === String(docId));
396
+ _libraryDocs = _libraryDocs.filter(d => String(d.id) !== String(docId));
397
+ _librarySelectedIds.delete(docId);
398
+ _libraryTotal = Math.max(0, _libraryTotal - 1);
399
+
400
+ const lang = removed && (removed.language || 'text');
401
+ if (lang && Object.prototype.hasOwnProperty.call(_libraryLanguages, lang)) {
402
+ const next = Math.max(0, Number(_libraryLanguages[lang] || 0) - 1);
403
+ if (next > 0) {
404
+ _libraryLanguages[lang] = next;
405
+ } else {
406
+ delete _libraryLanguages[lang];
407
+ }
408
+ }
409
+
410
+ libraryRenderStats();
411
+ libraryRenderLangChips();
412
+ libraryUpdateBulkCount();
413
+ }
414
+
415
+ function libraryRenderGrid() {
416
+ const grid = document.getElementById('doclib-grid');
417
+ if (!grid) return;
418
+ // An open card menu is mounted on <body> (to escape overflow clipping), so
419
+ // clearing the grid would orphan it; dismiss it first so its listener +
420
+ // Escape-stack entry go too.
421
+ document.querySelectorAll('.doclib-card-dropdown').forEach(dismissOrRemove);
422
+ grid.innerHTML = '';
423
+ // Drop any previous inline load-more — regenerated below alongside the list.
424
+ if (grid.parentElement) grid.parentElement.querySelectorAll(':scope > .doclib-inline-load-more').forEach(b => b.remove());
425
+
426
+ if (_libraryDocs.length === 0) {
427
+ if (_librarySearch || _libraryActiveLanguage) {
428
+ grid.innerHTML = '<div class="doclib-empty">No documents match your search.</div>';
429
+ } else {
430
+ const _impIco = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin:0 4px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>';
431
+ grid.innerHTML =
432
+ '<div class="doclib-empty" style="display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;">' +
433
+ '<span>No documents yet</span>' +
434
+ '<span style="opacity:0.7;font-size:11px;">' +
435
+ '<a href="#" data-doclib-import style="color:var(--accent,var(--red));text-decoration:underline;">Import' + _impIco + '</a>' +
436
+ ' &middot; or create one in a session' +
437
+ '</span>' +
438
+ '</div>';
439
+ grid.querySelector('[data-doclib-import]')?.addEventListener('click', (e) => {
440
+ e.preventDefault();
441
+ document.getElementById('doclib-import-file-btn')?.click();
442
+ });
443
+ }
444
+ return;
445
+ }
446
+ _maybeCascadeGrid(grid, 'documents');
447
+
448
+ // Reveal in 20-at-a-time chunks (matches the Chats tab). The legacy
449
+ // server-pagination button is suppressed in libraryRenderLoadMore; this
450
+ // inline button is the single control.
451
+ const shown = _libraryDocs.slice(0, _docsVisibleLimit);
452
+ for (const doc of shown) {
453
+ grid.appendChild(libraryCreateCard(doc));
454
+ }
455
+ // Show a "Load more" while either more loaded docs remain to reveal, or
456
+ // more exist on the server beyond what we've fetched.
457
+ const shownCount = shown.length;
458
+ if (shownCount < _libraryTotal) {
459
+ const btn = document.createElement('button');
460
+ btn.className = 'doclib-load-more doclib-inline-load-more';
461
+ btn.id = 'doclib-docs-load-more';
462
+ btn.textContent = `Load more (${shownCount} of ${_libraryTotal})`;
463
+ btn.addEventListener('click', async () => {
464
+ _docsVisibleLimit += 20;
465
+ // Need more than we've fetched? pull the next server page first.
466
+ if (_docsVisibleLimit > _libraryDocs.length && _libraryDocs.length < _libraryTotal) {
467
+ _libraryOffset = _libraryDocs.length;
468
+ await libraryFetch(true); // appends + re-renders
469
+ } else {
470
+ libraryRenderGrid();
471
+ }
472
+ });
473
+ grid.parentElement.appendChild(btn);
474
+ }
475
+ }
476
+
477
+ // Infinite scroll for the library (mobile + desktop), covering EVERY tab —
478
+ // Documents, Chats, Research, Archive all render a `.doclib-inline-load-more`
479
+ // button (regenerated fresh each render). A capture-phase scroll listener
480
+ // catches whichever element actually scrolls and, when the visible button
481
+ // nears the viewport bottom, clicks it — reusing each tab's own load logic.
482
+ // We mark a button once clicked so the SAME instance can't double-fire (the
483
+ // next render makes a fresh, unmarked one), which is safe for both the sync
484
+ // reveal tabs (Chats/Research) and the async fetch tabs (Documents/Archive).
485
+ if (!_libScrollBound) {
486
+ _libScrollBound = true;
487
+ let _tick = false;
488
+ const _maybeAutoLoad = () => {
489
+ _tick = false;
490
+ if (!_libraryOpen) return;
491
+ for (const btn of document.querySelectorAll('.doclib-inline-load-more')) {
492
+ if (btn.dataset.autoLoaded) continue;
493
+ if (!btn.offsetParent) continue; // inactive tab (hidden)
494
+ if (btn.getBoundingClientRect().top > window.innerHeight + 600) continue;
495
+ btn.dataset.autoLoaded = '1';
496
+ btn.click();
497
+ break; // one load per scroll tick
498
+ }
499
+ };
500
+ document.addEventListener('scroll', () => {
501
+ if (_tick) return;
502
+ _tick = true;
503
+ requestAnimationFrame(_maybeAutoLoad);
504
+ }, true);
505
+ }
506
+
507
+ function libraryCreateCard(doc) {
508
+ const card = document.createElement('div');
509
+ card.className = 'doclib-card memory-item';
510
+ card.dataset.docId = doc.id;
511
+ if (_librarySelectMode && _librarySelectedIds.has(doc.id)) {
512
+ card.classList.add('selected');
513
+ }
514
+
515
+ // Checkbox for select mode
516
+ if (_librarySelectMode) {
517
+ const cb = document.createElement('input');
518
+ cb.type = 'checkbox';
519
+ cb.className = 'memory-select-cb';
520
+ cb.checked = _librarySelectedIds.has(doc.id);
521
+ cb.addEventListener('click', (e) => e.stopPropagation());
522
+ cb.addEventListener('change', () => {
523
+ libraryToggleSelectItem(doc.id);
524
+ card.classList.toggle('selected', _librarySelectedIds.has(doc.id));
525
+ const selectAllEl = document.getElementById('doclib-select-all');
526
+ if (selectAllEl) selectAllEl.checked = _libraryDocs.every(d => _librarySelectedIds.has(d.id));
527
+ });
528
+ card.appendChild(cb);
529
+ }
530
+
531
+ // Content wrapper
532
+ const content = document.createElement('div');
533
+ content.style.cssText = 'flex:1;min-width:0;padding-top:4px;';
534
+
535
+ // Title row with version badge
536
+ const titleRow = document.createElement('div');
537
+ titleRow.style.cssText = 'display:flex;align-items:center;gap:6px;width:100%;';
538
+ const titleEl = document.createElement('span');
539
+ titleEl.className = 'memory-item-title';
540
+ titleEl.style.cssText = 'flex:0 1 auto;min-width:0;';
541
+ // Language-specific icon next to the title (matches the document's type:
542
+ // markdown/csv/python/html/etc.). Falls back to the generic document icon
543
+ // when the language has no dedicated glyph.
544
+ const _GEN_DOC_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.4;flex-shrink:0;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>';
545
+ const _langSvg = doc.language && doc.language !== 'text'
546
+ ? langIcon(doc.language, 12, { style: 'vertical-align:-2px;margin-right:4px;opacity:0.55;flex-shrink:0;color:currentColor;' })
547
+ : '';
548
+ titleEl.innerHTML = (_langSvg || _GEN_DOC_ICON) + _hlSearch(doc.title || 'Untitled');
549
+ titleRow.appendChild(titleEl);
550
+ const verBadge = document.createElement('span');
551
+ verBadge.style.cssText = 'font-size:9px;padding:1px 6px;border-radius:8px;background:color-mix(in srgb, var(--red) 15%, transparent);border:1px solid color-mix(in srgb, var(--red) 40%, transparent);color:var(--red);flex-shrink:0;';
552
+ verBadge.textContent = 'v' + (doc.version_count || 1);
553
+ titleRow.appendChild(verBadge);
554
+ // Chevron pushed to the right end of the title row — collapsed
555
+ // shows nothing, expanded reveals a downward chevron so the user
556
+ // sees the card is open and can tap to close it.
557
+ const chevron = document.createElement('span');
558
+ chevron.className = 'doclib-card-chevron';
559
+ chevron.style.marginLeft = 'auto';
560
+ chevron.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
561
+ titleRow.appendChild(chevron);
562
+ content.appendChild(titleRow);
563
+
564
+ // Meta line: session → [lang-icon language] → time
565
+ const meta = document.createElement('div');
566
+ meta.className = 'memory-item-meta';
567
+ meta.style.cssText = 'font-size:10px;opacity:0.55;margin-top:2px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;';
568
+ const _esc = (s) => uiModule.esc(String(s || ''));
569
+ const pieces = [];
570
+ if (doc.session_name) pieces.push(`<span>${_esc(doc.session_name)}</span>`);
571
+ if (doc.language && doc.language !== 'text') {
572
+ const ic = langIcon(doc.language, 11, { style: 'vertical-align:-2px;flex-shrink:0;opacity:0.65;color:currentColor;' });
573
+ pieces.push(`<span style="display:inline-flex;align-items:center;gap:3px;">${ic}${_esc(doc.language)}</span>`);
574
+ }
575
+ pieces.push(`<span>${_esc(libraryRelativeTime(doc.updated_at))}</span>`);
576
+ meta.innerHTML = pieces.join('<span style="opacity:0.5;">\u00b7</span>');
577
+ // Strip the per-language icon from the meta line \u2014 it now sits next to the
578
+ // title above, so duplicating it here was redundant.
579
+ content.appendChild(meta);
580
+ card.appendChild(content);
581
+
582
+ // Header element (kept for expand/preview compatibility)
583
+ const header = document.createElement('div');
584
+ header.className = 'doclib-card-header';
585
+ header.style.display = 'none';
586
+
587
+ // Action buttons — "..." menu
588
+ const actionsWrap = document.createElement('div');
589
+ actionsWrap.className = 'memory-item-actions';
590
+ const menuWrap = document.createElement('span');
591
+ menuWrap.className = 'doclib-card-menu-wrap';
592
+ menuWrap.style.position = 'relative';
593
+ const menuBtn = document.createElement('button');
594
+ menuBtn.className = 'memory-item-btn';
595
+ menuBtn.title = 'Actions';
596
+ menuBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>';
597
+ menuBtn.addEventListener('click', (e) => {
598
+ e.stopPropagation();
599
+ // Mobile: the custom 5-item dropdown is too crowded — route through the
600
+ // shared _showLibDropdown with a small set (Open, Clone) plus Select +
601
+ // Cancel. Heavier actions (Archive, Delete, Export) live in bulk mode.
602
+ if (window.innerWidth <= 768) {
603
+ const items = [];
604
+ if (doc.session_id) items.push({ label: 'Open', action: () => libraryOpenInSession(doc) });
605
+ items.push({ label: 'Clone', action: () => libraryImportDocument(doc) });
606
+ _showLibDropdown(menuBtn, items, { onSelect: () => {
607
+ libraryEnterSelectMode();
608
+ _librarySelectedIds.add(doc.id);
609
+ libraryUpdateBulkCount();
610
+ libraryRenderGrid();
611
+ } });
612
+ return;
613
+ }
614
+ const dropdown = menuWrap.querySelector('.doclib-card-dropdown') || document.body.querySelector('.doclib-card-dropdown[data-owner="' + CSS.escape(doc.id) + '"]');
615
+ if (dropdown) {
616
+ const isOpen = dropdown.style.display !== 'none' && dropdown.parentElement === document.body;
617
+ if (isOpen) {
618
+ hideCardDropdown();
619
+ } else {
620
+ // Position fixed on body to escape overflow clipping
621
+ const rect = menuBtn.getBoundingClientRect();
622
+ document.body.appendChild(dropdown);
623
+ dropdown.dataset.owner = doc.id;
624
+ dropdown.style.cssText = 'position:fixed;z-index:10000;min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;display:block;';
625
+ dropdown.style.top = (rect.bottom + 4) + 'px';
626
+ dropdown.style.left = 'auto';
627
+ dropdown.style.right = (window.innerWidth - rect.right) + 'px';
628
+ // Clamp to viewport
629
+ requestAnimationFrame(() => {
630
+ const mr = dropdown.getBoundingClientRect();
631
+ if (mr.bottom > window.innerHeight - 8) dropdown.style.top = (rect.top - mr.height - 4) + 'px';
632
+ if (mr.left < 8) { dropdown.style.left = '8px'; dropdown.style.right = 'auto'; }
633
+ });
634
+ // Close on outside click or Escape (the latter via the registry).
635
+ _cardDocClick = (ev) => {
636
+ if (!dropdown.contains(ev.target) && !menuWrap.contains(ev.target)) hideCardDropdown();
637
+ };
638
+ setTimeout(() => document.addEventListener('click', _cardDocClick, true), 0);
639
+ _cardUnreg = registerMenuDismiss(hideCardDropdown);
640
+ }
641
+ }
642
+ });
643
+ menuWrap.appendChild(menuBtn);
644
+
645
+ // Dropdown menu
646
+ const dropdown = document.createElement('div');
647
+ dropdown.className = 'doclib-card-dropdown';
648
+ dropdown.style.cssText = 'display:none;position:absolute;top:100%;right:0;z-index:1000;min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;';
649
+
650
+ // Single close path for the card action dropdown, shared by the toggle
651
+ // button, the outside-click listener, every menu item, and the Escape
652
+ // arbiter (via registerMenuDismiss). Hides the menu, returns it to its
653
+ // wrapper, drops the outside-click listener, and unregisters from the
654
+ // Escape stack. Idempotent — safe to call from whichever path fires first.
655
+ let _cardUnreg = () => {};
656
+ let _cardDocClick = null;
657
+ function hideCardDropdown() {
658
+ _cardUnreg(); _cardUnreg = () => {};
659
+ if (_cardDocClick) { document.removeEventListener('click', _cardDocClick, true); _cardDocClick = null; }
660
+ dropdown.style.display = 'none';
661
+ if (dropdown.parentElement === document.body) menuWrap.appendChild(dropdown);
662
+ }
663
+ dropdown._dismiss = hideCardDropdown; // bulk removers tear down through this
664
+
665
+ const _di = (svg) => `<span class="dropdown-icon">${svg}</span>`;
666
+ const _openIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
667
+
668
+ // Open
669
+ const openItem = document.createElement('button');
670
+ openItem.className = 'dropdown-item-compact';
671
+ openItem.style.cssText = 'background:none;border:none;width:100%;';
672
+ openItem.innerHTML = _di(_openIco) + '<span>Open</span>';
673
+ if (doc.session_id) {
674
+ openItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryOpenInSession(doc); });
675
+ } else {
676
+ // Orphaned doc (closed / session detached) is still openable in the editor
677
+ // by id — libraryOpenDocument handles the no-session case (#1602).
678
+ openItem.title = 'Open in the editor';
679
+ openItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryOpenDocument(doc); });
680
+ }
681
+ dropdown.appendChild(openItem);
682
+
683
+ // Clone
684
+ const _cloneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
685
+ const cloneItem = document.createElement('button');
686
+ cloneItem.className = 'dropdown-item-compact';
687
+ cloneItem.style.cssText = 'background:none;border:none;width:100%;';
688
+ cloneItem.innerHTML = _di(_cloneIco) + '<span>Clone</span>';
689
+ cloneItem.title = 'Clone to active session';
690
+ cloneItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryImportDocument(doc); });
691
+ dropdown.appendChild(cloneItem);
692
+
693
+ // Export
694
+ const _exportIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
695
+ const exportItem = document.createElement('button');
696
+ exportItem.className = 'dropdown-item-compact';
697
+ exportItem.style.cssText = 'background:none;border:none;width:100%;';
698
+ exportItem.innerHTML = _di(_exportIco) + '<span>Export</span>';
699
+ exportItem.addEventListener('click', async (e) => {
700
+ e.stopPropagation();
701
+ hideCardDropdown();
702
+ try {
703
+ const res = await fetch(`${API_BASE}/api/document/${doc.id}`);
704
+ if (!res.ok) throw new Error('Failed');
705
+ const full = await res.json();
706
+ const extMap = { javascript: '.js', python: '.py', html: '.html', css: '.css', markdown: '.md', json: '.json', yaml: '.yml', bash: '.sh', sql: '.sql', rust: '.rs', go: '.go', java: '.java', c: '.c', cpp: '.cpp', typescript: '.ts', ruby: '.rb', php: '.php', xml: '.xml', toml: '.toml', ini: '.ini' };
707
+ const ext = extMap[full.language] || '.txt';
708
+ const blob = new Blob([full.current_content || ''], { type: 'text/plain' });
709
+ const a = document.createElement('a');
710
+ a.href = URL.createObjectURL(blob);
711
+ a.download = (full.title || 'document') + ext;
712
+ a.click();
713
+ URL.revokeObjectURL(a.href);
714
+ } catch { if (uiModule) uiModule.showError('Failed to export document'); }
715
+ });
716
+ dropdown.appendChild(exportItem);
717
+
718
+ // Archive / Restore — soft-archive a doc out of the main list, or bring it back.
719
+ const _archiveIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>';
720
+ const archiveItem = document.createElement('button');
721
+ archiveItem.className = 'dropdown-item-compact';
722
+ archiveItem.style.cssText = 'background:none;border:none;width:100%;';
723
+ archiveItem.innerHTML = _di(_archiveIco) + `<span>${_libraryArchivedView ? 'Restore' : 'Archive'}</span>`;
724
+ archiveItem.title = _libraryArchivedView ? 'Restore to active documents' : 'Archive (hide from the main list)';
725
+ archiveItem.addEventListener('click', async (e) => {
726
+ e.stopPropagation();
727
+ hideCardDropdown();
728
+ const toArchived = !_libraryArchivedView;
729
+ try {
730
+ const res = await fetch(`${API_BASE}/api/document/${doc.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' });
731
+ if (!res.ok) throw new Error('failed');
732
+ // Drop it from the current view (it no longer belongs here) and refresh.
733
+ libraryRemoveDocumentFromState(doc.id);
734
+ libraryRenderGrid();
735
+ if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored');
736
+ } catch { if (uiModule) uiModule.showError('Failed to ' + (toArchived ? 'archive' : 'restore')); }
737
+ });
738
+ dropdown.appendChild(archiveItem);
739
+
740
+ // Delete
741
+ const _deleteIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
742
+ const deleteItem = document.createElement('button');
743
+ deleteItem.className = 'dropdown-item-compact dropdown-item-danger';
744
+ deleteItem.style.cssText = 'background:none;border:none;width:100%;';
745
+ deleteItem.innerHTML = _di(_deleteIco) + '<span>Delete</span>';
746
+ deleteItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryDeleteSingle(doc.id, card); });
747
+ dropdown.appendChild(deleteItem);
748
+
749
+ menuWrap.appendChild(dropdown);
750
+ actionsWrap.appendChild(menuWrap);
751
+ card.appendChild(actionsWrap);
752
+
753
+ // Hidden header for expand/preview compatibility
754
+ card.appendChild(header);
755
+
756
+ // Inject library card hover styles once
757
+ if (!document.getElementById('doclib-card-styles')) {
758
+ const s = document.createElement('style');
759
+ s.id = 'doclib-card-styles';
760
+ s.textContent = `.doclib-card:hover .doclib-card-icon-btn{opacity:.4}.doclib-card-icon-btn:hover{opacity:1!important}.doclib-card-text-btn{background:none;border:1px solid var(--border);color:var(--fg-muted);font-size:10px;padding:3px 8px;border-radius:4px;cursor:pointer;transition:border-color .15s,color .15s}.doclib-card-text-btn:hover{border-color:var(--accent,var(--red));color:var(--accent,var(--red))}.doclib-card-text-btn-danger{border-color:var(--color-danger,#e06c75)!important;color:var(--color-danger,#e06c75)!important}.doclib-card-text-btn-danger:hover{border-color:#ff4d4d!important;color:#ff4d4d!important}.doclib-card-chevron{display:none;align-items:center;justify-content:center;align-self:center;opacity:0.6;transition:transform .15s ease;flex-shrink:0;height:14px;line-height:0}.doclib-card-expanded .doclib-card-chevron{display:inline-flex;transform:rotate(180deg)}.doclib-card-chevron svg{display:block}`;
761
+ document.head.appendChild(s);
762
+ }
763
+
764
+ // Preview — hidden by default, shown on expand
765
+ const preview = document.createElement('div');
766
+ preview.className = 'doclib-card-preview';
767
+ const pre = document.createElement('pre');
768
+ const code = document.createElement('code');
769
+ try {
770
+ if (doc.language && doc.language !== 'text' && window.hljs && !_librarySearch) {
771
+ code.innerHTML = window.hljs.highlight(doc.preview || '', { language: doc.language }).value;
772
+ } else if (_librarySearch) {
773
+ // While searching, highlight matched terms in the preview (plain
774
+ // text) rather than syntax-highlighting — the match is what matters.
775
+ code.innerHTML = _hlSearch(doc.preview || '');
776
+ } else {
777
+ code.textContent = doc.preview || '';
778
+ }
779
+ } catch {
780
+ code.textContent = doc.preview || '';
781
+ }
782
+ pre.appendChild(code);
783
+ preview.appendChild(pre);
784
+
785
+ // Expanded-only action bar — inside preview
786
+ const expandedActions = document.createElement('div');
787
+ expandedActions.className = 'doclib-card-expanded-actions';
788
+
789
+ const openBtn = document.createElement('button');
790
+ openBtn.className = 'doclib-card-text-btn doclib-card-action-btn';
791
+ openBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M5 12h14M13 5l7 7-7 7"/></svg>Open';
792
+ if (doc.session_id) {
793
+ openBtn.title = 'Open in original session';
794
+ openBtn.addEventListener('click', (e) => { e.stopPropagation(); libraryOpenInSession(doc); });
795
+ } else {
796
+ // Orphaned doc (closed / session detached) is still openable in the editor
797
+ // by id — libraryOpenDocument handles the no-session case (#1602).
798
+ openBtn.title = 'Open in the editor';
799
+ openBtn.addEventListener('click', (e) => { e.stopPropagation(); libraryOpenDocument(doc); });
800
+ }
801
+
802
+ const cloneBtn = document.createElement('button');
803
+ cloneBtn.className = 'doclib-card-text-btn doclib-card-action-btn';
804
+ cloneBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Clone';
805
+ cloneBtn.title = 'Clone — copy to active session';
806
+ cloneBtn.addEventListener('click', (e) => { e.stopPropagation(); libraryImportDocument(doc); });
807
+
808
+ const deleteBtn = document.createElement('button');
809
+ deleteBtn.className = 'doclib-card-text-btn doclib-card-action-btn doclib-card-text-btn-danger';
810
+ deleteBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete';
811
+ deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); libraryDeleteSingle(doc.id, card); });
812
+
813
+ // Archive sits next to Delete on the LEFT — same lineup as the chat
814
+ // and research footers. Label flips to Restore inside the Archive view.
815
+ const archiveBtn = document.createElement('button');
816
+ archiveBtn.className = 'doclib-card-text-btn doclib-card-action-btn';
817
+ archiveBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>' + (_libraryArchivedView ? 'Restore' : 'Archive');
818
+ archiveBtn.title = _libraryArchivedView ? 'Restore to active documents' : 'Archive (hide from the main list)';
819
+ archiveBtn.addEventListener('click', async (e) => {
820
+ e.stopPropagation();
821
+ const toArchived = !_libraryArchivedView;
822
+ try {
823
+ const res = await fetch(`${API_BASE}/api/document/${doc.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' });
824
+ if (!res.ok) throw new Error('failed');
825
+ libraryRemoveDocumentFromState(doc.id);
826
+ libraryRenderGrid();
827
+ if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored');
828
+ } catch { if (uiModule) uiModule.showError('Failed to ' + (toArchived ? 'archive' : 'restore')); }
829
+ });
830
+
831
+ const leftGroup = document.createElement('div');
832
+ leftGroup.className = 'doclib-action-group';
833
+ const btnRow = document.createElement('div');
834
+ btnRow.className = 'doclib-action-btn-row';
835
+ // Export lives in the ⋮ menu — keep the footer uncrowded with Clone + Open.
836
+ btnRow.appendChild(cloneBtn);
837
+ btnRow.appendChild(openBtn);
838
+ leftGroup.appendChild(btnRow);
839
+ // Delete furthest LEFT, then Archive; Open/Clone group on the RIGHT.
840
+ // Nudge the Delete/Archive pair 8px left for alignment.
841
+ deleteBtn.style.cssText += ';position:relative;left:-8px;';
842
+ archiveBtn.style.cssText += ';position:relative;left:-8px;';
843
+ expandedActions.appendChild(deleteBtn);
844
+ expandedActions.appendChild(archiveBtn);
845
+ expandedActions.appendChild(leftGroup);
846
+
847
+ preview.appendChild(expandedActions);
848
+ card.appendChild(preview);
849
+
850
+ card.addEventListener('click', () => {
851
+ if (card._suppressNextClick) { card._suppressNextClick = false; return; }
852
+ if (_librarySelectMode) {
853
+ const cb = card.querySelector('.memory-select-cb');
854
+ if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); }
855
+ } else {
856
+ libraryExpandCard(card, doc);
857
+ }
858
+ });
859
+ _attachLongPressMenu(card, '.memory-item-btn');
860
+ return card;
861
+ }
862
+
863
+ async function libraryExpandCard(card, doc) {
864
+ const grid = card.closest('.doclib-grid');
865
+ const instant = card?.dataset?.spaceToggle === '1';
866
+
867
+ // Already expanded — collapse
868
+ if (card.classList.contains('doclib-card-expanded')) {
869
+ _collapseExpandedCard(card);
870
+ return;
871
+ }
872
+
873
+ // Collapse any other expanded card
874
+ if (grid) {
875
+ grid.querySelectorAll('.doclib-card-expanded').forEach(c => _collapseExpandedCard(c));
876
+ }
877
+
878
+ // Fade siblings out before the CSS display:none kicks in
879
+ const siblings = grid ? [...grid.querySelectorAll('.doclib-card')].filter(c => c !== card) : [];
880
+ // Force explicit starting opacity so the first transition works
881
+ siblings.forEach(s => { s.style.opacity = '1'; });
882
+ // Force reflow so the browser registers the starting value
883
+ if (!instant) {
884
+ if (siblings.length) siblings[0].offsetHeight;
885
+ siblings.forEach(s => { s.style.transition = 'opacity 0.12s ease'; s.style.opacity = '0'; });
886
+ }
887
+
888
+ // Capture the full grid + toolbar height so the modal stays the same
889
+ // size on desktop. On mobile the modal is full-height and we want the
890
+ // grid to claim all available space — skip the lock there.
891
+ const isMobile = window.innerWidth <= 768;
892
+ const toolbar = grid ? grid.closest('.admin-card')?.querySelector('.memory-toolbar') : null;
893
+ const toolbarH = toolbar ? toolbar.offsetHeight : 0;
894
+ if (grid && !isMobile) {
895
+ grid.style.minHeight = (grid.offsetHeight + toolbarH) + 'px';
896
+ grid.style.maxHeight = (grid.offsetHeight + toolbarH) + 'px';
897
+ }
898
+
899
+ // Wait for fade-out, then expand
900
+ if (!instant) await new Promise(r => setTimeout(r, 120));
901
+
902
+ card.classList.add('doclib-card-expanded');
903
+ if (grid) grid.scrollTop = 0;
904
+
905
+ // Clean up sibling inline styles (CSS display:none takes over now)
906
+ siblings.forEach(s => { s.style.transition = ''; s.style.opacity = ''; });
907
+
908
+ // Load full content into preview area
909
+ const preview = card.querySelector('.doclib-card-preview');
910
+ if (!preview) return;
911
+
912
+ const actionsBar = preview.querySelector('.doclib-card-expanded-actions');
913
+ const existingPre = preview.querySelector('pre');
914
+
915
+ try {
916
+ const res = await fetch(`${API_BASE}/api/document/${doc.id}`);
917
+ if (!res.ok) throw new Error('Failed');
918
+ const full = await res.json();
919
+ const content = full.current_content || '';
920
+ const lang = full.language || doc.language || 'text';
921
+
922
+ // PDF-backed docs have a marker comment in their markdown — show the
923
+ // rendered PDF in an iframe instead of dumping markdown source.
924
+ const isPdfDoc = /<!--\s*pdf_(?:form_)?source\s+upload_id="[^"]+"/.test(content);
925
+ const existingFrame = preview.querySelector('.doclib-card-pdf-frame');
926
+
927
+ if (isPdfDoc) {
928
+ const frame = document.createElement('iframe');
929
+ frame.className = 'doclib-card-pdf-frame';
930
+ frame.src = `${API_BASE}/api/document/${doc.id}/render-pdf?t=${Date.now()}`;
931
+ frame.style.cssText = 'width:100%;height:60vh;border:1px solid var(--border);border-radius:6px;background:var(--bg);opacity:0;transition:opacity 0.15s ease;';
932
+ if (existingPre) existingPre.remove();
933
+ if (existingFrame) existingFrame.remove();
934
+ preview.insertBefore(frame, preview.firstChild);
935
+ if (actionsBar && !preview.contains(actionsBar)) preview.appendChild(actionsBar);
936
+ requestAnimationFrame(() => { frame.style.opacity = '1'; });
937
+ return;
938
+ }
939
+
940
+ const pre = document.createElement('pre');
941
+ const code = document.createElement('code');
942
+ // Syntax highlighting is synchronous and O(n) — running it over a whole
943
+ // large document froze the main thread on click (the "lag"). Only
944
+ // highlight up to a cap; bigger docs render as plain text (still fully
945
+ // shown) so the preview opens instantly. Markdown gains little from
946
+ // highlighting anyway, so skip it there.
947
+ const HL_CAP = 20000;
948
+ try {
949
+ if (lang && lang !== 'text' && lang !== 'markdown' && window.hljs && content.length <= HL_CAP) {
950
+ code.innerHTML = window.hljs.highlight(content, { language: lang }).value;
951
+ } else {
952
+ code.textContent = content;
953
+ }
954
+ } catch {
955
+ code.textContent = content;
956
+ }
957
+ pre.appendChild(code);
958
+
959
+ // Swap content — fade in the full version
960
+ if (existingPre) existingPre.remove();
961
+ if (existingFrame) existingFrame.remove();
962
+ pre.style.opacity = '0';
963
+ preview.insertBefore(pre, preview.firstChild);
964
+ if (actionsBar && !preview.contains(actionsBar)) preview.appendChild(actionsBar);
965
+ requestAnimationFrame(() => {
966
+ pre.style.transition = 'opacity 0.15s ease';
967
+ pre.style.opacity = '1';
968
+ });
969
+ } catch (e) {
970
+ // On error, keep existing preview if available
971
+ if (!existingPre) {
972
+ preview.innerHTML = '<div style="padding:8px;color:var(--color-error);font-size:10px;">Failed to load</div>';
973
+ }
974
+ if (actionsBar && !preview.contains(actionsBar)) preview.appendChild(actionsBar);
975
+ }
976
+ }
977
+
978
+ function libraryRenderLoadMore() {
979
+ // Documents now reveal in 20-at-a-time chunks via the inline "Load more"
980
+ // rendered inside libraryRenderGrid (matching the Chats tab). The legacy
981
+ // server-pagination button + auto-fill are retired to avoid a double
982
+ // control and surprise auto-loading.
983
+ const legacy = document.getElementById('doclib-load-more');
984
+ if (legacy) legacy.style.display = 'none';
985
+ }
986
+
987
+ async function libraryOpenDocument(doc) {
988
+ closeLibrary();
989
+ // Orphaned doc (session deleted) — just open in editor without switching session
990
+ if (!doc.session_id) {
991
+ _loadDocument(doc.id);
992
+ return;
993
+ }
994
+ const currentSessionId = sessionModule && sessionModule.getCurrentSessionId();
995
+ if (doc.session_id !== currentSessionId) {
996
+ await sessionModule.selectSession(doc.session_id);
997
+ }
998
+ _loadDocument(doc.id);
999
+ }
1000
+
1001
+ /** Open a document in its linked session */
1002
+ async function libraryOpenInSession(doc) {
1003
+ if (!doc.session_id) return;
1004
+ closeLibrary();
1005
+
1006
+ // Step 1: switch session if needed and wait for it to load
1007
+ const currentSessionId = sessionModule && sessionModule.getCurrentSessionId();
1008
+ if (doc.session_id !== currentSessionId) {
1009
+ await sessionModule.selectSession(doc.session_id);
1010
+ // Give the session UI a moment to settle
1011
+ await new Promise(r => setTimeout(r, 150));
1012
+ }
1013
+
1014
+ // Step 2: ensure doc is in tabs
1015
+ const docs = _getDocs();
1016
+ if (!docs.has(doc.id)) {
1017
+ const res = await fetch(`${API_BASE}/api/document/${doc.id}`);
1018
+ if (res.ok) {
1019
+ const full = await res.json();
1020
+ _addDocToTabs(full, doc.session_id);
1021
+ }
1022
+ }
1023
+
1024
+ // Step 3: open panel (slide-in is handled by openPanel)
1025
+ if (!_isOpenFn()) _openPanel();
1026
+
1027
+ _switchToDoc(doc.id);
1028
+ _syncDocIndicator();
1029
+ }
1030
+
1031
+ /** Copy a document from the library into the current session */
1032
+ async function libraryImportDocument(doc) {
1033
+ let sessionId = sessionModule && sessionModule.getCurrentSessionId();
1034
+ if (!sessionId) {
1035
+ // Create a new session if none exists
1036
+ if (sessionModule && sessionModule.hasPendingChat && sessionModule.hasPendingChat()) {
1037
+ const ok = await sessionModule.materializePendingSession();
1038
+ if (ok) sessionId = sessionModule.getCurrentSessionId();
1039
+ }
1040
+ if (!sessionId) {
1041
+ // No pending chat either — trigger new session, preserving the current model
1042
+ const curModel = sessionModule.getCurrentModel ? sessionModule.getCurrentModel() : null;
1043
+ const sessions = sessionModule ? sessionModule.getSessions() : [];
1044
+ // Prefer the session matching the current model, otherwise fall back to first with a model
1045
+ const withModel = sessions.filter(s => s.endpoint_url && s.model);
1046
+ const match = (curModel && withModel.find(s => s.model === curModel)) || withModel[0];
1047
+ if (match) {
1048
+ sessionModule.createDirectChat(match.endpoint_url, match.model, match.endpoint_id);
1049
+ const ok = await sessionModule.materializePendingSession();
1050
+ if (ok) sessionId = sessionModule.getCurrentSessionId();
1051
+ }
1052
+ }
1053
+ if (!sessionId) {
1054
+ if (uiModule) uiModule.showError('Could not create a session');
1055
+ return;
1056
+ }
1057
+ }
1058
+ try {
1059
+ // Fetch full content of the source document
1060
+ const srcRes = await fetch(`${API_BASE}/api/document/${doc.id}`);
1061
+ if (!srcRes.ok) throw new Error('Failed to fetch document');
1062
+ const src = await srcRes.json();
1063
+
1064
+ // Deduplicate title — append (2), (3), etc. if name already exists in session
1065
+ let baseTitle = src.title || doc.title || 'Untitled';
1066
+ const existingTitles = new Set();
1067
+ const docs = _getDocs();
1068
+ for (const [, d] of docs) {
1069
+ if (d.sessionId === sessionId && d.title) existingTitles.add(d.title);
1070
+ }
1071
+ if (existingTitles.has(baseTitle)) {
1072
+ // Strip existing (N) suffix to get the root name
1073
+ const root = baseTitle.replace(/\s*\(\d+\)$/, '');
1074
+ let n = 2;
1075
+ while (existingTitles.has(root + ' (' + n + ')')) n++;
1076
+ baseTitle = root + ' (' + n + ')';
1077
+ }
1078
+
1079
+ // Create a new document copy in the current session
1080
+ const res = await fetch(`${API_BASE}/api/document`, {
1081
+ method: 'POST',
1082
+ credentials: 'same-origin',
1083
+ headers: { 'Content-Type': 'application/json' },
1084
+ body: JSON.stringify({
1085
+ session_id: sessionId,
1086
+ // Preserve the source's type; default to markdown when unknown
1087
+ // (the backend also sniffs, but this keeps the tab label correct).
1088
+ language: src.language || doc.language || 'markdown',
1089
+ content: src.current_content || '',
1090
+ }),
1091
+ });
1092
+ if (!res.ok) throw new Error('Failed to create document');
1093
+ const created = await res.json();
1094
+ closeLibrary();
1095
+ _addDocToTabs(created, sessionId);
1096
+ if (!_isOpenFn()) _openPanel();
1097
+
1098
+ _switchToDoc(created.id);
1099
+ _syncDocIndicator();
1100
+ if (uiModule) uiModule.showToast('Document cloned to session');
1101
+ } catch (e) {
1102
+ console.error('Failed to import document:', e);
1103
+ if (uiModule) uiModule.showError('Failed to import document');
1104
+ }
1105
+ }
1106
+
1107
+ // ---- Library bulk operations ----
1108
+
1109
+ function libraryEnterSelectMode() {
1110
+ _librarySelectMode = true;
1111
+ _librarySelectedIds.clear();
1112
+ const bulkBar = document.getElementById('doclib-bulk-bar');
1113
+ const selectBtn = document.getElementById('doclib-select-btn');
1114
+ if (bulkBar) bulkBar.classList.remove('hidden');
1115
+ if (selectBtn) { selectBtn.classList.add('active'); selectBtn.textContent = 'Cancel'; }
1116
+ libraryUpdateBulkCount();
1117
+ libraryRenderGrid();
1118
+ }
1119
+
1120
+ function libraryExitSelectMode() {
1121
+ _librarySelectMode = false;
1122
+ _librarySelectedIds.clear();
1123
+ const bulkBar = document.getElementById('doclib-bulk-bar');
1124
+ const selectBtn = document.getElementById('doclib-select-btn');
1125
+ const selectAll = document.getElementById('doclib-select-all');
1126
+ if (bulkBar) bulkBar.classList.add('hidden');
1127
+ if (selectBtn) { selectBtn.classList.remove('active'); selectBtn.textContent = 'Select'; }
1128
+ if (selectAll) selectAll.checked = false;
1129
+ libraryRenderGrid();
1130
+ }
1131
+
1132
+ function libraryToggleSelectItem(id) {
1133
+ if (_librarySelectedIds.has(id)) {
1134
+ _librarySelectedIds.delete(id);
1135
+ } else {
1136
+ _librarySelectedIds.add(id);
1137
+ }
1138
+ libraryUpdateBulkCount();
1139
+ }
1140
+
1141
+ function libraryToggleSelectAll() {
1142
+ const selectAllEl = document.getElementById('doclib-select-all');
1143
+ if (!selectAllEl) return;
1144
+ if (selectAllEl.checked) {
1145
+ _libraryDocs.forEach(d => _librarySelectedIds.add(d.id));
1146
+ } else {
1147
+ _librarySelectedIds.clear();
1148
+ }
1149
+ libraryUpdateBulkCount();
1150
+ libraryRenderGrid();
1151
+ }
1152
+
1153
+ function libraryUpdateBulkCount() {
1154
+ const countEl = document.getElementById('doclib-selected-count');
1155
+ const actionsBtn = document.getElementById('doclib-bulk-actions');
1156
+ if (countEl) countEl.textContent = `${_librarySelectedIds.size} Selected`;
1157
+ if (actionsBtn) actionsBtn.style.color = _librarySelectedIds.size > 0 ? 'var(--fg)' : '';
1158
+ // Legacy per-action buttons no longer rendered — guard so the rest of the
1159
+ // function (if anything still references them) doesn't crash.
1160
+ const deleteBtn = document.getElementById('doclib-bulk-delete');
1161
+ const exportBtn = document.getElementById('doclib-bulk-export');
1162
+ const archiveBtn = document.getElementById('doclib-bulk-archive');
1163
+ const cloneBtn = document.getElementById('doclib-bulk-clone');
1164
+ if (deleteBtn) deleteBtn.disabled = _librarySelectedIds.size === 0;
1165
+ if (exportBtn) exportBtn.disabled = _librarySelectedIds.size === 0;
1166
+ if (cloneBtn) cloneBtn.disabled = _librarySelectedIds.size === 0;
1167
+ if (archiveBtn) {
1168
+ archiveBtn.disabled = _librarySelectedIds.size === 0;
1169
+ archiveBtn.textContent = _libraryArchivedView ? 'Restore' : 'Archive';
1170
+ }
1171
+ }
1172
+
1173
+ async function libraryDeleteSingle(docId, card) {
1174
+ if (uiModule && uiModule.styledConfirm) {
1175
+ const ok = await uiModule.styledConfirm('Delete this document?', { confirmText: 'Delete', danger: true });
1176
+ if (!ok) return;
1177
+ } else if (!confirm('Delete this document?')) {
1178
+ return;
1179
+ }
1180
+ try {
1181
+ const res = await fetch(`${API_BASE}/api/document/${docId}`, { method: 'DELETE', credentials: 'same-origin' });
1182
+ if (!res.ok) {
1183
+ let detail = `HTTP ${res.status}`;
1184
+ try { const j = await res.json(); if (j?.detail) detail = j.detail; } catch {}
1185
+ throw new Error(detail);
1186
+ }
1187
+ if (card) {
1188
+ card.classList.add('doclib-card-deleting');
1189
+ card.addEventListener('transitionend', () => card.remove(), { once: true });
1190
+ setTimeout(() => { if (card.parentElement) card.remove(); }, 400);
1191
+ }
1192
+ libraryRemoveDocumentFromState(docId);
1193
+ if (uiModule) uiModule.showToast('Document deleted');
1194
+ } catch (e) {
1195
+ if (uiModule) uiModule.showError(`Failed to delete document: ${e.message || e}`);
1196
+ }
1197
+ }
1198
+
1199
+ async function libraryBulkDelete() {
1200
+ if (_librarySelectedIds.size === 0) return;
1201
+ const count = _librarySelectedIds.size;
1202
+ if (uiModule && uiModule.styledConfirm) {
1203
+ const ok = await uiModule.styledConfirm(
1204
+ `Delete ${count} document${count !== 1 ? 's' : ''}?`,
1205
+ { confirmText: 'Delete', danger: true }
1206
+ );
1207
+ if (!ok) return;
1208
+ } else if (!confirm(`Delete ${count} document${count !== 1 ? 's' : ''}?`)) {
1209
+ return;
1210
+ }
1211
+
1212
+ let deleted = 0;
1213
+ let failed = 0;
1214
+ const deletedIds = [];
1215
+ for (const id of _librarySelectedIds) {
1216
+ try {
1217
+ const res = await fetch(`${API_BASE}/api/document/${id}`, { method: 'DELETE', credentials: 'same-origin' });
1218
+ if (res.ok) {
1219
+ deleted++;
1220
+ deletedIds.push(id);
1221
+ }
1222
+ else { failed++; console.warn('Delete failed for', id, 'status', res.status); }
1223
+ } catch (e) {
1224
+ failed++;
1225
+ console.error('Failed to delete document:', id, e);
1226
+ }
1227
+ }
1228
+
1229
+ for (const id of deletedIds) {
1230
+ const card = document.querySelector(`.doclib-card[data-doc-id="${CSS.escape(String(id))}"]`);
1231
+ if (card) card.classList.add('doclib-card-deleting');
1232
+ }
1233
+ if (deletedIds.length) await new Promise(r => setTimeout(r, 320));
1234
+ libraryExitSelectMode();
1235
+ await libraryFetch(false);
1236
+ if (uiModule) {
1237
+ const msg = failed > 0
1238
+ ? `Deleted ${deleted} · ${failed} failed`
1239
+ : `Deleted ${deleted} document${deleted !== 1 ? 's' : ''}`;
1240
+ (failed > 0 ? uiModule.showError : uiModule.showToast)(msg);
1241
+ }
1242
+ }
1243
+
1244
+ async function libraryBulkArchive() {
1245
+ if (_librarySelectedIds.size === 0) return;
1246
+ const toArchived = !_libraryArchivedView;
1247
+ const ids = [..._librarySelectedIds];
1248
+ let done = 0, failed = 0;
1249
+ for (const id of ids) {
1250
+ try {
1251
+ const res = await fetch(`${API_BASE}/api/document/${id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' });
1252
+ if (res.ok) done++; else failed++;
1253
+ } catch { failed++; }
1254
+ }
1255
+ libraryExitSelectMode();
1256
+ await libraryFetch(false);
1257
+ if (uiModule) {
1258
+ const verb = toArchived ? 'Archived' : 'Restored';
1259
+ const msg = failed > 0 ? `${verb} ${done} · ${failed} failed` : `${verb} ${done} document${done !== 1 ? 's' : ''}`;
1260
+ (failed > 0 ? uiModule.showError : uiModule.showToast)(msg);
1261
+ }
1262
+ }
1263
+
1264
+ // Bulk "Clone" — reuse libraryImportDocument for each selected doc.
1265
+ // It handles session resolution + a possible new-session creation once
1266
+ // (subsequent calls in the loop see the now-resolved session).
1267
+ async function libraryBulkClone() {
1268
+ if (_librarySelectedIds.size === 0) return;
1269
+ const ids = [..._librarySelectedIds];
1270
+ let done = 0, failed = 0;
1271
+ for (const id of ids) {
1272
+ const doc = _libraryDocs.find(d => d.id === id);
1273
+ if (!doc) { failed++; continue; }
1274
+ try {
1275
+ const ok = await libraryImportDocument(doc);
1276
+ if (ok === false) failed++; else done++;
1277
+ } catch { failed++; }
1278
+ }
1279
+ libraryExitSelectMode();
1280
+ if (uiModule) {
1281
+ const msg = failed > 0
1282
+ ? `Cloned ${done} · ${failed} failed`
1283
+ : `Cloned ${done} document${done !== 1 ? 's' : ''}`;
1284
+ (failed > 0 ? uiModule.showError : uiModule.showToast)(msg);
1285
+ }
1286
+ }
1287
+
1288
+ async function libraryBulkExport() {
1289
+ if (_librarySelectedIds.size === 0) return;
1290
+ // More than 5 → one server-built .zip (mirrors the gallery's bulk export;
1291
+ // browsers also block a flood of individual downloads).
1292
+ if (_librarySelectedIds.size > 5) {
1293
+ const ids = [..._librarySelectedIds];
1294
+ try {
1295
+ if (uiModule) uiModule.showToast(`Zipping ${ids.length} documents…`);
1296
+ const res = await fetch(`${API_BASE}/api/documents/export-zip`, {
1297
+ method: 'POST', credentials: 'same-origin',
1298
+ headers: { 'Content-Type': 'application/json' },
1299
+ body: JSON.stringify({ ids }),
1300
+ });
1301
+ if (!res.ok) throw new Error('zip failed');
1302
+ const blob = await res.blob();
1303
+ const url = URL.createObjectURL(blob);
1304
+ const a = document.createElement('a');
1305
+ a.href = url;
1306
+ a.download = 'documents.zip';
1307
+ document.body.appendChild(a);
1308
+ a.click();
1309
+ a.remove();
1310
+ setTimeout(() => URL.revokeObjectURL(url), 2000);
1311
+ if (uiModule) uiModule.showToast(`Exported ${ids.length} documents (zip)`);
1312
+ } catch (e) {
1313
+ if (uiModule) uiModule.showError('Failed to create zip');
1314
+ }
1315
+ return;
1316
+ }
1317
+ const extMap = {
1318
+ javascript: '.js', python: '.py', html: '.html', css: '.css',
1319
+ markdown: '.md', json: '.json', yaml: '.yml', bash: '.sh',
1320
+ sql: '.sql', rust: '.rs', go: '.go', java: '.java', c: '.c', cpp: '.cpp',
1321
+ typescript: '.ts', ruby: '.rb', php: '.php', text: '.txt',
1322
+ xml: '.xml', toml: '.toml', ini: '.ini',
1323
+ };
1324
+
1325
+ const docs = await Promise.all([..._librarySelectedIds].map(async id => {
1326
+ try {
1327
+ const res = await fetch(`${API_BASE}/api/document/${id}`);
1328
+ if (!res.ok) return null;
1329
+ return await res.json();
1330
+ } catch (e) {
1331
+ console.error('Failed to export document:', id, e);
1332
+ return null;
1333
+ }
1334
+ }));
1335
+ for (const doc of docs) {
1336
+ if (!doc) continue;
1337
+ const ext = extMap[doc.language] || '.txt';
1338
+ const filename = (doc.title || 'document') + (doc.title && doc.title.includes('.') ? '' : ext);
1339
+ const blob = new Blob([doc.current_content || ''], { type: 'text/plain' });
1340
+ const url = URL.createObjectURL(blob);
1341
+ const a = document.createElement('a');
1342
+ a.href = url;
1343
+ a.download = filename;
1344
+ a.click();
1345
+ URL.revokeObjectURL(url);
1346
+ }
1347
+ if (uiModule) uiModule.showToast(`Exported ${_librarySelectedIds.size} document${_librarySelectedIds.size !== 1 ? 's' : ''}`);
1348
+ }
1349
+
1350
+ /** Lazy-load SheetJS for spreadsheet parsing */
1351
+ let _xlsxReady = null;
1352
+ function ensureXLSX() {
1353
+ if (_xlsxReady) return _xlsxReady;
1354
+ if (window.XLSX) return (_xlsxReady = Promise.resolve());
1355
+ _xlsxReady = new Promise((resolve, reject) => {
1356
+ const s = document.createElement('script');
1357
+ s.src = '/static/lib/xlsx.full.min.js';
1358
+ s.onload = resolve;
1359
+ s.onerror = () => reject(new Error('Failed to load XLSX library'));
1360
+ document.head.appendChild(s);
1361
+ });
1362
+ return _xlsxReady;
1363
+ }
1364
+
1365
+ let _mammothReady = null;
1366
+ function ensureMammoth() {
1367
+ if (_mammothReady) return _mammothReady;
1368
+ if (window.mammoth) return (_mammothReady = Promise.resolve());
1369
+ _mammothReady = new Promise((resolve, reject) => {
1370
+ const s = document.createElement('script');
1371
+ s.src = '/static/lib/mammoth.browser.min.js';
1372
+ s.onload = resolve;
1373
+ s.onerror = () => reject(new Error('Failed to load DOCX library'));
1374
+ document.head.appendChild(s);
1375
+ });
1376
+ return _mammothReady;
1377
+ }
1378
+
1379
+ /** Convert HTML from mammoth to clean markdown */
1380
+ function htmlToMarkdown(html) {
1381
+ const doc = new DOMParser().parseFromString(html, 'text/html');
1382
+ let md = '';
1383
+ function walk(node) {
1384
+ if (node.nodeType === 3) { md += node.textContent; return; }
1385
+ if (node.nodeType !== 1) return;
1386
+ const tag = node.tagName.toLowerCase();
1387
+ if (tag === 'h1') { md += '\n# '; walkChildren(node); md += '\n'; }
1388
+ else if (tag === 'h2') { md += '\n## '; walkChildren(node); md += '\n'; }
1389
+ else if (tag === 'h3') { md += '\n### '; walkChildren(node); md += '\n'; }
1390
+ else if (tag === 'h4') { md += '\n#### '; walkChildren(node); md += '\n'; }
1391
+ else if (tag === 'strong' || tag === 'b') { md += '**'; walkChildren(node); md += '**'; }
1392
+ else if (tag === 'em' || tag === 'i') { md += '*'; walkChildren(node); md += '*'; }
1393
+ else if (tag === 'a') { md += '['; walkChildren(node); md += `](${node.href || ''})`; }
1394
+ else if (tag === 'br') { md += '\n'; }
1395
+ else if (tag === 'p') { md += '\n'; walkChildren(node); md += '\n'; }
1396
+ else if (tag === 'ul' || tag === 'ol') { md += '\n'; walkChildren(node); }
1397
+ else if (tag === 'li') {
1398
+ const parent = node.parentElement?.tagName?.toLowerCase();
1399
+ if (parent === 'ol') {
1400
+ const idx = Array.from(node.parentElement.children).indexOf(node) + 1;
1401
+ md += `${idx}. `;
1402
+ } else { md += '- '; }
1403
+ walkChildren(node);
1404
+ md += '\n';
1405
+ }
1406
+ else if (tag === 'table') { md += '\n'; convertTable(node); md += '\n'; }
1407
+ else if (tag === 'img') {
1408
+ // Skip embedded base64 images — they produce huge unreadable blobs
1409
+ const src = node.src || '';
1410
+ if (!src.startsWith('data:')) {
1411
+ md += `![${node.alt || ''}](${src})`;
1412
+ } else if (node.alt) {
1413
+ md += `*[image: ${node.alt}]*`;
1414
+ }
1415
+ }
1416
+ else { walkChildren(node); }
1417
+ }
1418
+ function walkChildren(node) { for (const child of node.childNodes) walk(child); }
1419
+ function convertTable(table) {
1420
+ const rows = table.querySelectorAll('tr');
1421
+ rows.forEach((tr, i) => {
1422
+ const cells = tr.querySelectorAll('th, td');
1423
+ md += '| ' + Array.from(cells).map(c => c.textContent.trim()).join(' | ') + ' |\n';
1424
+ if (i === 0) md += '| ' + Array.from(cells).map(() => '---').join(' | ') + ' |\n';
1425
+ });
1426
+ }
1427
+ walkChildren(doc.body);
1428
+ return md.replace(/\n{3,}/g, '\n\n').trim();
1429
+ }
1430
+
1431
+ /** Read file contents — handles text, spreadsheet, and DOCX formats */
1432
+ async function readFileContent(file) {
1433
+ const name = file.name.toLowerCase();
1434
+ const isSpreadsheet = name.endsWith('.xlsx') || name.endsWith('.xls') || name.endsWith('.ods');
1435
+ const isDocx = name.endsWith('.docx');
1436
+
1437
+ if (isSpreadsheet) {
1438
+ await ensureXLSX();
1439
+ const buf = await file.arrayBuffer();
1440
+ const wb = window.XLSX.read(buf, { type: 'array' });
1441
+ // Convert each sheet to CSV, join with a header per sheet
1442
+ const parts = [];
1443
+ for (const sheetName of wb.SheetNames) {
1444
+ if (wb.SheetNames.length > 1) parts.push(`# Sheet: ${sheetName}`);
1445
+ parts.push(window.XLSX.utils.sheet_to_csv(wb.Sheets[sheetName]));
1446
+ }
1447
+ return parts.join('\n\n');
1448
+ }
1449
+
1450
+ if (isDocx) {
1451
+ await ensureMammoth();
1452
+ const buf = await file.arrayBuffer();
1453
+ const result = await window.mammoth.convertToHtml({ arrayBuffer: buf });
1454
+ return htmlToMarkdown(result.value);
1455
+ }
1456
+
1457
+ // Plain text
1458
+ return new Promise((resolve, reject) => {
1459
+ const reader = new FileReader();
1460
+ reader.onload = () => resolve(reader.result);
1461
+ reader.onerror = () => reject(reader.error);
1462
+ reader.readAsText(file);
1463
+ });
1464
+ }
1465
+
1466
+ /** Import files from disk into the document library */
1467
+ async function libraryImportFiles(fileList) {
1468
+ const EXT_TO_LANG = {
1469
+ '.py': 'python', '.js': 'javascript', '.ts': 'typescript',
1470
+ '.html': 'html', '.htm': 'html', '.css': 'css', '.md': 'markdown',
1471
+ '.json': 'json', '.yml': 'yaml', '.yaml': 'yaml', '.sh': 'bash',
1472
+ '.bash': 'bash', '.sql': 'sql', '.rs': 'rust', '.go': 'go',
1473
+ '.java': 'java', '.c': 'c', '.cpp': 'cpp', '.h': 'c', '.hpp': 'cpp',
1474
+ '.rb': 'ruby', '.php': 'php', '.xml': 'xml',
1475
+ '.toml': 'toml', '.ini': 'ini', '.txt': '', '.log': '',
1476
+ '.cfg': 'ini', '.conf': 'ini', '.env': '', '.jsx': 'javascript',
1477
+ '.tsx': 'typescript', '.vue': 'html', '.svelte': 'html',
1478
+ '.scss': 'css', '.sass': 'css', '.less': 'css',
1479
+ '.csv': 'csv', '.tsv': 'csv',
1480
+ '.xlsx': 'csv', '.xls': 'csv', '.ods': 'csv',
1481
+ '.docx': 'markdown', '.doc': 'markdown',
1482
+ };
1483
+
1484
+ let imported = 0;
1485
+ let failed = 0;
1486
+ let _firstErr = '';
1487
+
1488
+ // Library imports aren't tied to a chat — the backend now accepts a
1489
+ // session-less "library" document, so no session_id is sent.
1490
+ for (const file of fileList) {
1491
+ try {
1492
+ const name = file.name;
1493
+ const dotIdx = name.lastIndexOf('.');
1494
+ const ext = dotIdx >= 0 ? name.slice(dotIdx).toLowerCase() : '';
1495
+ const baseTitle = dotIdx > 0 ? name.slice(0, dotIdx) : name;
1496
+ const language = EXT_TO_LANG[ext] !== undefined ? EXT_TO_LANG[ext] : null;
1497
+
1498
+ const isSpreadsheet = ['.xlsx', '.xls', '.ods'].includes(ext);
1499
+ const isPdf = ext === '.pdf';
1500
+
1501
+ if (isPdf) {
1502
+ // Backend handles save + AcroForm detection in one shot — picks the
1503
+ // right doc kind so fillable forms get clickable inputs in the PDF
1504
+ // view, and plain PDFs get the static page-image viewer.
1505
+ const fd = new FormData();
1506
+ fd.append('file', file);
1507
+ const res = await fetch(`${API_BASE}/api/documents/import-pdf`, {
1508
+ method: 'POST',
1509
+ body: fd,
1510
+ });
1511
+ if (!res.ok) {
1512
+ let _e = `HTTP ${res.status}`;
1513
+ try { const _j = await res.json(); _e = _j.detail || _j.error || _e; } catch {}
1514
+ throw new Error('PDF import failed: ' + _e);
1515
+ }
1516
+ imported++;
1517
+ continue;
1518
+ }
1519
+
1520
+ if (isSpreadsheet) {
1521
+ // Multi-sheet: create one document per sheet
1522
+ await ensureXLSX();
1523
+ const buf = await file.arrayBuffer();
1524
+ const wb = window.XLSX.read(buf, { type: 'array' });
1525
+ for (const sheetName of wb.SheetNames) {
1526
+ const csv = window.XLSX.utils.sheet_to_csv(wb.Sheets[sheetName]);
1527
+ if (!csv.trim()) continue;
1528
+ const sheetTitle = wb.SheetNames.length > 1
1529
+ ? `${baseTitle} - ${sheetName}` : baseTitle;
1530
+ const res = await fetch(`${API_BASE}/api/document`, {
1531
+ method: 'POST',
1532
+ headers: { 'Content-Type': 'application/json' },
1533
+ body: JSON.stringify({ title: sheetTitle, language: 'csv', content: csv }),
1534
+ });
1535
+ if (!res.ok) throw new Error('Server error');
1536
+ }
1537
+ imported++;
1538
+ } else {
1539
+ const content = await readFileContent(file);
1540
+ const res = await fetch(`${API_BASE}/api/document`, {
1541
+ method: 'POST',
1542
+ headers: { 'Content-Type': 'application/json' },
1543
+ body: JSON.stringify({ title: baseTitle, language, content }),
1544
+ });
1545
+ if (!res.ok) throw new Error('Server error');
1546
+ imported++;
1547
+ }
1548
+ } catch (e) {
1549
+ console.error('Failed to import file:', file.name, e);
1550
+ if (!_firstErr) _firstErr = (e && e.message) || String(e);
1551
+ failed++;
1552
+ }
1553
+ }
1554
+
1555
+ const msg = `Imported ${imported} file${imported !== 1 ? 's' : ''}` +
1556
+ (failed ? `, ${failed} failed${_firstErr ? ' — ' + _firstErr : ''}` : '');
1557
+ if (failed && uiModule) uiModule.showError(msg);
1558
+ else if (uiModule) uiModule.showToast(msg);
1559
+ await libraryFetch(false);
1560
+ }
1561
+
1562
+ export function openLibrary(opts) {
1563
+ if (_libraryOpen) {
1564
+ // Recover from stuck state: the swipe-to-dismiss in ui.js adds .hidden
1565
+ // to the modal without calling closeLibrary, so _libraryOpen can stay
1566
+ // true even though the modal is gone or invisible. Detect and reset.
1567
+ const existing = document.getElementById('doclib-modal');
1568
+ if (!existing || existing.classList.contains('hidden')) {
1569
+ if (existing) existing.remove();
1570
+ _libraryOpen = false;
1571
+ } else {
1572
+ return;
1573
+ }
1574
+ }
1575
+ _libraryOpen = true;
1576
+ _libraryImportMode = !!(opts && opts.import);
1577
+ _librarySelectMode = false;
1578
+ _librarySelectedIds.clear();
1579
+ _librarySearch = '';
1580
+ _libraryActiveLanguage = null;
1581
+ _librarySort = 'recent';
1582
+ _libraryOffset = 0;
1583
+ _libraryDocs = [];
1584
+
1585
+ // Create modal
1586
+ const modal = document.createElement('div');
1587
+ modal.className = 'modal';
1588
+ modal.id = 'doclib-modal';
1589
+ modal.innerHTML = `
1590
+ <div class="modal-content doclib-modal-content" style="width:min(640px, 92vw);max-height:85vh;background:var(--bg);">
1591
+ <div class="modal-header">
1592
+ <h4><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="8" y1="7" x2="16" y2="7"/><line x1="8" y1="11" x2="14" y2="11"/></svg>Library</h4>
1593
+ <button class="close-btn" id="doclib-close">\u2716</button>
1594
+ </div>
1595
+ <div class="lib-tabs" id="doclib-lib-tabs" style="padding:0 10px;">
1596
+ <button class="lib-tab" data-doclib-tab="chats"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><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>Chats</button>
1597
+ <button class="lib-tab active" data-doclib-tab="documents"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>Documents</button>
1598
+ <button class="lib-tab" data-doclib-tab="research"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px;margin-right:3px;"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>Research</button>
1599
+ <button class="lib-tab" data-doclib-tab="archive"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>Archive</button>
1600
+ </div>
1601
+ <div class="modal-body" style="display:flex;flex-direction:column;gap:10px;overflow:hidden;">
1602
+ <div id="doclib-panel-chats" data-doclib-panel="chats" class="admin-card" style="display:none;flex:1;flex-direction:column;overflow:hidden;">
1603
+ <div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
1604
+ <h2 style="margin:0;padding:0;line-height:1;">Chats <span id="doclib-chats-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
1605
+ </div>
1606
+ <p class="memory-desc doclib-desc">All active chat sessions. Click to open.</p>
1607
+ <div class="memory-toolbar">
1608
+ <div class="memory-category-filters">
1609
+ <select class="memory-sort-select" id="doclib-chats-sort">
1610
+ <option value="recent">Recent</option>
1611
+ <option value="oldest">Oldest</option>
1612
+ <option value="most-messages">Most messages</option>
1613
+ <option value="alpha">A\u2013Z</option>
1614
+ </select>
1615
+ <button class="memory-toolbar-btn" id="doclib-chats-select-btn">Select</button>
1616
+ <button class="memory-toolbar-btn" id="doclib-chats-tidy-btn" title="AI tidy: delete junk sessions and organize into folders"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy</button>
1617
+ </div>
1618
+ <input type="text" id="doclib-chats-search" placeholder="Search chats\u2026" class="memory-search-input" />
1619
+ <div id="doclib-chats-chips" class="doclib-lang-chips"></div>
1620
+ </div>
1621
+ <div id="doclib-chats-bulk" class="memory-bulk-bar hidden" style="margin-bottom:5px;">
1622
+ <label class="memory-bulk-check-all" style="position:relative;top:0px;left:-1px;"><input type="checkbox" id="doclib-chats-select-all" style="position:relative;top:0px;"> All</label>
1623
+ <span id="doclib-chats-selected-count">0 Selected</span>
1624
+ <button class="memory-toolbar-btn" id="doclib-chats-bulk-archive" style="position:relative;top:-3px;left:2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>Archive</button>
1625
+ <button class="memory-toolbar-btn danger" id="doclib-chats-bulk-delete" style="position:relative;left:2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>
1626
+ <button class="memory-toolbar-btn" id="doclib-chats-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;position:relative;left:2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
1627
+ </div>
1628
+ <div id="doclib-chats-grid" class="doclib-grid"></div>
1629
+ </div>
1630
+ <div id="doclib-panel-archive" data-doclib-panel="archive" class="admin-card" style="display:none;flex:1;flex-direction:column;overflow:hidden;">
1631
+ <div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
1632
+ <h2 style="margin:0;padding:0;line-height:1;position:relative;top:2px;">Archive <span id="doclib-arc-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
1633
+ </div>
1634
+ <p class="memory-desc doclib-desc" style="position:relative;top:0.5px;">Archived sessions. Restore to make active again.</p>
1635
+ <div class="memory-toolbar">
1636
+ <div class="memory-category-filters">
1637
+ <select class="memory-sort-select" id="doclib-arc-sort">
1638
+ <option value="recent">Recent</option>
1639
+ <option value="oldest">Oldest</option>
1640
+ <option value="most-messages">Most messages</option>
1641
+ <option value="alpha">A\u2013Z</option>
1642
+ </select>
1643
+ <button class="memory-toolbar-btn" id="doclib-arc-select-btn">Select</button>
1644
+ </div>
1645
+ <input type="text" id="doclib-arc-search" placeholder="Search archive\u2026" class="memory-search-input" />
1646
+ <div id="doclib-arc-chips" class="doclib-lang-chips"></div>
1647
+ </div>
1648
+ <div id="doclib-arc-bulk" class="memory-bulk-bar hidden" style="margin-bottom:5px;">
1649
+ <label class="memory-bulk-check-all" style="position:relative;top:0px;left:1px;"><input type="checkbox" id="doclib-arc-select-all"> All</label>
1650
+ <span id="doclib-arc-selected-count">0 Selected</span>
1651
+ <button class="memory-toolbar-btn" id="doclib-arc-bulk-restore" style="position:relative;top:-3px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>Restore</button>
1652
+ <button class="memory-toolbar-btn danger" id="doclib-arc-bulk-delete"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>
1653
+ <button class="memory-toolbar-btn" id="doclib-arc-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
1654
+ </div>
1655
+ <div id="doclib-arc-grid" class="doclib-grid"></div>
1656
+ </div>
1657
+ <div id="doclib-panel-research" data-doclib-panel="research" class="admin-card" style="display:none;flex:1;flex-direction:column;overflow:hidden;">
1658
+ <div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;margin-top:10px;">
1659
+ <h2 style="margin:0;padding:0;line-height:1;">Research <span id="doclib-research-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
1660
+ </div>
1661
+ <p class="memory-desc doclib-desc" style="position:relative;top:-1px;">Completed deep research reports. Click to view.</p>
1662
+ <div class="memory-toolbar">
1663
+ <div class="memory-category-filters">
1664
+ <select class="memory-sort-select" id="doclib-research-sort">
1665
+ <option value="recent">Recent</option>
1666
+ <option value="oldest">Oldest</option>
1667
+ <option value="most-sources">Most sources</option>
1668
+ <option value="alpha">A\u2013Z</option>
1669
+ </select>
1670
+ <button class="memory-toolbar-btn" id="doclib-research-select-btn">Select</button>
1671
+ <button class="memory-toolbar-btn" id="doclib-research-tidy-btn" title="Tidy: delete research with no sources or empty reports">Tidy</button>
1672
+ </div>
1673
+ <input type="text" id="doclib-research-search" placeholder="Search research\u2026" class="memory-search-input" />
1674
+ </div>
1675
+ <div id="doclib-research-bulk" class="memory-bulk-bar hidden" style="margin-bottom:5px;">
1676
+ <label class="memory-bulk-check-all" style="position:relative;top:0px;left:1px;"><input type="checkbox" id="doclib-research-select-all"> All</label>
1677
+ <span id="doclib-research-selected-count">0 Selected</span>
1678
+ <button class="memory-toolbar-btn" id="doclib-research-bulk-archive" style="position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>Archive</button>
1679
+ <button class="memory-toolbar-btn danger" id="doclib-research-bulk-delete" style="position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>
1680
+ <button class="memory-toolbar-btn" id="doclib-research-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
1681
+ </div>
1682
+ <div id="doclib-research-grid" class="doclib-grid"></div>
1683
+ </div>
1684
+ <div data-doclib-panel="documents" class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
1685
+ <div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
1686
+ <h2 style="margin:0;padding:0;line-height:1;">Documents <span id="doclib-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
1687
+ <button class="memory-toolbar-btn" id="doclib-import-file-btn" title="Import files from disk" style="margin-left:auto;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:2px;"><polyline points="7 10 12 5 17 10"/><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="21" x2="19" y2="21"/></svg> Import</button>
1688
+ <button class="memory-toolbar-btn" id="doclib-create-btn" title="Create new blank document"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg> Create</button>
1689
+ </div>
1690
+ <p class="memory-desc doclib-desc">Open documents in a session, clone to a new or import new files.</p>
1691
+ <div class="memory-toolbar">
1692
+ <div class="memory-category-filters">
1693
+ <select class="memory-sort-select" id="doclib-sort">
1694
+ <option value="recent">Recent</option>
1695
+ <option value="oldest">Oldest</option>
1696
+ <option value="edits">Most edits</option>
1697
+ <option value="alpha">A\u2013Z</option>
1698
+ </select>
1699
+ <button class="memory-toolbar-btn" id="doclib-select-btn" title="Select documents">Select</button>
1700
+ <button class="memory-toolbar-btn" id="doclib-tidy-btn" title="Tidy: remove empty / junk / duplicate documents">Tidy</button>
1701
+ </div>
1702
+ <input type="text" id="doclib-search" placeholder="Search titles &amp; content\u2026" class="memory-search-input" />
1703
+ <div id="doclib-chips" class="doclib-lang-chips"></div>
1704
+ </div>
1705
+ <input type="file" id="doclib-file-input" multiple style="display:none" />
1706
+ <div id="doclib-bulk-bar" class="memory-bulk-bar hidden" style="margin-bottom:5px;">
1707
+ <label class="memory-bulk-check-all" style="position:relative;top:0px;left:1px;"><input type="checkbox" id="doclib-select-all" /> All</label>
1708
+ <span id="doclib-selected-count">0 Selected</span>
1709
+ <button id="doclib-bulk-actions" class="memory-toolbar-btn" style="position:relative;top:-2px;margin-left:auto;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>Actions <span style="opacity:0.55;font-size:9px;">&#9660;</span></button>
1710
+ <button id="doclib-bulk-cancel" class="memory-toolbar-btn" title="Cancel (Esc)" style="margin-left:4px;margin-right:4px;padding:3px 6px;position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
1711
+ </div>
1712
+ <div class="doclib-grid" id="doclib-grid"></div>
1713
+ <button class="doclib-load-more" id="doclib-load-more" style="display:none">Load more</button>
1714
+ </div>
1715
+ </div>
1716
+ </div>
1717
+ `;
1718
+ document.body.appendChild(modal);
1719
+
1720
+ // Make modal draggable (same logic as other modals)
1721
+ {
1722
+ const content = modal.querySelector('.modal-content');
1723
+ const header = modal.querySelector('.modal-header');
1724
+ if (content && header) {
1725
+ // Restore saved position / fullscreen state
1726
+ try {
1727
+ const saved = JSON.parse(localStorage.getItem('doclib-pos'));
1728
+ if (saved && saved.fullscreen) {
1729
+ localStorage.removeItem('doclib-pos');
1730
+ } else if (saved && saved.left && saved.top) {
1731
+ content.style.position = 'fixed';
1732
+ content.style.left = saved.left;
1733
+ content.style.top = saved.top;
1734
+ content.style.margin = '0';
1735
+ // Clamp to viewport in case window was resized
1736
+ requestAnimationFrame(() => {
1737
+ const r = content.getBoundingClientRect();
1738
+ if (r.right > window.innerWidth) content.style.left = Math.max(0, window.innerWidth - r.width - 8) + 'px';
1739
+ if (r.bottom > window.innerHeight) content.style.top = Math.max(0, window.innerHeight - r.height - 8) + 'px';
1740
+ if (r.left < 0) content.style.left = '8px';
1741
+ if (r.top < 0) content.style.top = '8px';
1742
+ });
1743
+ }
1744
+ } catch {}
1745
+ // Replaced ~150 lines of inline drag/snap/dock with one helper call.
1746
+ // Library intentionally disables top-edge fullscreen snap: that layout
1747
+ // breaks dense icon/tool rows. Side docking still works.
1748
+ const FS_CLASS = 'doclib-fullscreen';
1749
+ const enterFullscreen = () => {
1750
+ if (modal.classList.contains(FS_CLASS)) return;
1751
+ modal.classList.add(FS_CLASS);
1752
+ content.style.position = 'fixed';
1753
+ content.style.left = '0';
1754
+ content.style.top = '0';
1755
+ content.style.right = '0';
1756
+ content.style.bottom = '0';
1757
+ content.style.width = '100vw';
1758
+ content.style.maxWidth = '100vw';
1759
+ content.style.height = '100vh';
1760
+ content.style.maxHeight = '100vh';
1761
+ content.style.borderRadius = '0';
1762
+ content.style.margin = '0';
1763
+ content.style.transform = 'none';
1764
+ try { localStorage.setItem('doclib-pos', JSON.stringify({ fullscreen: true })); } catch {}
1765
+ };
1766
+ const exitFullscreen = (cx, cy) => {
1767
+ if (!modal.classList.contains(FS_CLASS)) return;
1768
+ modal.classList.remove(FS_CLASS);
1769
+ content.style.width = '';
1770
+ content.style.maxWidth = '';
1771
+ content.style.height = '';
1772
+ content.style.maxHeight = '';
1773
+ content.style.borderRadius = '';
1774
+ content.style.right = '';
1775
+ content.style.bottom = '';
1776
+ const r0 = content.getBoundingClientRect();
1777
+ const w = r0.width || Math.min(900, window.innerWidth * 0.92);
1778
+ content.style.left = Math.max(8, cx - w / 2) + 'px';
1779
+ content.style.top = Math.max(8, cy - 20) + 'px';
1780
+ };
1781
+ makeWindowDraggable(modal, {
1782
+ content,
1783
+ header,
1784
+ fsClass: FS_CLASS,
1785
+ skipSelector: '.modal-close',
1786
+ onEnterFullscreen: enterFullscreen,
1787
+ onExitFullscreen: exitFullscreen,
1788
+ enableFullscreen: false,
1789
+ onDragEnd: () => {
1790
+ try { localStorage.setItem('doclib-pos', JSON.stringify({ left: content.style.left, top: content.style.top })); } catch {}
1791
+ },
1792
+ });
1793
+ }
1794
+ }
1795
+
1796
+ // Wire events
1797
+ document.getElementById('doclib-close').addEventListener('click', closeLibrary);
1798
+
1799
+ // Tab switching — Chats / Documents / Archive / Research
1800
+ let _activeLibTab = (opts && opts.tab) || 'documents';
1801
+ const _tabBtns = modal.querySelectorAll('[data-doclib-tab]');
1802
+ const _tabPanels = modal.querySelectorAll('[data-doclib-panel]');
1803
+
1804
+ // Client-side pagination for tabs whose API returns everything at once
1805
+ // (chats/archive/research). Render only this many initially; the
1806
+ // load-more button reveals more in chunks.
1807
+ const _LIB_PAGE_SIZE = 20;
1808
+ let _chatsVisibleLimit = _LIB_PAGE_SIZE;
1809
+ let _arcVisibleLimit = _LIB_PAGE_SIZE;
1810
+ let _researchVisibleLimit = _LIB_PAGE_SIZE;
1811
+
1812
+ function _appendInlineLoadMore(grid, totalCount, currentLimit, onClick) {
1813
+ if (!grid || !grid.parentElement) return;
1814
+ // Drop the previous instance (if any) — we re-render the list from
1815
+ // scratch each pass, so the button is regenerated alongside it.
1816
+ grid.parentElement.querySelectorAll(':scope > .doclib-inline-load-more').forEach(b => b.remove());
1817
+ if (totalCount <= currentLimit) return;
1818
+ const btn = document.createElement('button');
1819
+ btn.className = 'doclib-load-more doclib-inline-load-more';
1820
+ btn.textContent = `Load more (${currentLimit} of ${totalCount})`;
1821
+ btn.addEventListener('click', onClick);
1822
+ grid.parentElement.appendChild(btn);
1823
+ }
1824
+
1825
+ function _switchLibTab(tab) {
1826
+ _activeLibTab = tab;
1827
+ _tabBtns.forEach(b => b.classList.toggle('active', b.dataset.doclibTab === tab));
1828
+ _tabPanels.forEach(p => {
1829
+ if (p.dataset.doclibPanel === tab) {
1830
+ p.style.display = 'flex';
1831
+ } else {
1832
+ p.style.display = 'none';
1833
+ }
1834
+ });
1835
+ if (tab === 'chats') _renderLibChats();
1836
+ else if (tab === 'archive') _renderLibArchive();
1837
+ else if (tab === 'research') _renderLibResearch();
1838
+ }
1839
+
1840
+ _tabBtns.forEach(btn => {
1841
+ btn.addEventListener('click', () => _switchLibTab(btn.dataset.doclibTab));
1842
+ });
1843
+
1844
+ // ── Chats tab state ──
1845
+ let _chatsSessions = [];
1846
+ let _chatsSearch = '';
1847
+ let _chatsSort = 'recent';
1848
+ let _chatsSelectMode = false;
1849
+ const _chatsSelected = new Set();
1850
+ let _chatsModelFilter = '';
1851
+
1852
+ function _renderLibChats() {
1853
+ const grid = document.getElementById('doclib-chats-grid');
1854
+ if (!grid) return;
1855
+ grid.innerHTML = '';
1856
+ grid.appendChild(spinnerModule.createLoadingRow('Loading…'));
1857
+ fetch(API_BASE + '/api/sessions', { credentials: 'same-origin' }).then(r => r.json()).then(data => {
1858
+ const raw = Array.isArray(data) ? data : (data.sessions || []);
1859
+ _chatsSessions = raw.filter(s => !s.archived);
1860
+ _renderChatsGrid();
1861
+ _renderChatsChips();
1862
+ }).catch(() => { grid.innerHTML = '<div class="doclib-empty">Failed to load</div>'; });
1863
+ }
1864
+
1865
+ // Tap a chat row to expand inline: fetches the recent messages and
1866
+ // renders them as a preview with an "Open chat" button. Tap again to
1867
+ // collapse. Mirrors the documents-tab expand pattern.
1868
+ async function _toggleChatPreview(card, session) {
1869
+ const preview = card.querySelector('.doclib-chat-preview');
1870
+ if (!preview) return;
1871
+ const isOpen = card.classList.contains('doclib-card-expanded');
1872
+ // Collapse any other open preview in this grid first
1873
+ const grid = card.closest('.doclib-grid');
1874
+ if (grid) {
1875
+ grid.querySelectorAll('.doclib-card-expanded').forEach(c => {
1876
+ if (c !== card) {
1877
+ c.classList.remove('doclib-card-expanded');
1878
+ const p = c.querySelector('.doclib-chat-preview');
1879
+ if (p) { p.style.display = 'none'; p.innerHTML = ''; }
1880
+ }
1881
+ });
1882
+ }
1883
+ if (isOpen) {
1884
+ card.classList.remove('doclib-card-expanded');
1885
+ preview.style.display = 'none';
1886
+ preview.innerHTML = '';
1887
+ return;
1888
+ }
1889
+ card.classList.add('doclib-card-expanded');
1890
+ preview.style.display = 'block';
1891
+ preview.innerHTML = '<div style="opacity:0.4;font-size:11px;padding:8px 4px;">Loading…</div>';
1892
+ try {
1893
+ const res = await fetch(`${API_BASE}/api/history/${session.id}`, { credentials: 'same-origin' });
1894
+ if (!res.ok) throw new Error('Failed');
1895
+ const data = await res.json();
1896
+ const history = Array.isArray(data) ? data : (data.history || []);
1897
+ const recent = history.filter(m => m.role === 'user' || m.role === 'assistant').slice(-5);
1898
+ const sessionModel = (session.model || '').split('/').pop();
1899
+ const msgsHtml = recent.length
1900
+ ? recent.map(m => {
1901
+ const isUser = m.role === 'user';
1902
+ const raw = m.content || '';
1903
+ const truncated = raw.length > 600 ? raw.slice(0, 600) + '…' : raw;
1904
+ // Strip thinking blocks (internal model state) and render with
1905
+ // the same markdown pipeline the chat uses.
1906
+ const cleaned = truncated
1907
+ .replace(/<think>[\s\S]*?<\/think>/g, '')
1908
+ .replace(/<think>[\s\S]*$/, '')
1909
+ .trim();
1910
+ let body;
1911
+ try {
1912
+ body = markdownModule.mdToHtml(cleaned);
1913
+ } catch { body = _esc(cleaned); }
1914
+ // Per-message model can override the session default (e.g.
1915
+ // when comparing models in the same chat).
1916
+ const msgModel = (m.metadata && (m.metadata.model || m.metadata.model_name)) || '';
1917
+ const modelTag = !isUser && (msgModel || sessionModel)
1918
+ ? `<span class="doclib-chat-msg-model">${_esc(msgModel || sessionModel)}</span>`
1919
+ : '';
1920
+ return `<div class="doclib-chat-bubble-row ${isUser ? 'user' : 'assistant'}">
1921
+ <div class="doclib-chat-bubble">
1922
+ ${modelTag}
1923
+ <div class="doclib-chat-bubble-body">${body}</div>
1924
+ </div>
1925
+ </div>`;
1926
+ }).join('')
1927
+ : '<div style="opacity:0.4;font-size:11px;padding:6px 4px;">No messages yet</div>';
1928
+ const isArchive = !!session.archived;
1929
+ // Archived chats get a Restore button (unarchive); active chats get the
1930
+ // Archive button. Matches the research + document archive previews.
1931
+ const archiveHtml = isArchive
1932
+ ? '<button class="doclib-chat-restore-btn">' +
1933
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>' +
1934
+ 'Restore' +
1935
+ '</button>'
1936
+ : '<button class="doclib-chat-archive-btn">' +
1937
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>' +
1938
+ 'Archive' +
1939
+ '</button>';
1940
+ // Copy sits next to Archive on the left side of the action row.
1941
+ // Uses the same border-only secondary-action style — distinct from
1942
+ // the danger Delete (red) and the primary Open (right-aligned).
1943
+ // Copy is hidden in the Archive (keep the footer to Delete + Restore +
1944
+ // Open there). It still shows for active chats.
1945
+ const copyHtml = isArchive ? '' : '<button class="doclib-chat-copy-btn">' +
1946
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>' +
1947
+ 'Copy' +
1948
+ '</button>';
1949
+ const deleteHtml = '<button class="doclib-chat-delete-btn">' +
1950
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>' +
1951
+ 'Delete' +
1952
+ '</button>';
1953
+ preview.innerHTML =
1954
+ '<div class="doclib-chat-preview-messages">' + msgsHtml + '</div>' +
1955
+ '<div class="doclib-chat-preview-actions">' +
1956
+ deleteHtml +
1957
+ archiveHtml +
1958
+ copyHtml +
1959
+ '<button class="doclib-chat-open-btn">' +
1960
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>' +
1961
+ 'Open' +
1962
+ '</button>' +
1963
+ '</div>';
1964
+ const openBtn = preview.querySelector('.doclib-chat-open-btn');
1965
+ if (openBtn) openBtn.addEventListener('click', (e) => {
1966
+ e.stopPropagation();
1967
+ if (window.sessionModule) window.sessionModule.selectSession(session.id);
1968
+ closeLibrary();
1969
+ // Also collapse the wide sidebar so the picked chat sits
1970
+ // fullscreen — same gesture as picking a session in the
1971
+ // sidebar itself on mobile. Skip on desktop where the user
1972
+ // expects the sidebar to stay where they left it.
1973
+ if (window.innerWidth <= 768) {
1974
+ const sb = document.getElementById('sidebar');
1975
+ if (sb) {
1976
+ sb.classList.add('hidden');
1977
+ try { window.syncRailSide && window.syncRailSide(); } catch (_) {}
1978
+ }
1979
+ }
1980
+ });
1981
+ const archiveBtn = preview.querySelector('.doclib-chat-archive-btn');
1982
+ if (archiveBtn) archiveBtn.addEventListener('click', async (e) => {
1983
+ e.stopPropagation();
1984
+ await fetch(API_BASE + '/api/session/' + session.id + '/archive', {
1985
+ method: 'POST',
1986
+ headers: { 'Content-Type': 'application/json' },
1987
+ });
1988
+ _renderLibChats();
1989
+ });
1990
+ const restoreBtn = preview.querySelector('.doclib-chat-restore-btn');
1991
+ if (restoreBtn) restoreBtn.addEventListener('click', async (e) => {
1992
+ e.stopPropagation();
1993
+ await fetch(API_BASE + '/api/session/' + session.id + '/unarchive', { method: 'POST' });
1994
+ _renderLibArchive();
1995
+ });
1996
+ const copyBtn = preview.querySelector('.doclib-chat-copy-btn');
1997
+ if (copyBtn) copyBtn.addEventListener('click', (e) => {
1998
+ e.stopPropagation();
1999
+ _copyChatById(session.id);
2000
+ });
2001
+ const deleteBtn = preview.querySelector('.doclib-chat-delete-btn');
2002
+ if (deleteBtn) deleteBtn.addEventListener('click', async (e) => {
2003
+ e.stopPropagation();
2004
+ if (!await window.styledConfirm('Delete this chat?', { confirmText: 'Delete', danger: true })) return;
2005
+ await fetch(API_BASE + '/api/session/' + session.id, { method: 'DELETE' });
2006
+ card.style.maxHeight = `${Math.max(card.getBoundingClientRect().height, card.scrollHeight)}px`;
2007
+ card.classList.add('memory-tidy-removing');
2008
+ await new Promise(r => setTimeout(r, 520));
2009
+ if (isArchive) _renderLibArchive(); else _renderLibChats();
2010
+ });
2011
+ } catch (e) {
2012
+ preview.innerHTML = '<div style="opacity:0.5;font-size:11px;padding:6px 4px;color:var(--color-error);">Failed to load preview</div>';
2013
+ }
2014
+ }
2015
+
2016
+ function _renderChatsGrid() {
2017
+ const grid = document.getElementById('doclib-chats-grid');
2018
+ if (!grid) return;
2019
+ const _csb = document.getElementById('doclib-chats-select-btn');
2020
+ if (_csb) { _csb.classList.toggle('active', _chatsSelectMode); _csb.textContent = _chatsSelectMode ? 'Cancel' : 'Select'; }
2021
+ let filtered = _chatsSessions.slice();
2022
+ if (_chatsSearch) {
2023
+ const q = _chatsSearch.toLowerCase();
2024
+ filtered = filtered.filter(s => (s.name || '').toLowerCase().includes(q) || (s.model || '').toLowerCase().includes(q));
2025
+ }
2026
+ if (_chatsModelFilter) filtered = filtered.filter(s => s.folder === _chatsModelFilter);
2027
+ if (_chatsSort === 'oldest') filtered.sort((a, b) => (a.updated_at || '') > (b.updated_at || '') ? 1 : -1);
2028
+ else if (_chatsSort === 'most-messages') filtered.sort((a, b) => (b.message_count || 0) - (a.message_count || 0));
2029
+ else if (_chatsSort === 'alpha') filtered.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
2030
+ else filtered.sort((a, b) => (b.updated_at || '') > (a.updated_at || '') ? 1 : -1);
2031
+
2032
+ const stats = document.getElementById('doclib-chats-stats');
2033
+ if (stats) stats.textContent = filtered.length + ' chat' + (filtered.length !== 1 ? 's' : '');
2034
+
2035
+ if (!filtered.length) {
2036
+ // Sad-mouth smiley (downturn curve) for "nothing here yet".
2037
+ const _sadIco = '<span style="vertical-align:-3px;margin-left:6px;">' + uiModule.emptyStateIcon('sad') + '</span>';
2038
+ grid.innerHTML = '<div class="doclib-empty">No chats' + _sadIco + '</div>';
2039
+ _appendInlineLoadMore(grid, 0, _chatsVisibleLimit, () => {});
2040
+ return;
2041
+ }
2042
+ const total = filtered.length;
2043
+ const visible = filtered.slice(0, _chatsVisibleLimit);
2044
+ grid.innerHTML = '';
2045
+ _maybeCascadeGrid(grid, 'chats');
2046
+ for (const s of visible) {
2047
+ const card = document.createElement('div');
2048
+ card.className = 'memory-item doclib-chat-row';
2049
+ card.style.cursor = 'pointer';
2050
+ card.dataset.sid = s.id;
2051
+ const model = (s.model || '').split('/').pop();
2052
+ const cbHtml = _chatsSelectMode ? '<input type="checkbox" class="memory-select-cb"' + (_chatsSelected.has(s.id) ? ' checked' : '') + '>' : '';
2053
+ const chatIconSvg = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.4;flex-shrink:0;"><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>';
2054
+ const chevronSvg = '<span class="doclib-card-chevron"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>';
2055
+ // Msg count badge inside the title, dimmer than the name so it
2056
+ // reads as metadata at a glance. Hidden when count is 0 so
2057
+ // brand-new "New Chat" rows don't show "\u00b7 0 msgs".
2058
+ const _chatMsgs = s.message_count || 0;
2059
+ const msgCountHtml = _chatMsgs > 0
2060
+ ? '<span style="opacity:0.45;font-weight:normal;font-size:0.9em;margin-left:6px;">\u00b7 ' + _chatMsgs + ' msg' + (_chatMsgs === 1 ? '' : 's') + '</span>'
2061
+ : '';
2062
+ card.innerHTML =
2063
+ '<div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;">' +
2064
+ cbHtml +
2065
+ '<div style="flex:1;min-width:0;">' +
2066
+ '<div class="memory-item-title">' + chatIconSvg + _esc(s.name || 'Untitled') + msgCountHtml + '</div>' +
2067
+ '<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">' + [model, _relTime(s.updated_at)].filter(Boolean).join(' \u00b7 ') + '</div>' +
2068
+ '</div>' +
2069
+ chevronSvg +
2070
+ '<div class="memory-item-actions"><button class="memory-item-btn _chat-menu" title="Actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button></div>' +
2071
+ '</div>' +
2072
+ '<div class="doclib-chat-preview" style="display:none;"></div>';
2073
+ const cb = card.querySelector('.memory-select-cb');
2074
+ if (cb) { cb.addEventListener('click', e => e.stopPropagation()); cb.addEventListener('change', () => { if (cb.checked) _chatsSelected.add(s.id); else _chatsSelected.delete(s.id); _updateChatsCount(); }); }
2075
+ card.querySelector('._chat-menu').addEventListener('click', (e) => { e.stopPropagation(); _showLibDropdown(e.currentTarget, [
2076
+ { label: 'Open', action: () => { if (window.sessionModule) window.sessionModule.selectSession(s.id); } },
2077
+ { label: 'Copy', action: () => _copyChatById(s.id) },
2078
+ { label: 'Archive', action: async () => { await fetch(API_BASE + '/api/session/' + s.id + '/archive', { method: 'POST', headers: {'Content-Type':'application/json'} }); _renderLibChats(); } },
2079
+ { label: 'Delete', action: async () => {
2080
+ if (!await window.styledConfirm('Delete this chat?', { confirmText: 'Delete', danger: true })) return;
2081
+ await fetch(API_BASE + '/api/session/' + s.id, { method: 'DELETE' });
2082
+ card.style.maxHeight = `${Math.max(card.getBoundingClientRect().height, card.scrollHeight)}px`;
2083
+ card.classList.add('memory-tidy-removing');
2084
+ await new Promise(r => setTimeout(r, 520));
2085
+ _renderLibChats();
2086
+ }, danger: true },
2087
+ ], { onSelect: () => {
2088
+ _chatsSelectMode = true;
2089
+ _chatsSelected.add(s.id);
2090
+ document.getElementById('doclib-chats-bulk')?.classList.remove('hidden');
2091
+ _renderChatsGrid();
2092
+ } }); });
2093
+ card.addEventListener('click', (e) => {
2094
+ if (card._suppressNextClick) { card._suppressNextClick = false; return; }
2095
+ if (_chatsSelectMode) { const c = card.querySelector('.memory-select-cb'); if (c) { c.checked = !c.checked; if (c.checked) _chatsSelected.add(s.id); else _chatsSelected.delete(s.id); _updateChatsCount(); } return; }
2096
+ if (e.target.closest('._chat-menu') || e.target.closest('.memory-select-cb') || e.target.closest('.doclib-chat-open-btn')) return;
2097
+ _toggleChatPreview(card, s);
2098
+ });
2099
+ _attachLongPressMenu(card, '._chat-menu');
2100
+ grid.appendChild(card);
2101
+ }
2102
+ _appendInlineLoadMore(grid, total, _chatsVisibleLimit, () => {
2103
+ _chatsVisibleLimit += _LIB_PAGE_SIZE;
2104
+ _renderChatsGrid();
2105
+ });
2106
+ }
2107
+
2108
+ function _renderChatsChips() {
2109
+ const el = document.getElementById('doclib-chats-chips');
2110
+ if (!el) return;
2111
+ const counts = {};
2112
+ _chatsSessions.forEach(s => { const f = s.folder; if (f) counts[f] = (counts[f] || 0) + 1; });
2113
+ const folders = Object.keys(counts).sort();
2114
+ if (folders.length < 1) { el.innerHTML = ''; return; }
2115
+ el.innerHTML = '';
2116
+ const mk = (label, val, count) => { const c = document.createElement('button'); c.className = 'memory-cat-chip' + (_chatsModelFilter === val ? ' active' : ''); c.textContent = label + ' (' + count + ')'; c.addEventListener('click', () => { _chatsModelFilter = _chatsModelFilter === val ? '' : val; _renderChatsGrid(); _renderChatsChips(); }); el.appendChild(c); };
2117
+ mk('all', '', _chatsSessions.length);
2118
+ folders.forEach(f => mk(f, f, counts[f]));
2119
+ }
2120
+
2121
+ function _updateChatsCount() { const el = document.getElementById('doclib-chats-selected-count'); if (el) el.textContent = _chatsSelected.size + ' Selected'; }
2122
+
2123
+ // Chats event listeners
2124
+ document.getElementById('doclib-chats-sort').addEventListener('change', (e) => { _chatsSort = e.target.value; _renderChatsGrid(); });
2125
+ document.getElementById('doclib-chats-search').addEventListener('input', (e) => { _chatsSearch = e.target.value.trim(); _renderChatsGrid(); });
2126
+ document.getElementById('doclib-chats-select-btn').addEventListener('click', () => { _chatsSelectMode = !_chatsSelectMode; _chatsSelected.clear(); document.getElementById('doclib-chats-bulk').classList.toggle('hidden', !_chatsSelectMode); _renderChatsGrid(); });
2127
+ document.getElementById('doclib-chats-bulk-cancel')?.addEventListener('click', () => {
2128
+ _chatsSelectMode = false; _chatsSelected.clear();
2129
+ document.getElementById('doclib-chats-bulk').classList.add('hidden');
2130
+ _renderChatsGrid();
2131
+ });
2132
+ function _chatsToggleAll() {
2133
+ const allCb = document.getElementById('doclib-chats-select-all');
2134
+ const newState = _chatsSelected.size < _chatsSessions.length;
2135
+ if (allCb) allCb.checked = newState;
2136
+ document.querySelectorAll('#doclib-chats-grid .memory-select-cb').forEach(cb => { cb.checked = newState; });
2137
+ _chatsSessions.forEach(s => { if (newState) _chatsSelected.add(s.id); else _chatsSelected.delete(s.id); });
2138
+ _updateChatsCount();
2139
+ }
2140
+ document.getElementById('doclib-chats-select-all').addEventListener('change', _chatsToggleAll);
2141
+ document.getElementById('doclib-chats-bulk').addEventListener('click', (e) => {
2142
+ if (e.target.closest('button') || e.target.closest('input')) return;
2143
+ _chatsToggleAll();
2144
+ });
2145
+ document.getElementById('doclib-chats-bulk-archive').addEventListener('click', async () => {
2146
+ const count = _chatsSelected.size;
2147
+ if (!count) return;
2148
+ const grid = document.getElementById('doclib-chats-grid');
2149
+ if (grid) {
2150
+ grid.querySelectorAll('.doclib-card').forEach(card => {
2151
+ const sid = card.dataset.sid || card.dataset.sessionId;
2152
+ if (sid && _chatsSelected.has(sid)) {
2153
+ card.style.transition = 'opacity 0.25s, transform 0.25s';
2154
+ card.style.opacity = '0';
2155
+ card.style.transform = 'scale(0.95)';
2156
+ }
2157
+ });
2158
+ }
2159
+ await new Promise(r => setTimeout(r, 250));
2160
+ const ids = [..._chatsSelected];
2161
+ const results = await Promise.all(
2162
+ ids.map(sid => fetch(API_BASE + '/api/session/' + sid + '/archive', { method: 'POST', headers: {'Content-Type':'application/json'} })
2163
+ .then(r => ({ sid, ok: r.ok }))
2164
+ .catch(() => ({ sid, ok: false }))
2165
+ )
2166
+ );
2167
+ const failed = results.filter(r => !r.ok).map(r => r.sid);
2168
+ if (failed.length && grid) {
2169
+ grid.querySelectorAll('.doclib-card').forEach(card => {
2170
+ const sid = card.dataset.sid || card.dataset.sessionId;
2171
+ if (sid && failed.includes(sid)) {
2172
+ card.style.opacity = '';
2173
+ card.style.transform = '';
2174
+ }
2175
+ });
2176
+ if (window.uiModule) window.uiModule.showError(`Failed to archive ${failed.length} of ${ids.length} chat${ids.length > 1 ? 's' : ''}`);
2177
+ }
2178
+ _chatsSelected.clear();
2179
+ _chatsSelectMode = false;
2180
+ document.getElementById('doclib-chats-bulk').classList.add('hidden');
2181
+ _renderLibChats();
2182
+ });
2183
+ document.getElementById('doclib-chats-bulk-delete').addEventListener('click', async () => {
2184
+ const count = _chatsSelected.size;
2185
+ if (!count) return;
2186
+ if (!await window.styledConfirm(`Delete ${count} chat${count > 1 ? 's' : ''}? This cannot be undone.`, { confirmText: 'Delete', danger: true })) return;
2187
+ // Fade out selected cards
2188
+ const grid = document.getElementById('doclib-chats-grid');
2189
+ if (grid) {
2190
+ grid.querySelectorAll('.doclib-card').forEach(card => {
2191
+ const sid = card.dataset.sid || card.dataset.sessionId;
2192
+ if (sid && _chatsSelected.has(sid)) {
2193
+ card.style.transition = 'opacity 0.25s, transform 0.25s';
2194
+ card.style.opacity = '0';
2195
+ card.style.transform = 'scale(0.95)';
2196
+ }
2197
+ });
2198
+ }
2199
+ // Delete after animation. v2 review HIGH-8: inspect each response
2200
+ // so cards that the server rejected get restored (instead of
2201
+ // staying faded out forever) and the user sees an aggregate
2202
+ // error toast.
2203
+ await new Promise(r => setTimeout(r, 250));
2204
+ const ids = [..._chatsSelected];
2205
+ const results = await Promise.all(
2206
+ ids.map(sid => fetch(API_BASE + '/api/session/' + sid, { method: 'DELETE' })
2207
+ .then(r => ({ sid, ok: r.ok }))
2208
+ .catch(() => ({ sid, ok: false }))
2209
+ )
2210
+ );
2211
+ const failed = results.filter(r => !r.ok).map(r => r.sid);
2212
+ if (failed.length && grid) {
2213
+ // Restore faded cards for the rows the server refused.
2214
+ grid.querySelectorAll('.doclib-card').forEach(card => {
2215
+ const sid = card.dataset.sid || card.dataset.sessionId;
2216
+ if (sid && failed.includes(sid)) {
2217
+ card.style.opacity = '';
2218
+ card.style.transform = '';
2219
+ }
2220
+ });
2221
+ if (window.uiModule) window.uiModule.showError(`Failed to delete ${failed.length} of ${ids.length} chat${ids.length > 1 ? 's' : ''}`);
2222
+ }
2223
+ _chatsSelected.clear();
2224
+ _chatsSelectMode = false;
2225
+ document.getElementById('doclib-chats-bulk').classList.add('hidden');
2226
+ _renderLibChats();
2227
+ });
2228
+
2229
+ // Tidy button — AI cleanup + organize into folders
2230
+ document.getElementById('doclib-chats-tidy-btn').addEventListener('click', async () => {
2231
+ const tidyBtn = document.getElementById('doclib-chats-tidy-btn');
2232
+ const origHTML = tidyBtn.innerHTML;
2233
+ tidyBtn.disabled = true;
2234
+ tidyBtn.classList.add('spinning');
2235
+ tidyBtn.textContent = '';
2236
+ // Silent whirlpool, nudged up to line up with the surrounding button
2237
+ // text in the Chats header. The previous version checked
2238
+ // `window.spinnerModule` (never bound) and always fell through to a
2239
+ // plain "Tidying..." label.
2240
+ const sp = spinnerModule.create('', 'clean', 'whirlpool');
2241
+ const el = sp.createElement();
2242
+ el.style.position = 'relative';
2243
+ el.style.top = '1px';
2244
+ tidyBtn.appendChild(el);
2245
+ sp.start();
2246
+ try {
2247
+ const res = await fetch(API_BASE + '/api/sessions/auto-sort', { method: 'POST', credentials: 'same-origin' });
2248
+ const data = await res.json();
2249
+ if (!res.ok) throw new Error(data.detail || 'Tidy failed');
2250
+ if (data.status === 'ok') {
2251
+ if (window.uiModule) window.uiModule.showToast('Sorted ' + data.updated + ' sessions into ' + data.folders.length + ' folders');
2252
+ if (window.sessionModule) await window.sessionModule.loadSessions();
2253
+ _renderLibChats();
2254
+ } else {
2255
+ if (window.uiModule) window.uiModule.showToast(data.reason || 'Nothing to tidy');
2256
+ }
2257
+ } catch (e) {
2258
+ if (window.uiModule) window.uiModule.showError('Tidy: ' + e.message);
2259
+ } finally {
2260
+ tidyBtn.disabled = false;
2261
+ tidyBtn.classList.remove('spinning');
2262
+ tidyBtn.innerHTML = origHTML;
2263
+ }
2264
+ });
2265
+
2266
+ // ── Archive tab state ──
2267
+ let _arcSessions = [];
2268
+ let _arcDocs = []; // archived documents
2269
+ let _arcResearch = []; // archived research reports
2270
+ let _arcSearch = '';
2271
+ let _arcSort = 'recent';
2272
+ let _arcSelectMode = false;
2273
+ const _arcSelected = new Set();
2274
+ let _arcModelFilter = '';
2275
+ let _arcTypeFilter = ''; // '', 'chats', 'documents', 'research'
2276
+
2277
+ function _renderLibArchive() {
2278
+ const grid = document.getElementById('doclib-arc-grid');
2279
+ if (!grid) return;
2280
+ grid.innerHTML = '';
2281
+ grid.appendChild(spinnerModule.createLoadingRow('Loading…'));
2282
+ // Archive tab is the home for ALL archived items — chats, documents, and
2283
+ // research — each rendered with its own icon. Load the three in parallel.
2284
+ Promise.all([
2285
+ fetch(API_BASE + '/api/sessions/archived?limit=100&sort=recent', { credentials: 'same-origin' }).then(r => r.json()).catch(() => ({})),
2286
+ fetch(API_BASE + '/api/documents/library?archived=true&limit=50', { credentials: 'same-origin' }).then(r => r.json()).catch(() => ({})),
2287
+ fetch('/api/research/library?archived=true', { credentials: 'same-origin' }).then(r => r.json()).catch(() => ({})),
2288
+ ]).then(([s, d, r]) => {
2289
+ // These are all archived by definition — flag them so the expanded
2290
+ // chat preview hides its (redundant) "Archive" button.
2291
+ _arcSessions = (s.sessions || []).map(x => ({ ...x, archived: true }));
2292
+ _arcDocs = d.documents || [];
2293
+ _arcResearch = (r.research || []).map(x => ({ ...x, archived: true }));
2294
+ _renderArcGrid();
2295
+ _renderArcChips();
2296
+ }).catch(() => { grid.innerHTML = '<div class="doclib-empty">Failed to load</div>'; });
2297
+ }
2298
+
2299
+ // Inline expand/collapse for an archived DOCUMENT card (chat-style). Loads
2300
+ // the doc content into the card's .doclib-chat-preview. Lag-safe: caps the
2301
+ // shown text and skips highlighting (archived previews are read-only peeks).
2302
+ async function _toggleArcDocPreview(card, d) {
2303
+ const preview = card.querySelector('.doclib-chat-preview');
2304
+ if (!preview) return;
2305
+ const grid = card.closest('.doclib-grid');
2306
+ if (grid) {
2307
+ grid.querySelectorAll('.doclib-card-expanded').forEach(c => {
2308
+ if (c !== card) {
2309
+ c.classList.remove('doclib-card-expanded');
2310
+ const p = c.querySelector('.doclib-chat-preview');
2311
+ if (p) { p.style.display = 'none'; p.innerHTML = ''; }
2312
+ }
2313
+ });
2314
+ }
2315
+ if (card.classList.contains('doclib-card-expanded')) {
2316
+ card.classList.remove('doclib-card-expanded');
2317
+ preview.style.display = 'none'; preview.innerHTML = '';
2318
+ return;
2319
+ }
2320
+ card.classList.add('doclib-card-expanded');
2321
+ preview.style.display = 'block';
2322
+ preview.innerHTML = '<div style="opacity:0.4;font-size:11px;padding:8px 4px;">Loading…</div>';
2323
+ try {
2324
+ const res = await fetch(`${API_BASE}/api/document/${d.id}`, { credentials: 'same-origin' });
2325
+ if (!res.ok) throw new Error('failed');
2326
+ const full = await res.json();
2327
+ const content = (full.current_content || '').slice(0, 20000);
2328
+ const pre = document.createElement('pre');
2329
+ pre.style.cssText = 'white-space:pre-wrap;word-break:break-word;font-size:11px;margin:6px 4px;max-height:50vh;overflow:auto;';
2330
+ pre.textContent = content || '(empty document)';
2331
+ preview.innerHTML = '';
2332
+ preview.appendChild(pre);
2333
+
2334
+ // Footer — uses the same visible .doclib-chat-preview-actions style as
2335
+ // the chat/research previews (the .doclib-card-expanded-actions class is
2336
+ // display:none unless inside a .doclib-card, which these archive rows
2337
+ // are not). Delete + Restore, matching the others.
2338
+ const actions = document.createElement('div');
2339
+ actions.className = 'doclib-chat-preview-actions';
2340
+ actions.innerHTML =
2341
+ '<button class="doclib-chat-delete-btn"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>' +
2342
+ '<button class="doclib-chat-restore-btn"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>Restore</button>' +
2343
+ '<button class="doclib-chat-open-btn"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>Open</button>';
2344
+ actions.querySelector('.doclib-chat-delete-btn').addEventListener('click', async (ev) => {
2345
+ ev.stopPropagation();
2346
+ if (!await window.styledConfirm('Delete this document?', { confirmText: 'Delete', danger: true })) return;
2347
+ await fetch(`${API_BASE}/api/document/${d.id}`, { method: 'DELETE', credentials: 'same-origin' });
2348
+ _renderLibArchive();
2349
+ });
2350
+ actions.querySelector('.doclib-chat-restore-btn').addEventListener('click', async (ev) => {
2351
+ ev.stopPropagation();
2352
+ await fetch(`${API_BASE}/api/document/${d.id}/archive?archived=false`, { method: 'POST', credentials: 'same-origin' });
2353
+ _renderLibArchive();
2354
+ });
2355
+ // Open = clone the doc into the active session and surface it in the editor.
2356
+ actions.querySelector('.doclib-chat-open-btn').addEventListener('click', (ev) => {
2357
+ ev.stopPropagation();
2358
+ libraryImportDocument(d);
2359
+ });
2360
+ preview.appendChild(actions);
2361
+ } catch {
2362
+ preview.innerHTML = '<div style="opacity:0.4;font-size:11px;padding:8px 4px;">Failed to load preview</div>';
2363
+ }
2364
+ }
2365
+
2366
+ function _renderArcGrid() {
2367
+ const grid = document.getElementById('doclib-arc-grid');
2368
+ if (!grid) return;
2369
+ const _asb = document.getElementById('doclib-arc-select-btn');
2370
+ if (_asb) { _asb.classList.toggle('active', _arcSelectMode); _asb.textContent = _arcSelectMode ? 'Cancel' : 'Select'; }
2371
+ let filtered = _arcSessions.slice();
2372
+ if (_arcSearch) {
2373
+ const q = _arcSearch.toLowerCase();
2374
+ filtered = filtered.filter(s => (s.name || '').toLowerCase().includes(q) || (s.model || '').toLowerCase().includes(q));
2375
+ }
2376
+ if (_arcModelFilter) filtered = filtered.filter(s => (s.model || '').split('/').pop() === _arcModelFilter);
2377
+ if (_arcSort === 'oldest') filtered.sort((a, b) => (a.updated_at || '') > (b.updated_at || '') ? 1 : -1);
2378
+ else if (_arcSort === 'most-messages') filtered.sort((a, b) => (b.message_count || 0) - (a.message_count || 0));
2379
+ else if (_arcSort === 'alpha') filtered.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
2380
+ else filtered.sort((a, b) => (b.updated_at || '') > (a.updated_at || '') ? 1 : -1);
2381
+
2382
+ // Archived documents + research also live here — filter them by the same search.
2383
+ const _aq = (_arcSearch || '').toLowerCase();
2384
+ let filtDocs = _aq ? _arcDocs.filter(d => (d.title || '').toLowerCase().includes(_aq)) : _arcDocs;
2385
+ let filtResearch = _aq ? _arcResearch.filter(r => (r.query || '').toLowerCase().includes(_aq)) : _arcResearch;
2386
+
2387
+ // Type filter chips (Chats / Documents / Research) zero out the others.
2388
+ const _showChats = !_arcTypeFilter || _arcTypeFilter === 'chats';
2389
+ const _showDocs = !_arcTypeFilter || _arcTypeFilter === 'documents';
2390
+ const _showResearch = !_arcTypeFilter || _arcTypeFilter === 'research';
2391
+ if (!_showChats) filtered = [];
2392
+ if (!_showDocs) filtDocs = [];
2393
+ if (!_showResearch) filtResearch = [];
2394
+
2395
+ const stats = document.getElementById('doclib-arc-stats');
2396
+ if (stats) stats.textContent = (filtered.length + filtDocs.length + filtResearch.length) + ' archived';
2397
+
2398
+ if (!filtered.length && !filtDocs.length && !filtResearch.length) {
2399
+ // Neutral / no-smile face for "nothing archived here".
2400
+ const _neutralIco = '<span style="vertical-align:-3px;margin-left:6px;">' + uiModule.emptyStateIcon('neutral') + '</span>';
2401
+ grid.innerHTML = '<div class="doclib-empty">No archived items' + _neutralIco + '</div>';
2402
+ _appendInlineLoadMore(grid, 0, _arcVisibleLimit, () => {});
2403
+ return;
2404
+ }
2405
+ const total = filtered.length;
2406
+ const visible = filtered.slice(0, _arcVisibleLimit);
2407
+ grid.innerHTML = '';
2408
+ _maybeCascadeGrid(grid, 'archive');
2409
+ for (const s of visible) {
2410
+ const card = document.createElement('div');
2411
+ card.className = 'memory-item doclib-chat-row';
2412
+ card.style.cursor = 'pointer';
2413
+ card.dataset.sid = s.id;
2414
+ card.dataset.arckey = 'chats:' + s.id;
2415
+ const model = (s.model || '').split('/').pop();
2416
+ const cbHtml = _arcSelectMode ? '<input type="checkbox" class="memory-select-cb" data-arckey="chats:' + s.id + '"' + (_arcSelected.has('chats:' + s.id) ? ' checked' : '') + '>' : '';
2417
+ const arcIconSvg = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.5;flex-shrink:0;"><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>';
2418
+ card.innerHTML =
2419
+ '<div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;">' +
2420
+ cbHtml +
2421
+ '<div style="flex:1;min-width:0;">' +
2422
+ '<div class="memory-item-title">' + arcIconSvg + _esc(s.name || 'Untitled') + '</div>' +
2423
+ '<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">' + [model, _relTime(s.updated_at)].filter(Boolean).join(' \u00b7 ') + '</div>' +
2424
+ '</div>' +
2425
+ '<div class="memory-item-actions"><button class="memory-item-btn _arc-menu" title="Actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button></div>' +
2426
+ '</div>' +
2427
+ '<div class="doclib-chat-preview" style="display:none;"></div>';
2428
+ const cb = card.querySelector('.memory-select-cb');
2429
+ if (cb) { cb.addEventListener('click', e => e.stopPropagation()); cb.addEventListener('change', () => { if (cb.checked) _arcSelected.add('chats:' + s.id); else _arcSelected.delete('chats:' + s.id); _updateArcCount(); }); }
2430
+ card.querySelector('._arc-menu').addEventListener('click', (e) => { e.stopPropagation(); _showLibDropdown(e.currentTarget, [
2431
+ { label: 'Open', action: () => { if (window.sessionModule) window.sessionModule.selectSession(s.id); } },
2432
+ { label: 'Copy', action: () => _copyChatById(s.id) },
2433
+ { label: 'Restore', action: async () => { await fetch(API_BASE + '/api/session/' + s.id + '/unarchive', { method: 'POST' }); _renderLibArchive(); } },
2434
+ { label: 'Delete', action: async () => {
2435
+ if (!await window.styledConfirm('Delete this chat permanently?', { confirmText: 'Delete', danger: true })) return;
2436
+ await fetch(API_BASE + '/api/session/' + s.id, { method: 'DELETE' });
2437
+ _renderLibArchive();
2438
+ }, danger: true },
2439
+ ], { onSelect: () => {
2440
+ _arcSelectMode = true;
2441
+ _arcSelected.add('chats:' + s.id);
2442
+ document.getElementById('doclib-arc-bulk')?.classList.remove('hidden');
2443
+ _renderArcGrid();
2444
+ } }); });
2445
+ card.addEventListener('click', (e) => {
2446
+ if (card._suppressNextClick) { card._suppressNextClick = false; return; }
2447
+ if (_arcSelectMode) { const c = card.querySelector('.memory-select-cb'); if (c) { c.checked = !c.checked; if (c.checked) _arcSelected.add('chats:' + s.id); else _arcSelected.delete('chats:' + s.id); _updateArcCount(); } return; }
2448
+ if (e.target.closest('._arc-menu') || e.target.closest('.memory-select-cb') || e.target.closest('.doclib-chat-open-btn')) return;
2449
+ _toggleChatPreview(card, s);
2450
+ });
2451
+ _attachLongPressMenu(card, '._arc-menu');
2452
+ grid.appendChild(card);
2453
+ }
2454
+ // Archived DOCUMENTS — document icon, Restore / Delete.
2455
+ const _arcDocIco = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.5;flex-shrink:0;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>';
2456
+ for (const d of filtDocs) {
2457
+ const card = document.createElement('div');
2458
+ card.className = 'memory-item doclib-chat-row';
2459
+ card.style.cursor = 'pointer';
2460
+ card.dataset.arckey = 'documents:' + d.id;
2461
+ const _dcb = _arcSelectMode ? '<input type="checkbox" class="memory-select-cb" data-arckey="documents:' + d.id + '"' + (_arcSelected.has('documents:' + d.id) ? ' checked' : '') + '>' : '';
2462
+ card.innerHTML =
2463
+ '<div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;">' +
2464
+ _dcb +
2465
+ '<div style="flex:1;min-width:0;">' +
2466
+ '<div class="memory-item-title">' + _arcDocIco + _esc(d.title || 'Untitled') + '</div>' +
2467
+ '<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">' + ['Document', (d.language || 'text'), _relTime(d.updated_at)].filter(Boolean).join(' · ') + '</div>' +
2468
+ '</div>' +
2469
+ '<div class="memory-item-actions"><button class="memory-item-btn _arc-doc-menu" title="Actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button></div>' +
2470
+ '</div>' +
2471
+ '<div class="doclib-chat-preview" style="display:none;"></div>';
2472
+ const _dcbEl = card.querySelector('.memory-select-cb');
2473
+ if (_dcbEl) { _dcbEl.addEventListener('click', e => e.stopPropagation()); _dcbEl.addEventListener('change', () => { if (_dcbEl.checked) _arcSelected.add('documents:' + d.id); else _arcSelected.delete('documents:' + d.id); _updateArcCount(); }); }
2474
+ card.addEventListener('click', (e) => {
2475
+ if (e.target.closest('._arc-doc-menu') || e.target.closest('.memory-select-cb')) return;
2476
+ if (_arcSelectMode) { const c = card.querySelector('.memory-select-cb'); if (c) { c.checked = !c.checked; if (c.checked) _arcSelected.add('documents:' + d.id); else _arcSelected.delete('documents:' + d.id); _updateArcCount(); } return; }
2477
+ _toggleArcDocPreview(card, d);
2478
+ });
2479
+ card.querySelector('._arc-doc-menu').addEventListener('click', (e) => { e.stopPropagation(); _showLibDropdown(e.currentTarget, [
2480
+ { label: 'Restore', action: async () => { await fetch(API_BASE + '/api/document/' + d.id + '/archive?archived=false', { method: 'POST', credentials: 'same-origin' }); _renderLibArchive(); } },
2481
+ { label: 'Delete', danger: true, action: async () => { if (!await window.styledConfirm('Delete this document?', { confirmText: 'Delete', danger: true })) return; await fetch(API_BASE + '/api/document/' + d.id, { method: 'DELETE', credentials: 'same-origin' }); _renderLibArchive(); } },
2482
+ ], { onSelect: () => {
2483
+ _arcSelectMode = true;
2484
+ _arcSelected.add('documents:' + d.id);
2485
+ document.getElementById('doclib-arc-bulk')?.classList.remove('hidden');
2486
+ _renderArcGrid();
2487
+ } }); });
2488
+ _attachLongPressMenu(card, '._arc-doc-menu');
2489
+ grid.appendChild(card);
2490
+ }
2491
+ // Archived RESEARCH — magnifier icon, Open / Restore / Delete.
2492
+ const _arcResIco = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.5;flex-shrink:0;"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>';
2493
+ for (const r of filtResearch) {
2494
+ const card = document.createElement('div');
2495
+ card.className = 'memory-item doclib-chat-row';
2496
+ card.style.cursor = 'pointer';
2497
+ card.dataset.arckey = 'research:' + r.id;
2498
+ const _rcb = _arcSelectMode ? '<input type="checkbox" class="memory-select-cb" data-arckey="research:' + r.id + '"' + (_arcSelected.has('research:' + r.id) ? ' checked' : '') + '>' : '';
2499
+ card.innerHTML =
2500
+ '<div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;">' +
2501
+ _rcb +
2502
+ '<div style="flex:1;min-width:0;">' +
2503
+ '<div class="memory-item-title">' + _arcResIco + _esc(r.query || 'Research') + '</div>' +
2504
+ '<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">' + ['Research', (r.source_count ? r.source_count + ' sources' : ''), _relTime(r.completed_at ? new Date(r.completed_at * 1000).toISOString() : '')].filter(Boolean).join(' · ') + '</div>' +
2505
+ '</div>' +
2506
+ '<div class="memory-item-actions"><button class="memory-item-btn _arc-res-menu" title="Actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button></div>' +
2507
+ '</div>' +
2508
+ '<div class="doclib-chat-preview" style="display:none;"></div>';
2509
+ const _rcbEl = card.querySelector('.memory-select-cb');
2510
+ if (_rcbEl) { _rcbEl.addEventListener('click', e => e.stopPropagation()); _rcbEl.addEventListener('change', () => { if (_rcbEl.checked) _arcSelected.add('research:' + r.id); else _arcSelected.delete('research:' + r.id); _updateArcCount(); }); }
2511
+ card.addEventListener('click', (e) => {
2512
+ if (e.target.closest('._arc-res-menu') || e.target.closest('.memory-select-cb')) return;
2513
+ if (_arcSelectMode) { const c = card.querySelector('.memory-select-cb'); if (c) { c.checked = !c.checked; if (c.checked) _arcSelected.add('research:' + r.id); else _arcSelected.delete('research:' + r.id); _updateArcCount(); } return; }
2514
+ _toggleResearchPreview(card, r);
2515
+ });
2516
+ card.querySelector('._arc-res-menu').addEventListener('click', (e) => { e.stopPropagation(); _showLibDropdown(e.currentTarget, [
2517
+ { label: 'Open', action: () => { const a = document.createElement('a'); a.href = '/api/research/report/' + r.id; a.target = '_blank'; a.rel = 'noopener'; document.body.appendChild(a); a.click(); a.remove(); } },
2518
+ { label: 'Restore', action: async () => { await fetch('/api/research/' + r.id + '/archive?archived=false', { method: 'POST', credentials: 'same-origin' }); _renderLibArchive(); } },
2519
+ { label: 'Delete', danger: true, action: async () => { if (!await window.styledConfirm('Delete this research?', { confirmText: 'Delete', danger: true })) return; await fetch('/api/research/' + r.id, { method: 'DELETE', credentials: 'same-origin' }); _renderLibArchive(); } },
2520
+ ], { onSelect: () => {
2521
+ _arcSelectMode = true;
2522
+ _arcSelected.add('research:' + r.id);
2523
+ document.getElementById('doclib-arc-bulk')?.classList.remove('hidden');
2524
+ _renderArcGrid();
2525
+ } }); });
2526
+ _attachLongPressMenu(card, '._arc-res-menu');
2527
+ grid.appendChild(card);
2528
+ }
2529
+ _appendInlineLoadMore(grid, total, _arcVisibleLimit, () => {
2530
+ _arcVisibleLimit += _LIB_PAGE_SIZE;
2531
+ _renderArcGrid();
2532
+ });
2533
+ }
2534
+
2535
+ function _renderArcChips() {
2536
+ const el = document.getElementById('doclib-arc-chips');
2537
+ if (!el) return;
2538
+ // Type filters: All / Chats / Documents / Research (only the ones present).
2539
+ el.innerHTML = '';
2540
+ const mk = (label, val, count) => {
2541
+ const c = document.createElement('button');
2542
+ c.className = 'memory-cat-chip' + (_arcTypeFilter === val ? ' active' : '');
2543
+ c.textContent = label + ' (' + count + ')';
2544
+ c.addEventListener('click', () => { _arcTypeFilter = _arcTypeFilter === val ? '' : val; _renderArcGrid(); _renderArcChips(); });
2545
+ el.appendChild(c);
2546
+ };
2547
+ const total = _arcSessions.length + _arcDocs.length + _arcResearch.length;
2548
+ if (!total) return;
2549
+ mk('All', '', total);
2550
+ if (_arcSessions.length) mk('Chats', 'chats', _arcSessions.length);
2551
+ if (_arcDocs.length) mk('Documents', 'documents', _arcDocs.length);
2552
+ if (_arcResearch.length) mk('Research', 'research', _arcResearch.length);
2553
+ }
2554
+
2555
+ function _updateArcCount() { const el = document.getElementById('doclib-arc-selected-count'); if (el) el.textContent = _arcSelected.size + ' Selected'; }
2556
+
2557
+ // Archive event listeners
2558
+ document.getElementById('doclib-arc-sort').addEventListener('change', (e) => { _arcSort = e.target.value; _renderArcGrid(); });
2559
+ document.getElementById('doclib-arc-search').addEventListener('input', (e) => { _arcSearch = e.target.value.trim(); _renderArcGrid(); });
2560
+ document.getElementById('doclib-arc-select-btn').addEventListener('click', () => { _arcSelectMode = !_arcSelectMode; _arcSelected.clear(); document.getElementById('doclib-arc-bulk').classList.toggle('hidden', !_arcSelectMode); _renderArcGrid(); });
2561
+ document.getElementById('doclib-arc-bulk-cancel')?.addEventListener('click', () => {
2562
+ _arcSelectMode = false; _arcSelected.clear();
2563
+ document.getElementById('doclib-arc-bulk').classList.add('hidden');
2564
+ _renderArcGrid();
2565
+ });
2566
+ // Select-all toggles EVERY visible archived card (chats + docs + research),
2567
+ // keyed by the card's composite "type:id" data-arckey.
2568
+ function _arcToggleAll() {
2569
+ const cbs = document.querySelectorAll('#doclib-arc-grid .memory-select-cb');
2570
+ const newState = _arcSelected.size < cbs.length;
2571
+ const allCb = document.getElementById('doclib-arc-select-all');
2572
+ if (allCb) allCb.checked = newState;
2573
+ cbs.forEach(cb => {
2574
+ cb.checked = newState;
2575
+ const k = cb.dataset.arckey;
2576
+ if (k) { if (newState) _arcSelected.add(k); else _arcSelected.delete(k); }
2577
+ });
2578
+ _updateArcCount();
2579
+ }
2580
+ document.getElementById('doclib-arc-select-all').addEventListener('change', _arcToggleAll);
2581
+ document.getElementById('doclib-arc-bulk').addEventListener('click', (e) => {
2582
+ if (e.target.closest('button') || e.target.closest('input')) return;
2583
+ _arcToggleAll();
2584
+ });
2585
+ // Route a composite "type:id" key to the right restore / delete endpoint.
2586
+ function _arcRestoreOne(key) {
2587
+ const i = key.indexOf(':'), type = key.slice(0, i), id = key.slice(i + 1);
2588
+ if (type === 'documents') return fetch(API_BASE + '/api/document/' + id + '/archive?archived=false', { method: 'POST', credentials: 'same-origin' });
2589
+ if (type === 'research') return fetch('/api/research/' + id + '/archive?archived=false', { method: 'POST', credentials: 'same-origin' });
2590
+ return fetch(API_BASE + '/api/session/' + id + '/unarchive', { method: 'POST', credentials: 'same-origin' });
2591
+ }
2592
+ function _arcDeleteOne(key) {
2593
+ const i = key.indexOf(':'), type = key.slice(0, i), id = key.slice(i + 1);
2594
+ if (type === 'documents') return fetch(API_BASE + '/api/document/' + id, { method: 'DELETE', credentials: 'same-origin' });
2595
+ if (type === 'research') return fetch('/api/research/' + id, { method: 'DELETE', credentials: 'same-origin' });
2596
+ return fetch(API_BASE + '/api/session/' + id, { method: 'DELETE', credentials: 'same-origin' });
2597
+ }
2598
+ document.getElementById('doclib-arc-bulk-restore').addEventListener('click', async () => {
2599
+ if (!_arcSelected.size) return;
2600
+ await Promise.all([..._arcSelected].map(_arcRestoreOne));
2601
+ _arcSelected.clear(); _arcSelectMode = false;
2602
+ document.getElementById('doclib-arc-bulk').classList.add('hidden');
2603
+ _renderLibArchive();
2604
+ });
2605
+ document.getElementById('doclib-arc-bulk-delete').addEventListener('click', async () => {
2606
+ const count = _arcSelected.size;
2607
+ if (!count) return;
2608
+ if (!await window.styledConfirm(`Delete ${count} archived item${count > 1 ? 's' : ''} permanently?`, { confirmText: 'Delete', danger: true })) return;
2609
+ const grid = document.getElementById('doclib-arc-grid');
2610
+ if (grid) {
2611
+ grid.querySelectorAll('.memory-item[data-arckey]').forEach(card => {
2612
+ if (_arcSelected.has(card.dataset.arckey)) {
2613
+ card.style.transition = 'opacity 0.25s, transform 0.25s';
2614
+ card.style.opacity = '0';
2615
+ card.style.transform = 'scale(0.95)';
2616
+ }
2617
+ });
2618
+ }
2619
+ await new Promise(r => setTimeout(r, 250));
2620
+ await Promise.all([..._arcSelected].map(_arcDeleteOne));
2621
+ _arcSelected.clear();
2622
+ _arcSelectMode = false;
2623
+ document.getElementById('doclib-arc-bulk').classList.add('hidden');
2624
+ _renderLibArchive();
2625
+ });
2626
+
2627
+ // ── Research tab ──
2628
+ let _researchItems = [];
2629
+ let _researchSearch = '';
2630
+ let _researchSelectMode = false;
2631
+ let _researchArchivedView = false;
2632
+ const _researchSelected = new Set();
2633
+
2634
+ async function _renderLibResearch() {
2635
+ const grid = document.getElementById('doclib-research-grid');
2636
+ const stats = document.getElementById('doclib-research-stats');
2637
+ if (!grid) return;
2638
+ // Show our whirlpool spinner instead of the plain "Loading..." text.
2639
+ grid.innerHTML = '';
2640
+ try {
2641
+ const _spm = (await import('./spinner.js')).default;
2642
+ const _sp = _spm.createWhirlpool(22);
2643
+ _sp.element.style.cssText = 'margin:18px auto;display:block;';
2644
+ grid.appendChild(_sp.element);
2645
+ } catch { grid.innerHTML = '<div class="hwfit-loading">Loading…</div>'; }
2646
+ try {
2647
+ const res = await fetch('/api/research/library' + (_researchArchivedView ? '?archived=true' : ''), { credentials: 'same-origin' });
2648
+ if (!res.ok) throw new Error(res.statusText);
2649
+ const data = await res.json();
2650
+ _researchItems = data.research || data || [];
2651
+ } catch (e) {
2652
+ grid.innerHTML = `<div class="hwfit-loading">Failed to load: ${e.message}</div>`;
2653
+ return;
2654
+ }
2655
+ _renderResearchGrid();
2656
+ }
2657
+
2658
+ // Toggle inline preview for a research row. Mirrors _toggleChatPreview
2659
+ // but pulls research-specific metadata: query, sources list (truncated),
2660
+ // followed by an "Open" action that loads the full report.
2661
+ async function _toggleResearchPreview(card, item) {
2662
+ const preview = card.querySelector('.doclib-chat-preview');
2663
+ if (!preview) return;
2664
+ const isOpen = card.classList.contains('doclib-card-expanded');
2665
+ const grid = card.closest('.doclib-grid');
2666
+ if (grid) {
2667
+ grid.querySelectorAll('.doclib-card-expanded').forEach(c => {
2668
+ if (c !== card) {
2669
+ c.classList.remove('doclib-card-expanded');
2670
+ const p = c.querySelector('.doclib-chat-preview');
2671
+ if (p) { p.style.display = 'none'; p.innerHTML = ''; }
2672
+ }
2673
+ });
2674
+ }
2675
+ if (isOpen) {
2676
+ card.classList.remove('doclib-card-expanded');
2677
+ preview.style.display = 'none';
2678
+ preview.innerHTML = '';
2679
+ return;
2680
+ }
2681
+ card.classList.add('doclib-card-expanded');
2682
+ preview.style.display = 'block';
2683
+ preview.innerHTML = '<div style="opacity:0.4;font-size:11px;padding:8px 4px;">Loading…</div>';
2684
+ let detail = item;
2685
+ try {
2686
+ // Hit the per-research detail endpoint to pull sources + summary.
2687
+ // The library list endpoint only returns lightweight metadata.
2688
+ const res = await fetch(`${API_BASE}/api/research/detail/${item.id}`, { credentials: 'same-origin' });
2689
+ if (res.ok) detail = await res.json();
2690
+ } catch {}
2691
+ const sources = Array.isArray(detail.sources) ? detail.sources : [];
2692
+ const sourcesList = sources.slice(0, 12).map((src, i) => {
2693
+ const title = _esc(src.title || src.url || `Source ${i + 1}`);
2694
+ const url = src.url || '';
2695
+ return url
2696
+ ? `<li><a href="${_esc(url)}" target="_blank" rel="noopener">${title}</a></li>`
2697
+ : `<li>${title}</li>`;
2698
+ }).join('');
2699
+ const sourcesHtml = sources.length
2700
+ ? `<div class="doclib-research-sources"><div class="doclib-research-section-label">Sources (${sources.length})</div><ol>${sourcesList}${sources.length > 12 ? `<li style="opacity:0.5;">…and ${sources.length - 12} more</li>` : ''}</ol></div>`
2701
+ : '';
2702
+ // The stored research JSON keeps the report under `result` (clean) /
2703
+ // `raw_report` — there's no `summary` field, so the preview was empty.
2704
+ const summary = (detail.summary || detail.report_summary || detail.result || detail.raw_report || '').toString().trim();
2705
+ const summaryHtml = summary
2706
+ ? `<div class="doclib-research-summary"><div class="doclib-research-section-label">Report</div><div>${markdownModule.mdToHtml ? markdownModule.mdToHtml(summary) : _esc(summary)}</div></div>`
2707
+ : '';
2708
+ preview.innerHTML =
2709
+ '<div class="doclib-chat-preview-messages">' +
2710
+ (summaryHtml || sourcesHtml || '<div style="opacity:0.4;font-size:11px;padding:6px 4px;">No preview available</div>') +
2711
+ (summaryHtml && sourcesHtml ? sourcesHtml : '') +
2712
+ '</div>' +
2713
+ '<div class="doclib-chat-preview-actions">' +
2714
+ '<button class="doclib-chat-delete-btn">' +
2715
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>' +
2716
+ 'Delete' +
2717
+ '</button>' +
2718
+ '<button class="doclib-chat-archive-btn">' +
2719
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>' +
2720
+ ((_researchArchivedView || item.archived) ? 'Restore' : 'Archive') +
2721
+ '</button>' +
2722
+ // Discuss is hidden in the Archive so the footer matches chat
2723
+ // (Delete + Restore + Open).
2724
+ (item.archived ? '' :
2725
+ '<button class="doclib-chat-discuss-btn">' +
2726
+ '<svg width="11" height="11" 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>' +
2727
+ 'Discuss' +
2728
+ '</button>') +
2729
+ '<button class="doclib-chat-open-btn">' +
2730
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>' +
2731
+ 'Open' +
2732
+ '</button>' +
2733
+ '</div>';
2734
+ const discussBtn = preview.querySelector('.doclib-chat-discuss-btn');
2735
+ if (discussBtn) discussBtn.addEventListener('click', async (e) => {
2736
+ e.stopPropagation();
2737
+ const _orig = discussBtn.innerHTML;
2738
+ discussBtn.disabled = true;
2739
+ discussBtn.textContent = 'Creating…';
2740
+ try {
2741
+ const _sid = detail.session_id || detail.id || item.id;
2742
+ const res = await fetch(`${API_BASE}/api/research/spinoff/${_sid}`, { method: 'POST', credentials: 'same-origin' });
2743
+ if (!res.ok) { let d = ''; try { d = (await res.json()).detail || ''; } catch {} throw new Error(d || ('HTTP ' + res.status)); }
2744
+ const payload = await res.json();
2745
+ if (window.sessionModule && payload.session_id) {
2746
+ await window.sessionModule.loadSessions().catch(() => {});
2747
+ await window.sessionModule.selectSession(payload.session_id);
2748
+ }
2749
+ closeLibrary();
2750
+ } catch (err) {
2751
+ discussBtn.disabled = false;
2752
+ discussBtn.innerHTML = _orig;
2753
+ if (uiModule) uiModule.showError('Could not start discussion: ' + (err.message || err));
2754
+ }
2755
+ });
2756
+ const openBtn = preview.querySelector('.doclib-chat-open-btn');
2757
+ if (openBtn) openBtn.addEventListener('click', (e) => {
2758
+ e.stopPropagation();
2759
+ const a = document.createElement('a');
2760
+ a.href = '/api/research/report/' + item.id;
2761
+ a.target = '_blank';
2762
+ a.rel = 'noopener';
2763
+ document.body.appendChild(a);
2764
+ a.click();
2765
+ a.remove();
2766
+ });
2767
+ const delBtn = preview.querySelector('.doclib-chat-delete-btn');
2768
+ if (delBtn) delBtn.addEventListener('click', async (e) => {
2769
+ e.stopPropagation();
2770
+ const ok = uiModule && uiModule.styledConfirm
2771
+ ? await uiModule.styledConfirm('Delete this research report?', { confirmText: 'Delete', danger: true })
2772
+ : window.confirm('Delete this research report?');
2773
+ if (!ok) return;
2774
+ try {
2775
+ const res = await fetch(`${API_BASE}/api/research/${item.id}`, { method: 'DELETE', credentials: 'same-origin' });
2776
+ if (!res.ok) throw new Error(await res.text());
2777
+ if (item.archived) {
2778
+ _renderLibArchive();
2779
+ } else {
2780
+ _researchItems = _researchItems.filter(r => r.id !== item.id);
2781
+ _renderResearchGrid();
2782
+ }
2783
+ } catch (err) {
2784
+ if (uiModule && uiModule.showError) uiModule.showError('Failed to delete: ' + err.message);
2785
+ }
2786
+ });
2787
+ const arcBtn = preview.querySelector('.doclib-chat-archive-btn');
2788
+ if (arcBtn) arcBtn.addEventListener('click', async (e) => {
2789
+ e.stopPropagation();
2790
+ // From the main Archive tab the item is already archived → Restore and
2791
+ // refresh the archive. From the Research tab, toggle as before.
2792
+ const fromArchiveTab = !!item.archived;
2793
+ const toArchived = fromArchiveTab ? false : !_researchArchivedView;
2794
+ try {
2795
+ await fetch(`${API_BASE}/api/research/${item.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' });
2796
+ if (fromArchiveTab) {
2797
+ _renderLibArchive();
2798
+ } else {
2799
+ _researchItems = _researchItems.filter(r => r.id !== item.id);
2800
+ _renderResearchGrid();
2801
+ }
2802
+ if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored');
2803
+ } catch { if (uiModule) uiModule.showError('Failed to ' + (toArchived ? 'archive' : 'restore')); }
2804
+ });
2805
+ }
2806
+
2807
+ function _renderResearchGrid() {
2808
+ const grid = document.getElementById('doclib-research-grid');
2809
+ const stats = document.getElementById('doclib-research-stats');
2810
+ if (!grid) return;
2811
+ const _rsb = document.getElementById('doclib-research-select-btn');
2812
+ if (_rsb) { _rsb.classList.toggle('active', _researchSelectMode); _rsb.textContent = _researchSelectMode ? 'Cancel' : 'Select'; }
2813
+ let items = _researchItems;
2814
+ if (_researchSearch) {
2815
+ const s = _researchSearch.toLowerCase();
2816
+ items = items.filter(r => (r.query || '').toLowerCase().includes(s));
2817
+ }
2818
+ // Sort
2819
+ const _rSort = document.getElementById('doclib-research-sort')?.value || 'recent';
2820
+ if (_rSort === 'recent') items.sort((a, b) => (b.completed_at || 0) - (a.completed_at || 0));
2821
+ else if (_rSort === 'oldest') items.sort((a, b) => (a.completed_at || 0) - (b.completed_at || 0));
2822
+ else if (_rSort === 'most-sources') items.sort((a, b) => (b.source_count || 0) - (a.source_count || 0));
2823
+ else if (_rSort === 'alpha') items.sort((a, b) => (a.query || '').localeCompare(b.query || ''));
2824
+ if (stats) stats.textContent = items.length + ' research' + (items.length !== 1 ? 'es' : '');
2825
+ if (!items.length) {
2826
+ grid.innerHTML =
2827
+ '<div class="hwfit-loading" style="display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;">' +
2828
+ '<span>No research yet</span>' +
2829
+ '<span style="opacity:0.7;font-size:11px;">' +
2830
+ 'create one in the <a href="#" data-doclib-open-research style="color:var(--accent,var(--red));text-decoration:underline;">Deep Research</a> tab' +
2831
+ '</span>' +
2832
+ '</div>';
2833
+ grid.querySelector('[data-doclib-open-research]')?.addEventListener('click', (e) => {
2834
+ e.preventDefault();
2835
+ document.getElementById('rail-research')?.click();
2836
+ });
2837
+ _appendInlineLoadMore(grid, 0, _researchVisibleLimit, () => {});
2838
+ return;
2839
+ }
2840
+ const total = items.length;
2841
+ items = items.slice(0, _researchVisibleLimit);
2842
+ let html = '';
2843
+ for (const r of items) {
2844
+ const date = r.completed_at ? new Date(r.completed_at * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '';
2845
+ const time = r.completed_at ? new Date(r.completed_at * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : '';
2846
+ const sources = r.source_count || 0;
2847
+ const duration = r.duration || '';
2848
+ const rounds = r.rounds || '';
2849
+ const selected = _researchSelected.has(r.id);
2850
+ const metaBits = [];
2851
+ if (date) metaBits.push(`${date} ${time}`);
2852
+ if (sources) metaBits.push(`${sources} sources`);
2853
+ if (rounds) metaBits.push(`${rounds} rounds`);
2854
+ if (duration) metaBits.push(`${duration}`);
2855
+ const metaText = metaBits.join(' \u00B7 ');
2856
+ html += `<div class="memory-item doclib-chat-row doclib-research-card" data-research-id="${r.id}" style="cursor:pointer;">`;
2857
+ html += `<div class="doclib-chat-header" style="display:flex;align-items:center;width:100%;gap:6px;">`;
2858
+ if (_researchSelectMode) html += `<input type="checkbox" class="memory-select-cb _res-cb" data-rid="${r.id}"${selected ? ' checked' : ''}>`;
2859
+ html += `<div style="flex:1;min-width:0;">`;
2860
+ html += `<div class="memory-item-title"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.4;flex-shrink:0;"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>${_esc(r.query || 'Untitled Research')}</div>`;
2861
+ html += `<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">${metaText}</div>`;
2862
+ html += `</div>`;
2863
+ if (!_researchSelectMode) html += `<div class="memory-item-actions"><button class="memory-item-btn doclib-research-delete" data-rid="${r.id}" title="Delete"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button></div>`;
2864
+ html += `</div>`;
2865
+ html += `<div class="doclib-chat-preview" style="display:none;"></div>`;
2866
+ html += `</div>`;
2867
+ }
2868
+ grid.innerHTML = html;
2869
+ _maybeCascadeGrid(grid, 'research');
2870
+
2871
+ // Wire checkboxes
2872
+ grid.querySelectorAll('._res-cb').forEach(cb => {
2873
+ cb.addEventListener('click', e => e.stopPropagation());
2874
+ cb.addEventListener('change', () => {
2875
+ if (cb.checked) _researchSelected.add(cb.dataset.rid); else _researchSelected.delete(cb.dataset.rid);
2876
+ _updateResearchCount();
2877
+ });
2878
+ });
2879
+
2880
+ // Click card → toggle preview (chat-style expand). The menu button
2881
+ // and Open-report button inside the preview are exempt.
2882
+ grid.querySelectorAll('.doclib-research-card').forEach(card => {
2883
+ card.addEventListener('click', (e) => {
2884
+ if (card._suppressNextClick) { card._suppressNextClick = false; return; }
2885
+ if (e.target.closest('.doclib-research-delete') || e.target.closest('._res-cb') || e.target.closest('.doclib-chat-open-btn')) return;
2886
+ const rid = card.dataset.researchId;
2887
+ if (_researchSelectMode) {
2888
+ const cb = card.querySelector('._res-cb');
2889
+ if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); }
2890
+ return;
2891
+ }
2892
+ const item = _researchItems.find(r => r.id === rid);
2893
+ if (item) _toggleResearchPreview(card, item);
2894
+ });
2895
+ _attachLongPressMenu(card, '.doclib-research-delete');
2896
+ });
2897
+
2898
+ // The action button on each research row opens the actions menu
2899
+ // (Open report, Delete) — chat-style ••• menu.
2900
+ grid.querySelectorAll('.doclib-research-delete').forEach(btn => {
2901
+ btn.addEventListener('click', (e) => {
2902
+ e.stopPropagation();
2903
+ const rid = btn.dataset.rid;
2904
+ _showLibDropdown(btn, [
2905
+ { label: 'Open', action: () => {
2906
+ const a = document.createElement('a');
2907
+ a.href = '/api/research/report/' + rid;
2908
+ a.target = '_blank';
2909
+ a.rel = 'noopener';
2910
+ document.body.appendChild(a);
2911
+ a.click();
2912
+ a.remove();
2913
+ } },
2914
+ { label: _researchArchivedView ? 'Restore' : 'Archive', action: async () => {
2915
+ const toArchived = !_researchArchivedView;
2916
+ const card = btn.closest('.doclib-research-card');
2917
+ if (card) { card.style.transition = 'opacity 0.25s, transform 0.25s'; card.style.opacity = '0'; card.style.transform = 'scale(0.95)'; }
2918
+ try { await fetch('/api/research/' + rid + '/archive?archived=' + toArchived, { method: 'POST', credentials: 'same-origin' }); } catch {}
2919
+ await new Promise(r => setTimeout(r, 200));
2920
+ _researchItems = _researchItems.filter(r => r.id !== rid);
2921
+ _renderResearchGrid();
2922
+ if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored');
2923
+ } },
2924
+ { label: 'Delete', danger: true, action: async () => {
2925
+ if (!await window.styledConfirm('Delete this research?', { confirmText: 'Delete', danger: true })) return;
2926
+ const card = btn.closest('.doclib-research-card');
2927
+ if (card) {
2928
+ card.style.transition = 'opacity 0.25s, transform 0.25s';
2929
+ card.style.opacity = '0';
2930
+ card.style.transform = 'scale(0.95)';
2931
+ }
2932
+ await new Promise(r => setTimeout(r, 250));
2933
+ await fetch('/api/research/' + rid, { method: 'DELETE', credentials: 'same-origin' });
2934
+ _researchItems = _researchItems.filter(r => r.id !== rid);
2935
+ _renderResearchGrid();
2936
+ } },
2937
+ ], { onSelect: () => {
2938
+ _researchSelectMode = true;
2939
+ _researchSelected.add(rid);
2940
+ document.getElementById('doclib-research-bulk')?.classList.remove('hidden');
2941
+ _renderResearchGrid();
2942
+ } });
2943
+ });
2944
+ });
2945
+ _appendInlineLoadMore(grid, total, _researchVisibleLimit, () => {
2946
+ _researchVisibleLimit += _LIB_PAGE_SIZE;
2947
+ _renderResearchGrid();
2948
+ });
2949
+ }
2950
+
2951
+ // Research sort + search
2952
+ const researchSortEl = document.getElementById('doclib-research-sort');
2953
+ if (researchSortEl) researchSortEl.addEventListener('change', () => _renderResearchGrid());
2954
+ const researchSearchEl = document.getElementById('doclib-research-search');
2955
+ if (researchSearchEl) {
2956
+ researchSearchEl.addEventListener('input', () => {
2957
+ _researchSearch = researchSearchEl.value.trim();
2958
+ _renderResearchGrid();
2959
+ });
2960
+ }
2961
+
2962
+ function _updateResearchCount() {
2963
+ const el = document.getElementById('doclib-research-selected-count');
2964
+ if (el) el.textContent = _researchSelected.size + ' Selected';
2965
+ const arc = document.getElementById('doclib-research-bulk-archive');
2966
+ if (arc) arc.textContent = _researchArchivedView ? 'Restore' : 'Archive';
2967
+ }
2968
+
2969
+ // Research select mode
2970
+ document.getElementById('doclib-research-select-btn')?.addEventListener('click', () => {
2971
+ _researchSelectMode = !_researchSelectMode;
2972
+ _researchSelected.clear();
2973
+ document.getElementById('doclib-research-bulk').classList.toggle('hidden', !_researchSelectMode);
2974
+ _renderResearchGrid();
2975
+ });
2976
+
2977
+ // Research tidy — delete reports that came back empty (no sources, or
2978
+ // empty report body). Matches the Chats tidy whirlpool/borderless pattern
2979
+ // and skips confirmation per user request.
2980
+ document.getElementById('doclib-research-tidy-btn')?.addEventListener('click', async (e) => {
2981
+ const btn = e.currentTarget;
2982
+ const origHTML = btn.innerHTML;
2983
+ btn.disabled = true;
2984
+ btn.classList.add('spinning');
2985
+ btn.textContent = '';
2986
+ const sp = spinnerModule.create('', 'clean', 'whirlpool');
2987
+ const el = sp.createElement();
2988
+ el.style.position = 'relative';
2989
+ el.style.top = '1px';
2990
+ btn.appendChild(el);
2991
+ sp.start();
2992
+ try {
2993
+ const candidates = [];
2994
+ const needFetch = [];
2995
+ for (const r of _researchItems) {
2996
+ if ((r.source_count || 0) === 0) candidates.push(r);
2997
+ else needFetch.push(r);
2998
+ }
2999
+ const results = await Promise.all(needFetch.map(async r => {
3000
+ try {
3001
+ const res = await fetch('/api/research/detail/' + r.id, { credentials: 'same-origin' });
3002
+ if (!res.ok) return null;
3003
+ const d = await res.json();
3004
+ // Backend JSON uses `result` (rendered) or `raw_report` (raw md).
3005
+ // If neither exists or both are tiny, treat as empty.
3006
+ const body = (d.result || d.raw_report || '').trim();
3007
+ return body.length < 200 ? r : null;
3008
+ } catch { return null; }
3009
+ }));
3010
+ for (const r of results) if (r) candidates.push(r);
3011
+ if (candidates.length === 0) {
3012
+ if (uiModule) uiModule.showToast('Nothing to tidy');
3013
+ return;
3014
+ }
3015
+ await Promise.all(candidates.map(r => fetch('/api/research/' + r.id, { method: 'DELETE', credentials: 'same-origin' }).catch(() => {})));
3016
+ const ids = new Set(candidates.map(r => r.id));
3017
+ _researchItems = _researchItems.filter(r => !ids.has(r.id));
3018
+ _renderResearchGrid();
3019
+ if (uiModule) uiModule.showToast('Deleted ' + candidates.length);
3020
+ } finally {
3021
+ sp.stop();
3022
+ btn.disabled = false;
3023
+ btn.classList.remove('spinning');
3024
+ btn.innerHTML = origHTML;
3025
+ }
3026
+ });
3027
+ document.getElementById('doclib-research-archived-btn')?.addEventListener('click', (e) => {
3028
+ _researchArchivedView = !_researchArchivedView;
3029
+ e.currentTarget.classList.toggle('active', _researchArchivedView);
3030
+ e.currentTarget.title = _researchArchivedView ? 'Show active research' : 'Show archived research';
3031
+ if (_researchSelectMode) { _researchSelectMode = false; _researchSelected.clear(); document.getElementById('doclib-research-bulk').classList.add('hidden'); }
3032
+ _renderLibResearch();
3033
+ });
3034
+ document.getElementById('doclib-research-bulk-cancel')?.addEventListener('click', () => {
3035
+ _researchSelectMode = false;
3036
+ _researchSelected.clear();
3037
+ document.getElementById('doclib-research-bulk').classList.add('hidden');
3038
+ _renderResearchGrid();
3039
+ });
3040
+
3041
+ // Research select all
3042
+ document.getElementById('doclib-research-select-all')?.addEventListener('change', () => {
3043
+ const allCb = document.getElementById('doclib-research-select-all');
3044
+ const newState = allCb?.checked;
3045
+ _researchItems.forEach(r => { if (newState) _researchSelected.add(r.id); else _researchSelected.delete(r.id); });
3046
+ _updateResearchCount();
3047
+ _renderResearchGrid();
3048
+ });
3049
+
3050
+ // Research bulk delete
3051
+ document.getElementById('doclib-research-bulk-delete')?.addEventListener('click', async () => {
3052
+ const count = _researchSelected.size;
3053
+ if (!count) return;
3054
+ if (!await window.styledConfirm(`Delete ${count} research report${count > 1 ? 's' : ''} permanently?`, { confirmText: 'Delete', danger: true })) return;
3055
+ const grid = document.getElementById('doclib-research-grid');
3056
+ if (grid) {
3057
+ grid.querySelectorAll('.doclib-research-card').forEach(card => {
3058
+ if (_researchSelected.has(card.dataset.researchId)) {
3059
+ card.style.transition = 'opacity 0.25s, transform 0.25s';
3060
+ card.style.opacity = '0';
3061
+ card.style.transform = 'scale(0.95)';
3062
+ }
3063
+ });
3064
+ }
3065
+ await new Promise(r => setTimeout(r, 250));
3066
+ await Promise.all([..._researchSelected].map(rid => fetch('/api/research/' + rid, { method: 'DELETE', credentials: 'same-origin' })));
3067
+ _researchItems = _researchItems.filter(r => !_researchSelected.has(r.id));
3068
+ _researchSelected.clear();
3069
+ _researchSelectMode = false;
3070
+ document.getElementById('doclib-research-bulk').classList.add('hidden');
3071
+ _renderResearchGrid();
3072
+ });
3073
+
3074
+ // Research bulk archive / restore
3075
+ document.getElementById('doclib-research-bulk-archive')?.addEventListener('click', async () => {
3076
+ const count = _researchSelected.size;
3077
+ if (!count) return;
3078
+ const toArchived = !_researchArchivedView;
3079
+ const grid = document.getElementById('doclib-research-grid');
3080
+ if (grid) {
3081
+ grid.querySelectorAll('.doclib-research-card').forEach(card => {
3082
+ if (_researchSelected.has(card.dataset.researchId)) {
3083
+ card.style.transition = 'opacity 0.25s, transform 0.25s';
3084
+ card.style.opacity = '0';
3085
+ card.style.transform = 'scale(0.95)';
3086
+ }
3087
+ });
3088
+ }
3089
+ await new Promise(r => setTimeout(r, 250));
3090
+ await Promise.all([..._researchSelected].map(rid => fetch('/api/research/' + rid + '/archive?archived=' + toArchived, { method: 'POST', credentials: 'same-origin' })));
3091
+ _researchItems = _researchItems.filter(r => !_researchSelected.has(r.id));
3092
+ _researchSelected.clear();
3093
+ _researchSelectMode = false;
3094
+ document.getElementById('doclib-research-bulk').classList.add('hidden');
3095
+ _renderResearchGrid();
3096
+ if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored');
3097
+ });
3098
+
3099
+ // Shared dropdown for chats/archive menus — defined at module scope below
3100
+ // (was here originally; hoisted so libraryCreateCard's mobile kebab
3101
+ // handler — which lives outside openLibrary's closure — can call it).
3102
+
3103
+ function _relTime(iso) {
3104
+ if (!iso) return '';
3105
+ const diff = Date.now() - new Date(iso).getTime();
3106
+ const mins = Math.floor(diff / 60000);
3107
+ if (mins < 60) return mins + 'm ago';
3108
+ const hrs = Math.floor(mins / 60);
3109
+ if (hrs < 24) return hrs + 'h ago';
3110
+ const days = Math.floor(hrs / 24);
3111
+ if (days < 30) return days + 'd ago';
3112
+ return new Date(iso).toLocaleDateString();
3113
+ }
3114
+
3115
+ // Switch to initial tab if not documents
3116
+ if (_activeLibTab !== 'documents') _switchLibTab(_activeLibTab);
3117
+
3118
+ const searchInput = document.getElementById('doclib-search');
3119
+ searchInput.addEventListener('input', () => {
3120
+ clearTimeout(_librarySearchDebounce);
3121
+ _librarySearchDebounce = setTimeout(() => {
3122
+ _librarySearch = searchInput.value.trim();
3123
+ libraryFetch(false);
3124
+ }, 300);
3125
+ });
3126
+
3127
+ document.getElementById('doclib-sort').addEventListener('change', (e) => {
3128
+ _librarySort = e.target.value;
3129
+ libraryFetch(false);
3130
+ });
3131
+
3132
+ document.getElementById('doclib-load-more').addEventListener('click', () => {
3133
+ _libraryOffset = _libraryDocs.length;
3134
+ libraryFetch(true);
3135
+ });
3136
+
3137
+ // Show "Load more" only when scrolled near bottom
3138
+ const grid = document.getElementById('doclib-grid');
3139
+ if (grid) {
3140
+ grid.addEventListener('scroll', () => libraryRenderLoadMore());
3141
+ // Auto-fill on resize (fullscreen toggle, window resize, sidebar
3142
+ // toggle): re-run the load-more check so newly-revealed empty
3143
+ // space below the last card pulls in the next page automatically.
3144
+ if (typeof ResizeObserver !== 'undefined') {
3145
+ new ResizeObserver(() => libraryRenderLoadMore()).observe(grid);
3146
+ }
3147
+ }
3148
+
3149
+ // Wire file import button
3150
+ const importFileBtn = document.getElementById('doclib-import-file-btn');
3151
+ const fileInput = document.getElementById('doclib-file-input');
3152
+ if (importFileBtn && fileInput) {
3153
+ importFileBtn.addEventListener('click', () => fileInput.click());
3154
+ fileInput.addEventListener('change', async () => {
3155
+ if (fileInput.files.length === 0) return;
3156
+ const files = Array.from(fileInput.files);
3157
+ fileInput.value = '';
3158
+ // Swap the import icon for a whirlpool while files upload.
3159
+ const _orig = importFileBtn.innerHTML;
3160
+ importFileBtn.disabled = true;
3161
+ let _sp = null;
3162
+ try {
3163
+ _sp = spinnerModule.createWhirlpool(12);
3164
+ _sp.element.style.cssText = 'width:12px;height:12px;margin:0 4px 0 0;display:inline-block;vertical-align:middle;position:relative;top:-2px;';
3165
+ importFileBtn.innerHTML = '';
3166
+ importFileBtn.appendChild(_sp.element);
3167
+ importFileBtn.appendChild(document.createTextNode('Import'));
3168
+ } catch {}
3169
+ try {
3170
+ await libraryImportFiles(files);
3171
+ } finally {
3172
+ try { _sp && _sp.stop(); } catch {}
3173
+ importFileBtn.innerHTML = _orig;
3174
+ importFileBtn.disabled = false;
3175
+ }
3176
+ });
3177
+ }
3178
+
3179
+ // Create button — new blank document
3180
+ const createBtn = document.getElementById('doclib-create-btn');
3181
+ if (createBtn) {
3182
+ createBtn.addEventListener('click', async () => {
3183
+ // Create a new session, then create a blank document in it
3184
+ try {
3185
+ const sRes = await fetch('/api/session', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Untitled Document' }) });
3186
+ const sData = await sRes.json();
3187
+ const sessionId = sData.session_id;
3188
+ await _createDocument(sessionId);
3189
+ // Close library and open the new session
3190
+ closeLibrary();
3191
+ if (window.sessionsModule) window.sessionsModule.loadSession(sessionId);
3192
+ setTimeout(() => _openPanel(), 300);
3193
+ } catch (e) {
3194
+ console.error('Failed to create document:', e);
3195
+ if (uiModule) uiModule.showError('Failed to create document');
3196
+ }
3197
+ });
3198
+ }
3199
+
3200
+ // Archived toggle — flip the Documents list between active and archived.
3201
+ const archivedBtn = document.getElementById('doclib-archived-btn');
3202
+ if (archivedBtn) archivedBtn.addEventListener('click', () => {
3203
+ _libraryArchivedView = !_libraryArchivedView;
3204
+ archivedBtn.classList.toggle('active', _libraryArchivedView);
3205
+ archivedBtn.title = _libraryArchivedView ? 'Show active documents' : 'Show archived documents';
3206
+ if (_librarySelectMode) libraryExitSelectMode();
3207
+ libraryFetch(false);
3208
+ });
3209
+
3210
+ // Tidy button — remove empty/broken documents
3211
+ const tidyBtn = document.getElementById('doclib-tidy-btn');
3212
+ if (tidyBtn) tidyBtn.addEventListener('click', async () => {
3213
+ tidyBtn.disabled = true;
3214
+ tidyBtn.classList.add('spinning');
3215
+ const origHTML = tidyBtn.innerHTML;
3216
+ tidyBtn.textContent = '';
3217
+ const spinner = spinnerModule.create('', 'clean', 'whirlpool');
3218
+ const _spEl = spinner.createElement();
3219
+ // Optical alignment: whirlpool reads 1px high inside the button.
3220
+ _spEl.style.position = 'relative';
3221
+ _spEl.style.top = '1px';
3222
+ tidyBtn.appendChild(_spEl);
3223
+ spinner.start();
3224
+
3225
+ let totalDeleted = 0;
3226
+ let totalFixed = 0;
3227
+ let aiMessage = '';
3228
+ try {
3229
+ // Phase 1: regex tidy (empty/broken docs)
3230
+ const [res1] = await Promise.all([
3231
+ fetch(`${API_BASE}/api/documents/tidy`, { method: 'POST' }),
3232
+ new Promise(r => setTimeout(r, 600)),
3233
+ ]);
3234
+ if (res1.ok) {
3235
+ const d1 = await res1.json();
3236
+ totalDeleted += d1.deleted || 0;
3237
+ totalFixed += d1.fixed_titles || 0;
3238
+ }
3239
+
3240
+ // Phase 2: AI tidy (junk/test detection)
3241
+ try {
3242
+ const res2 = await fetch(`${API_BASE}/api/documents/ai-tidy`, { method: 'POST' });
3243
+ if (res2.ok) {
3244
+ const d2 = await res2.json();
3245
+ totalDeleted += d2.deleted || 0;
3246
+ if (d2.message) aiMessage = d2.message;
3247
+ }
3248
+ } catch (_) { /* AI tidy is optional */ }
3249
+
3250
+ spinner.destroy();
3251
+
3252
+ if (totalDeleted === 0 && totalFixed === 0) {
3253
+ tidyBtn.innerHTML = '<span style="opacity:0.7">Already tidy</span>';
3254
+ } else {
3255
+ const msg = aiMessage || `Removed ${totalDeleted} document${totalDeleted !== 1 ? 's' : ''}`;
3256
+ if (uiModule) uiModule.showToast(msg);
3257
+ libraryFetch(false);
3258
+ }
3259
+ setTimeout(() => { tidyBtn.innerHTML = origHTML; tidyBtn.disabled = false; tidyBtn.classList.remove('spinning'); }, 1500);
3260
+ } catch (e) {
3261
+ spinner.destroy();
3262
+ console.error('Document tidy failed:', e);
3263
+ if (uiModule) uiModule.showToast('Tidy failed');
3264
+ tidyBtn.disabled = false;
3265
+ tidyBtn.classList.remove('spinning');
3266
+ tidyBtn.innerHTML = origHTML;
3267
+ }
3268
+ });
3269
+
3270
+ // Select mode
3271
+ const selectBtn = document.getElementById('doclib-select-btn');
3272
+ if (selectBtn) selectBtn.addEventListener('click', () => {
3273
+ if (_librarySelectMode) libraryExitSelectMode();
3274
+ else libraryEnterSelectMode();
3275
+ });
3276
+
3277
+ const selectAll = document.getElementById('doclib-select-all');
3278
+ if (selectAll) selectAll.addEventListener('change', libraryToggleSelectAll);
3279
+
3280
+ // Click anywhere in the bulk bar "All" label or count area to toggle select-all
3281
+ const bulkCheckLabel = modal.querySelector('.memory-bulk-check-all');
3282
+ if (bulkCheckLabel) {
3283
+ bulkCheckLabel.addEventListener('click', (e) => {
3284
+ if (e.target === selectAll) return; // let native checkbox handle it
3285
+ e.preventDefault();
3286
+ selectAll.checked = !selectAll.checked;
3287
+ libraryToggleSelectAll();
3288
+ });
3289
+ }
3290
+ const selectedCountEl = document.getElementById('doclib-selected-count');
3291
+ if (selectedCountEl) {
3292
+ selectedCountEl.style.cursor = 'pointer';
3293
+ selectedCountEl.addEventListener('click', () => {
3294
+ selectAll.checked = !selectAll.checked;
3295
+ libraryToggleSelectAll();
3296
+ });
3297
+ }
3298
+
3299
+ const bulkActionsBtn = document.getElementById('doclib-bulk-actions');
3300
+ if (bulkActionsBtn) bulkActionsBtn.addEventListener('click', (e) => {
3301
+ e.stopPropagation();
3302
+ if (_librarySelectedIds.size === 0) {
3303
+ if (uiModule) uiModule.showToast('Select documents first');
3304
+ return;
3305
+ }
3306
+ _showLibDropdown(e.currentTarget, [
3307
+ { label: _libraryArchivedView ? 'Restore' : 'Archive', icon: _libraryArchivedView ? 'restore' : 'archive', action: libraryBulkArchive },
3308
+ { label: 'Clone', icon: 'clone', action: libraryBulkClone },
3309
+ { label: 'Export', icon: 'open', action: libraryBulkExport },
3310
+ { label: 'Delete', icon: 'delete', danger: true, action: libraryBulkDelete },
3311
+ ], { onCancel: libraryExitSelectMode });
3312
+ });
3313
+
3314
+ const bulkCancelBtn = document.getElementById('doclib-bulk-cancel');
3315
+ if (bulkCancelBtn) bulkCancelBtn.addEventListener('click', libraryExitSelectMode);
3316
+
3317
+ // Close on click outside modal content
3318
+ modal.addEventListener('click', (e) => {
3319
+ if (uiModule.isTouchInsideModal()) return;
3320
+ if (e.target === modal) closeLibrary();
3321
+ });
3322
+
3323
+ // Escape key
3324
+ _libraryEscHandler = (e) => {
3325
+ if (e.key === 'Escape') {
3326
+ // Collapse expanded card first, then close modal on second Escape
3327
+ const expanded = document.querySelector('#doclib-grid .doclib-card-expanded');
3328
+ if (expanded) {
3329
+ _collapseExpandedCard(expanded);
3330
+ } else {
3331
+ closeLibrary();
3332
+ }
3333
+ }
3334
+ };
3335
+ document.addEventListener('keydown', _libraryEscHandler);
3336
+
3337
+ // Toggle active on tool button
3338
+ const btn = document.getElementById('tool-doclib-btn');
3339
+ if (btn) btn.classList.add('active');
3340
+
3341
+ libraryFetch(false);
3342
+ if (window.innerWidth >= 768) searchInput.focus();
3343
+ }
3344
+
3345
+ export function closeLibrary() {
3346
+ if (!_libraryOpen) return;
3347
+ _libraryOpen = false;
3348
+ _librarySelectMode = false;
3349
+ _librarySelectedIds.clear();
3350
+ _libraryImportMode = false;
3351
+ clearTimeout(_librarySearchDebounce);
3352
+
3353
+ const modal = document.getElementById('doclib-modal');
3354
+ if (modal) {
3355
+ const content = modal.querySelector('.modal-content, .doclib-modal-content');
3356
+ if (content) {
3357
+ content.classList.add('modal-closing');
3358
+ content.addEventListener('animationend', () => modal.remove(), { once: true });
3359
+ setTimeout(() => { if (modal.parentElement) modal.remove(); }, 250);
3360
+ } else {
3361
+ modal.remove();
3362
+ }
3363
+ }
3364
+
3365
+ if (_libraryEscHandler) {
3366
+ document.removeEventListener('keydown', _libraryEscHandler);
3367
+ _libraryEscHandler = null;
3368
+ }
3369
+
3370
+ const btn = document.getElementById('tool-doclib-btn');
3371
+ if (btn) btn.classList.remove('active');
3372
+ }
3373
+
3374
+ export function isLibraryOpen() {
3375
+ return _libraryOpen;
3376
+ }