dsh-mobile-plugin 0.5.28 → 0.5.29

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 (350) hide show
  1. package/extensions/catalog.json +10 -1
  2. package/extensions/magpie/agentcompact.js +104 -0
  3. package/extensions/magpie/app.css +4902 -0
  4. package/extensions/magpie/app.js +24474 -0
  5. package/extensions/magpie/autobackup.js +185 -0
  6. package/extensions/magpie/autoresetown.js +151 -0
  7. package/extensions/magpie/balfound.js +38 -0
  8. package/extensions/magpie/boot.js +23 -0
  9. package/extensions/magpie/cfgblocked.js +39 -0
  10. package/extensions/magpie/checkinnow.js +111 -0
  11. package/extensions/magpie/claudemode.js +92 -0
  12. package/extensions/magpie/compat.js +41 -0
  13. package/extensions/magpie/context.css +433 -0
  14. package/extensions/magpie/context.js +1576 -0
  15. package/extensions/magpie/ctxtext.js +223 -0
  16. package/extensions/magpie/fonts.js +42 -0
  17. package/extensions/magpie/i18n.js +18942 -0
  18. package/extensions/magpie/icons/ai21.svg +1 -0
  19. package/extensions/magpie/icons/ai302-color.svg +1 -0
  20. package/extensions/magpie/icons/ai360-color.svg +1 -0
  21. package/extensions/magpie/icons/aihubmix-color.svg +1 -0
  22. package/extensions/magpie/icons/aionui.svg +1 -0
  23. package/extensions/magpie/icons/air.svg +4 -0
  24. package/extensions/magpie/icons/alibaba-color.svg +1 -0
  25. package/extensions/magpie/icons/alibabacloud-color.svg +1 -0
  26. package/extensions/magpie/icons/alma.png +0 -0
  27. package/extensions/magpie/icons/anthropic.svg +1 -0
  28. package/extensions/magpie/icons/antigravity-color.svg +1 -0
  29. package/extensions/magpie/icons/anyscale-color.svg +1 -0
  30. package/extensions/magpie/icons/app-alma.png +0 -0
  31. package/extensions/magpie/icons/app-cc-switch.png +0 -0
  32. package/extensions/magpie/icons/apple-touch-icon.png +0 -0
  33. package/extensions/magpie/icons/aside.svg +28 -0
  34. package/extensions/magpie/icons/astrbot.svg +1 -0
  35. package/extensions/magpie/icons/atomcode.png +0 -0
  36. package/extensions/magpie/icons/aws.svg +1 -0
  37. package/extensions/magpie/icons/azure-color.svg +1 -0
  38. package/extensions/magpie/icons/azureai-color.svg +1 -0
  39. package/extensions/magpie/icons/baichuan-color.svg +1 -0
  40. package/extensions/magpie/icons/baidu-color.svg +1 -0
  41. package/extensions/magpie/icons/baiducloud-color.svg +1 -0
  42. package/extensions/magpie/icons/bailian-color.svg +1 -0
  43. package/extensions/magpie/icons/baseten.svg +1 -0
  44. package/extensions/magpie/icons/bedrock-color.svg +1 -0
  45. package/extensions/magpie/icons/burncloud-color.svg +1 -0
  46. package/extensions/magpie/icons/bytedance-color.svg +1 -0
  47. package/extensions/magpie/icons/cerebras.svg +1 -0
  48. package/extensions/magpie/icons/chatglm-color.svg +1 -0
  49. package/extensions/magpie/icons/cherryin-color.svg +1 -0
  50. package/extensions/magpie/icons/chutes.svg +1 -0
  51. package/extensions/magpie/icons/cindy.png +0 -0
  52. package/extensions/magpie/icons/claude-color.svg +1 -0
  53. package/extensions/magpie/icons/claudecode-color.svg +1 -0
  54. package/extensions/magpie/icons/cline.svg +1 -0
  55. package/extensions/magpie/icons/cloudflare-color.svg +1 -0
  56. package/extensions/magpie/icons/codebuddy-color.svg +1 -0
  57. package/extensions/magpie/icons/codex-color.svg +1 -0
  58. package/extensions/magpie/icons/cohere-color.svg +1 -0
  59. package/extensions/magpie/icons/cometapi-color.svg +1 -0
  60. package/extensions/magpie/icons/commandcode.svg +1 -0
  61. package/extensions/magpie/icons/copilot-color.svg +1 -0
  62. package/extensions/magpie/icons/coze.svg +1 -0
  63. package/extensions/magpie/icons/crush.png +0 -0
  64. package/extensions/magpie/icons/curl.svg +1 -0
  65. package/extensions/magpie/icons/cursor.svg +1 -0
  66. package/extensions/magpie/icons/deepinfra-color.svg +1 -0
  67. package/extensions/magpie/icons/deepmind-color.svg +1 -0
  68. package/extensions/magpie/icons/deepseek-color.svg +1 -0
  69. package/extensions/magpie/icons/deveco-color.svg +12 -0
  70. package/extensions/magpie/icons/devin.svg +1 -0
  71. package/extensions/magpie/icons/doubao-color.svg +1 -0
  72. package/extensions/magpie/icons/empryo-color.svg +1 -0
  73. package/extensions/magpie/icons/factory.svg +1 -0
  74. package/extensions/magpie/icons/favicon.svg +1 -0
  75. package/extensions/magpie/icons/featherless-color.svg +1 -0
  76. package/extensions/magpie/icons/fireworks-color.svg +1 -0
  77. package/extensions/magpie/icons/friendli.svg +1 -0
  78. package/extensions/magpie/icons/fx.svg +1 -0
  79. package/extensions/magpie/icons/gemini-color.svg +1 -0
  80. package/extensions/magpie/icons/geminicli-color.svg +1 -0
  81. package/extensions/magpie/icons/gemma-color.svg +1 -0
  82. package/extensions/magpie/icons/giteeai.svg +1 -0
  83. package/extensions/magpie/icons/github.svg +1 -0
  84. package/extensions/magpie/icons/githubcopilot.svg +1 -0
  85. package/extensions/magpie/icons/glama.svg +1 -0
  86. package/extensions/magpie/icons/gmicloud.svg +1 -0
  87. package/extensions/magpie/icons/google-color.svg +1 -0
  88. package/extensions/magpie/icons/googlecloud-color.svg +1 -0
  89. package/extensions/magpie/icons/goose.svg +1 -0
  90. package/extensions/magpie/icons/grok.svg +1 -0
  91. package/extensions/magpie/icons/groq.svg +1 -0
  92. package/extensions/magpie/icons/hanako.png +0 -0
  93. package/extensions/magpie/icons/hermes.svg +1 -0
  94. package/extensions/magpie/icons/huawei-color.svg +1 -0
  95. package/extensions/magpie/icons/huaweicloud-color.svg +1 -0
  96. package/extensions/magpie/icons/huggingface-color.svg +1 -0
  97. package/extensions/magpie/icons/hunyuan-color.svg +1 -0
  98. package/extensions/magpie/icons/hyperbolic-color.svg +1 -0
  99. package/extensions/magpie/icons/icon-512.png +0 -0
  100. package/extensions/magpie/icons/iflytekcloud-color.svg +1 -0
  101. package/extensions/magpie/icons/inference.svg +1 -0
  102. package/extensions/magpie/icons/infinigence-color.svg +1 -0
  103. package/extensions/magpie/icons/internlm-color.svg +1 -0
  104. package/extensions/magpie/icons/jcode.svg +1 -0
  105. package/extensions/magpie/icons/jina.svg +1 -0
  106. package/extensions/magpie/icons/kilo.svg +1 -0
  107. package/extensions/magpie/icons/kilocode.svg +1 -0
  108. package/extensions/magpie/icons/kimi-code-desktop.png +0 -0
  109. package/extensions/magpie/icons/kimi.svg +1 -0
  110. package/extensions/magpie/icons/kiro-color.svg +1 -0
  111. package/extensions/magpie/icons/kling-color.svg +1 -0
  112. package/extensions/magpie/icons/lambda.svg +1 -0
  113. package/extensions/magpie/icons/leptonai-color.svg +1 -0
  114. package/extensions/magpie/icons/liquid.svg +1 -0
  115. package/extensions/magpie/icons/liveagent-color.svg +1 -0
  116. package/extensions/magpie/icons/llmapi.svg +1 -0
  117. package/extensions/magpie/icons/lmstudio.svg +1 -0
  118. package/extensions/magpie/icons/lody.png +0 -0
  119. package/extensions/magpie/icons/longcat.svg +1 -0
  120. package/extensions/magpie/icons/magpie.svg +1 -0
  121. package/extensions/magpie/icons/meta-color.svg +1 -0
  122. package/extensions/magpie/icons/metaai-color.svg +1 -0
  123. package/extensions/magpie/icons/microsoft-color.svg +1 -0
  124. package/extensions/magpie/icons/mimocode.svg +1 -0
  125. package/extensions/magpie/icons/minimax-color.svg +1 -0
  126. package/extensions/magpie/icons/mistermorph-color.svg +1 -0
  127. package/extensions/magpie/icons/mistral-color.svg +1 -0
  128. package/extensions/magpie/icons/mlx-serve.png +0 -0
  129. package/extensions/magpie/icons/modelscope-color.svg +1 -0
  130. package/extensions/magpie/icons/monica-color.svg +1 -0
  131. package/extensions/magpie/icons/moonshot.svg +1 -0
  132. package/extensions/magpie/icons/morph-color.svg +1 -0
  133. package/extensions/magpie/icons/mothx.png +0 -0
  134. package/extensions/magpie/icons/nebius.svg +1 -0
  135. package/extensions/magpie/icons/newapi-color.svg +1 -0
  136. package/extensions/magpie/icons/nousresearch.svg +1 -0
  137. package/extensions/magpie/icons/nova-color.svg +1 -0
  138. package/extensions/magpie/icons/novita-color.svg +1 -0
  139. package/extensions/magpie/icons/nvidia-color.svg +1 -0
  140. package/extensions/magpie/icons/ollama.svg +1 -0
  141. package/extensions/magpie/icons/omlx.svg +1 -0
  142. package/extensions/magpie/icons/omo.svg +1 -0
  143. package/extensions/magpie/icons/omp.svg +1 -0
  144. package/extensions/magpie/icons/openai.svg +1 -0
  145. package/extensions/magpie/icons/openchamber.svg +11 -0
  146. package/extensions/magpie/icons/openclaw.svg +1 -0
  147. package/extensions/magpie/icons/opencode.svg +1 -0
  148. package/extensions/magpie/icons/openmausbot.svg +1 -0
  149. package/extensions/magpie/icons/openrouter.svg +1 -0
  150. package/extensions/magpie/icons/openwebui.svg +1 -0
  151. package/extensions/magpie/icons/parasail.svg +1 -0
  152. package/extensions/magpie/icons/penguin-color.svg +1 -0
  153. package/extensions/magpie/icons/perplexity-color.svg +1 -0
  154. package/extensions/magpie/icons/phala-color.svg +1 -0
  155. package/extensions/magpie/icons/pi.svg +1 -0
  156. package/extensions/magpie/icons/pipellm-color.svg +5 -0
  157. package/extensions/magpie/icons/poe.svg +1 -0
  158. package/extensions/magpie/icons/ppio-color.svg +1 -0
  159. package/extensions/magpie/icons/prime-agent.svg +1 -0
  160. package/extensions/magpie/icons/qiniu-color.svg +1 -0
  161. package/extensions/magpie/icons/qoder.svg +1 -0
  162. package/extensions/magpie/icons/qwen-color.svg +1 -0
  163. package/extensions/magpie/icons/qwenpaw.svg +1 -0
  164. package/extensions/magpie/icons/raycast.svg +1 -0
  165. package/extensions/magpie/icons/reasonix-color.svg +48 -0
  166. package/extensions/magpie/icons/reka.svg +1 -0
  167. package/extensions/magpie/icons/replicate.svg +1 -0
  168. package/extensions/magpie/icons/roocode.svg +1 -0
  169. package/extensions/magpie/icons/rtk.png +0 -0
  170. package/extensions/magpie/icons/sambanova-color.svg +1 -0
  171. package/extensions/magpie/icons/sensenova-color.svg +1 -0
  172. package/extensions/magpie/icons/siliconcloud-color.svg +1 -0
  173. package/extensions/magpie/icons/snow.png +0 -0
  174. package/extensions/magpie/icons/snowflake-color.svg +1 -0
  175. package/extensions/magpie/icons/sophnet-color.svg +1 -0
  176. package/extensions/magpie/icons/spark-color.svg +1 -0
  177. package/extensions/magpie/icons/stepfun-color.svg +1 -0
  178. package/extensions/magpie/icons/straico-color.svg +1 -0
  179. package/extensions/magpie/icons/submodel-color.svg +1 -0
  180. package/extensions/magpie/icons/t3code.svg +4 -0
  181. package/extensions/magpie/icons/targon-color.svg +1 -0
  182. package/extensions/magpie/icons/tencent-color.svg +1 -0
  183. package/extensions/magpie/icons/tencentcloud-color.svg +1 -0
  184. package/extensions/magpie/icons/tiangong-color.svg +1 -0
  185. package/extensions/magpie/icons/together-color.svg +1 -0
  186. package/extensions/magpie/icons/trae.svg +1 -0
  187. package/extensions/magpie/icons/typesafe.png +0 -0
  188. package/extensions/magpie/icons/upstage-color.svg +1 -0
  189. package/extensions/magpie/icons/venice-color.svg +1 -0
  190. package/extensions/magpie/icons/vercel.svg +1 -0
  191. package/extensions/magpie/icons/vertexai-color.svg +1 -0
  192. package/extensions/magpie/icons/vllm-color.svg +1 -0
  193. package/extensions/magpie/icons/volcengine-color.svg +1 -0
  194. package/extensions/magpie/icons/voyage-color.svg +1 -0
  195. package/extensions/magpie/icons/vscode.svg +1 -0
  196. package/extensions/magpie/icons/wenxin-color.svg +1 -0
  197. package/extensions/magpie/icons/windsurf.svg +1 -0
  198. package/extensions/magpie/icons/workbuddy-color.svg +1 -0
  199. package/extensions/magpie/icons/workersai-color.svg +1 -0
  200. package/extensions/magpie/icons/xai.svg +1 -0
  201. package/extensions/magpie/icons/xiaomimimo.svg +1 -0
  202. package/extensions/magpie/icons/xinference-color.svg +1 -0
  203. package/extensions/magpie/icons/yi.svg +1 -0
  204. package/extensions/magpie/icons/yuanbao-color.svg +1 -0
  205. package/extensions/magpie/icons/yylx.svg +5 -0
  206. package/extensions/magpie/icons/zai.svg +1 -0
  207. package/extensions/magpie/icons/zcode.png +0 -0
  208. package/extensions/magpie/icons/zed.svg +1 -0
  209. package/extensions/magpie/icons/zenmux.svg +1 -0
  210. package/extensions/magpie/icons/zeroone.svg +1 -0
  211. package/extensions/magpie/icons/zhipu-color.svg +1 -0
  212. package/extensions/magpie/icons/zoo-code.png +0 -0
  213. package/extensions/magpie/index.html +572 -0
  214. package/extensions/magpie/kernel.js +274 -0
  215. package/extensions/magpie/keyspend.js +63 -0
  216. package/extensions/magpie/layout.js +26 -0
  217. package/extensions/magpie/manifest.json +37 -0
  218. package/extensions/magpie/navlamp.js +47 -0
  219. package/extensions/magpie/omarchy.css +72 -0
  220. package/extensions/magpie/omarchy.js +46 -0
  221. package/extensions/magpie/passerrors.js +21 -0
  222. package/extensions/magpie/pluginpages.js +405 -0
  223. package/extensions/magpie/plugins/gateway/client.js +1194 -0
  224. package/extensions/magpie/plugins/gateway/keys.js +972 -0
  225. package/extensions/magpie/plugins/gateway/settings.js +459 -0
  226. package/extensions/magpie/plugins/library/client.js +6788 -0
  227. package/extensions/magpie/plugins/library/i18n.js +3228 -0
  228. package/extensions/magpie/plugins/library/library.css +848 -0
  229. package/extensions/magpie/plugins/otel/i18n.js +48 -0
  230. package/extensions/magpie/plugins/otel/page.js +189 -0
  231. package/extensions/magpie/plugins/redact/client.js +46 -0
  232. package/extensions/magpie/plugins/redact/demo.js +461 -0
  233. package/extensions/magpie/plugins/redact/i18n.js +432 -0
  234. package/extensions/magpie/plugins/redact/page.js +985 -0
  235. package/extensions/magpie/plugins/redact/redact.css +385 -0
  236. package/extensions/magpie/plugins/redact/settings.js +248 -0
  237. package/extensions/magpie/plugins/redact/xq_dark.jpg +0 -0
  238. package/extensions/magpie/plugins/redact/xq_dark.webm +0 -0
  239. package/extensions/magpie/plugins/redact/xq_light.jpg +0 -0
  240. package/extensions/magpie/plugins/redact/xq_light.webm +0 -0
  241. package/extensions/magpie/plugins/routing/client.js +8593 -0
  242. package/extensions/magpie/plugins/routing/i18n.js +2696 -0
  243. package/extensions/magpie/plugins/routing/routing.css +1111 -0
  244. package/extensions/magpie/plugins/search/settings.js +460 -0
  245. package/extensions/magpie/plugins/sessions/client.js +1622 -0
  246. package/extensions/magpie/plugins/sessions/i18n.js +360 -0
  247. package/extensions/magpie/plugins/sessions/sessions.css +181 -0
  248. package/extensions/magpie/plugins/sessions/settings.js +100 -0
  249. package/extensions/magpie/plugins/usage/client.js +150 -0
  250. package/extensions/magpie/plugins/usage/i18n.js +984 -0
  251. package/extensions/magpie/plugins/usage/page.js +4307 -0
  252. package/extensions/magpie/plugins.css +319 -0
  253. package/extensions/magpie/plugins.js +1926 -0
  254. package/extensions/magpie/profilerename.js +27 -0
  255. package/extensions/magpie/purposes.js +2 -0
  256. package/extensions/magpie/quitask.js +79 -0
  257. package/extensions/magpie/quotaall.js +142 -0
  258. package/extensions/magpie/ratewindows.js +123 -0
  259. package/extensions/magpie/registry.js +87 -0
  260. package/extensions/magpie/resetask.js +137 -0
  261. package/extensions/magpie/ruleedit.js +53 -0
  262. package/extensions/magpie/scrollends.js +91 -0
  263. package/extensions/magpie/settingshints.js +75 -0
  264. package/extensions/magpie/signsheets.js +86 -0
  265. package/extensions/magpie/start.js +26 -0
  266. package/extensions/magpie/syncfiles.js +216 -0
  267. package/extensions/magpie/theme.js +29 -0
  268. package/extensions/magpie/traywindows.js +41 -0
  269. package/extensions/magpie/webfonts.js +130 -0
  270. package/extensions/magpie/whatsnew.js +252 -0
  271. package/extensions/magpie.zip +0 -0
  272. package/knowledge/apps/baijiahao.md +30 -0
  273. package/knowledge/apps/bilibili.md +1 -0
  274. package/knowledge/apps/dedao.md +33 -0
  275. package/knowledge/apps/feishu.md +35 -0
  276. package/knowledge/apps/gmail.md +33 -0
  277. package/knowledge/apps/jike.md +31 -0
  278. package/knowledge/apps/kuaishou.md +34 -0
  279. package/knowledge/apps/linkedin.md +33 -0
  280. package/knowledge/apps/qqmail.md +30 -0
  281. package/knowledge/apps/telegram.md +27 -0
  282. package/knowledge/apps/toutiao.md +32 -0
  283. package/knowledge/apps/uber-eats.md +26 -0
  284. package/knowledge/apps/uber.md +27 -0
  285. package/knowledge/apps/whatsapp.md +27 -0
  286. package/knowledge/apps/x.md +32 -0
  287. package/knowledge/apps/xiaohongshu.md +1 -0
  288. package/knowledge/apps/zhihu.md +1 -0
  289. package/knowledge/sites/bilibili.com/SITE.md +50 -0
  290. package/knowledge/sites/bilibili.com/references/view-fields.md +17 -0
  291. package/knowledge/sites/github.com/SITE.md +51 -0
  292. package/knowledge/sites/github.com/references/repo-fields.md +13 -0
  293. package/knowledge/sites/x.com/SITE.md +47 -0
  294. package/knowledge/sites/xiaohongshu.com/SITE.md +48 -0
  295. package/knowledge/sites/xiaohongshu.com/references/note-fields.md +18 -0
  296. package/knowledge/sites/youtube.com/SITE.md +49 -0
  297. package/knowledge/sites/youtube.com/references/innertube-fields.md +14 -0
  298. package/knowledge/sites/zhihu.com/SITE.md +47 -0
  299. package/knowledge/sites/zhihu.com/references/api-fields.md +21 -0
  300. package/lib/mobile/extension-catalog.js +41 -1
  301. package/lib/mobile/extension-data.js +224 -0
  302. package/lib/mobile/extension-dev.js +3 -1
  303. package/lib/mobile/file-ops.js +252 -0
  304. package/lib/mobile/history-derive.js +80 -4
  305. package/lib/mobile/host-bridge/endpoints.js +48 -6
  306. package/lib/mobile/host-bridge/event-synthesis.js +33 -6
  307. package/lib/mobile/jobs-control.js +118 -0
  308. package/lib/mobile/memory.js +635 -0
  309. package/lib/mobile/model-badges.js +77 -0
  310. package/lib/mobile/pet-tools.js +50 -4
  311. package/lib/mobile/pets.js +214 -18
  312. package/lib/mobile/phone/access.js +85 -0
  313. package/lib/mobile/phone/evidence.js +228 -0
  314. package/lib/mobile/phone/gate.js +290 -0
  315. package/lib/mobile/phone/grounding.js +314 -0
  316. package/lib/mobile/phone/guard.js +465 -0
  317. package/lib/mobile/phone/irreversible.js +88 -8
  318. package/lib/mobile/phone/knowledge-tools.js +665 -0
  319. package/lib/mobile/phone/learn.js +329 -0
  320. package/lib/mobile/phone/redact.js +237 -0
  321. package/lib/mobile/phone/risk.js +95 -0
  322. package/lib/mobile/phone/routes.js +28 -1
  323. package/lib/mobile/phone/service.js +23 -0
  324. package/lib/mobile/phone/site-commands.js +271 -0
  325. package/lib/mobile/phone/step-summary.js +201 -0
  326. package/lib/mobile/phone/stop-summary.js +235 -0
  327. package/lib/mobile/phone/tool-groups.js +125 -0
  328. package/lib/mobile/phone/tools.js +589 -0
  329. package/lib/mobile/phone/untrusted.js +65 -0
  330. package/lib/mobile/phone-knowledge.js +20 -4
  331. package/lib/mobile/schedule-edit.js +500 -0
  332. package/lib/mobile/schedule.js +5 -0
  333. package/lib/mobile/send-continuation.js +52 -0
  334. package/lib/mobile/session-models.js +27 -31
  335. package/lib/mobile/session-send.js +12 -12
  336. package/lib/mobile/skill-import.js +338 -0
  337. package/lib/mobile/skill-store.js +524 -0
  338. package/lib/routes/mobile-api.js +42 -0
  339. package/package.json +7 -6
  340. package/skills/skill-authoring/SKILL.md +46 -0
  341. package/skills/skill-authoring/references/examples.md +82 -0
  342. package/skills/skill-authoring/references/frontmatter-and-fields.md +46 -0
  343. package/skills/skill-authoring/scripts/check_skill.py +167 -0
  344. package/skills/skill-authoring/scripts/manifest.json +3 -0
  345. package/knowledge/sites/bilibili.com.md +0 -27
  346. package/knowledge/sites/github.com.md +0 -27
  347. package/knowledge/sites/x.com.md +0 -28
  348. package/knowledge/sites/xiaohongshu.com.md +0 -27
  349. package/knowledge/sites/youtube.com.md +0 -28
  350. package/knowledge/sites/zhihu.com.md +0 -27
@@ -0,0 +1,1622 @@
1
+ // Sessions: every session of an agent on this computer, by the folder it
2
+ // ran in, to pick up again or to delete. A delete asks first, in magpie's
3
+ // own dialog, and moves the session's files into magpie's trash; the Trash
4
+ // lists them, each with a Restore and a Delete forever, and can be emptied;
5
+ // erasing for good asks in the same dialog, and is only ever done when the
6
+ // reader asks for it. A session written to in the last minute
7
+ // may still be running, and is left as it is. Each row shows what the
8
+ // session spent, as the Usage page's list does, and that list's rows open
9
+ // here (#752). Everything comes from /api/sessions/manage.
10
+ //
11
+ // A page the Sessions plugin adds to the window (kernel.js), its words in
12
+ // i18n.js beside this file. Classic script, loaded before app.js: what
13
+ // runs as it loads touches neither the page nor app.js. mount, at
14
+ // magpie.start, is handed the page; open loads it, relang draws it again
15
+ // in a new language, and reveal is the Usage page's way in.
16
+ magpie.plugin("sessions", (ctx) => {
17
+ let page = null;
18
+
19
+ let data = null; // /api/sessions/manage
20
+ let failed = "";
21
+ let agent = "";
22
+ try { agent = localStorage.getItem("magpie.sessionsAgent") || ""; } catch {}
23
+ let query = "";
24
+ // The filter also finds a session by what was said in it: the server
25
+ // reads the agent's conversations a moment after the last keystroke, and
26
+ // a new keystroke cancels the read it was on (YOU on Discord: a session
27
+ // the gateway recorded is named ses_…, and its words are how it is
28
+ // remembered). found is that search, for one agent and one query.
29
+ let found = null; // { agent, needle, hits: Map(id -> hit), busy, cut, unread, searched, total, error }
30
+ let searchCtl = null;
31
+ let searchT = 0;
32
+ const SEARCH_WAIT = 300;
33
+ const needleOf = (q) => q.trim().split(/\s+/).filter(Boolean).join(" ").toLowerCase();
34
+ let trashOn = false;
35
+ // Picking sessions to delete is a mode of its own, as in Finder and Mail:
36
+ // off, no row carries a checkbox and there is no bar over the list
37
+ // (yetone: a box on every row, and a lone Delete under the Trash).
38
+ let selecting = false;
39
+ let othersOpen = false; // the folders an app or the system made, unfolded
40
+ const picked = new Set(); // ids picked to delete, of the agent shown
41
+ const opened = new Set(); // folders unfolded, by cwd
42
+ const folderBoxes = new Map(); // each folder's box as drawn, by cwd
43
+ let openedFor = ""; // the agent the folders were first unfolded for
44
+ let detail = ""; // the session opened to its details
45
+ const talkOpen = new Set(); // sessions whose conversation is shown, by agent/id
46
+ const talks = new Map(); // what was said in them, as read, by agent/id
47
+ // A session's subagents sit under it, folded (#2261): the conversations
48
+ // its agent started inside it (Claude Code, Qoder, OpenCode, Kimi Code:
49
+ // s.subagents, each opened to what was said in it), and the Codex
50
+ // threads that name it as their parent_session, which are sessions of
51
+ // their own and drawn as such, only one step in. subsOpen is the
52
+ // sessions unfolded, by agent/id; kidsOf is the sessions shown under
53
+ // each one, by its id, and rootOf the top one each nested session is
54
+ // under, as last drawn.
55
+ const subsOpen = new Set();
56
+ let kidsOf = new Map();
57
+ let rootOf = new Map();
58
+ const MAX_DEPTH = 8;
59
+ let loading = 0;
60
+ let fitObserver = null;
61
+ let focus = null; // { agent, id, until }: a session to bring into sight once drawn
62
+ let recordingBusy = false;
63
+ // the agents picked in the consent (RustCats on X), by id, and whether
64
+ // the choice is being saved
65
+ const consentPicked = new Set();
66
+ let consentBusy = false;
67
+ // An open folder draws its latest SM_PAGE sessions and a "Show N more"
68
+ // that adds the next page where it is (ChildhoodAndy on X: with thousands
69
+ // of sessions, switching to the page or to another agent, a keystroke in
70
+ // the filter and opening a session each drew every row again, 17,000
71
+ // elements for 1,000 sessions, and scrolling stuttered). drawnIn is how
72
+ // many of each folder's sessions are drawn, by cwd; it starts again at a
73
+ // page when the agent or the filter changes.
74
+ const SM_PAGE = 100;
75
+ const drawnIn = new Map();
76
+ // The folders are drawn a page at a time too, under FOLDERS in drawnIn:
77
+ // an app that runs Codex once per picture in a folder of its own left
78
+ // 11,358 folders of one session each, and drawing them all made 114,000
79
+ // elements, held every click on the page for over a second, and slowed
80
+ // the Library page after it (yetone).
81
+ const FOLDERS = "\x00folders";
82
+ const OTHERS = "\x00others";
83
+
84
+ // A session seen only through magpie's gateway has no folder magpie knows
85
+ // (its agent's files are on another computer, in a container or under
86
+ // another config folder): such sessions are grouped by that, not under
87
+ // "No folder" with the agent's own (lc on Discord).
88
+ const GW = "\x00gateway";
89
+ const groupOf = (s) => s.cwd || (s.gateway ? GW : "");
90
+ const groupName = (k) => k === GW ? t("Seen through the gateway") : k ? baseName(k) : t("No folder");
91
+
92
+ // A folder an app or the system made, not a project of the reader's: the
93
+ // disk's root, a temp folder, or one under a hidden folder of a home
94
+ // (~/.some-agent/…). They go under a fold of their own after the
95
+ // projects (yetone: "/", "T" and folders named by UUIDs led the list).
96
+ const scratch = (k) => {
97
+ if (!k || k === GW) return false;
98
+ const p = k.replace(/\\/g, "/").replace(/\/+$/, "");
99
+ return p === "" || /^[a-z]:$/i.test(p)
100
+ || /^\/(private\/)?(tmp|var\/tmp|var\/folders)(\/|$)/.test(p)
101
+ || /\/AppData\/Local\/Temp(\/|$)/i.test(p)
102
+ || /^([a-z]:)?\/(Users|home)\/[^/]+\/\.[^/]/i.test(p) || /^\/root\/\./.test(p);
103
+ };
104
+ // where a folder is, as its row says it: the folder it is in, with the
105
+ // home folder as ~; the whole path is the row's title
106
+ const whereOf = (p) => {
107
+ const home = data?.home || "";
108
+ const up = p.replace(/[\\/]+$/, "");
109
+ const cut = Math.max(up.lastIndexOf("/"), up.lastIndexOf("\\"));
110
+ if (home && up === home) return "~";
111
+ const parent = cut < 0 ? "" : cut === 0 ? "/" : up.slice(0, cut);
112
+ if (!home || !parent) return parent;
113
+ return parent === home ? "~" : parent.startsWith(home + "/") || parent.startsWith(home + "\\") ? "~" + parent.slice(home.length) : parent;
114
+ };
115
+
116
+ const TRASH = "M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5M7 7v4M9 7v4";
117
+ const TERM = "M3 4.5 6 7.5 3 10.5M7.5 11.5h5.5";
118
+ const UNDO = "M5.5 3.5 2.5 6.5l3 3M2.5 6.5H10a3.5 3.5 0 0 1 0 7H7";
119
+ const MOVE = "M2.5 8h10M9.5 5l3 3-3 3";
120
+
121
+ // fillIn asks again, while rows are still pending (a file of theirs not
122
+ // read whole yet: their first lines are what shows), for what the read
123
+ // behind the answer has found; only the list is drawn again, so the
124
+ // filter keeps its focus and an open menu stays.
125
+ let fillT = 0;
126
+ function fillSoon() {
127
+ clearTimeout(fillT);
128
+ if (!data?.sessions?.some((s) => s.pending)) return;
129
+ const n = loading, was = data.agent;
130
+ fillT = setTimeout(async () => {
131
+ if (n !== loading || view !== "sessions") return;
132
+ let d;
133
+ try { d = await api("sessions/manage?agent=" + encodeURIComponent(was)); } catch { return fillSoon(); }
134
+ if (n !== loading || !data || data.agent !== was || d?.agent !== was) return;
135
+ data = { ...data, ...d, agents: d?.agents || [], sessions: (d?.sessions || []).map((s) => ({ ...s, models: s.models || [] })), trash: d?.trash || [], agent: was };
136
+ if (view === "sessions" && !trashOn) redrawList(); // the pages shown stay
137
+ fillSoon();
138
+ }, 1500);
139
+ }
140
+
141
+ async function load(name) {
142
+ const n = ++loading;
143
+ clearTimeout(fillT);
144
+ if (!data) draw();
145
+ try {
146
+ const d = await api("sessions/manage?agent=" + encodeURIComponent(name ?? agent));
147
+ if (n !== loading) return;
148
+ // models may be null for a session that spent nothing
149
+ data = { ...d, agents: d?.agents || [], sessions: (d?.sessions || []).map((s) => ({ ...s, models: s.models || [] })), trash: d?.trash || [], agent: d?.agent || "" };
150
+ failed = "";
151
+ talks.clear(); // read again when shown: a session may have gone on
152
+ if (agent !== d.agent) { agent = d.agent; picked.clear(); }
153
+ if (query) searchSoon(); // the same words, in this agent's sessions
154
+ } catch (e) {
155
+ if (n !== loading) return;
156
+ failed = e.message;
157
+ }
158
+ if (view === "sessions") draw();
159
+ if (n === loading) fillSoon();
160
+ }
161
+
162
+ // reveal: the Sessions page, from the Usage page's list (#752):
163
+ // on the session's agent, its folder unfolded and the session opened to
164
+ // its details, brought into sight once it is drawn; with no id, the page
165
+ // on that agent (or as it was). The reader's click is what moves the view.
166
+ function reveal(s, e) {
167
+ if (s.agent && s.agent !== agent) {
168
+ agent = s.agent;
169
+ try { localStorage.setItem("magpie.sessionsAgent", agent); } catch {}
170
+ picked.clear();
171
+ if (data) data = { ...data, agent, sessions: null };
172
+ }
173
+ trashOn = false;
174
+ if (s.id) {
175
+ query = "";
176
+ searchSoon();
177
+ detail = s.id;
178
+ openedFor = agent;
179
+ opened.clear();
180
+ opened.add(groupOf(s));
181
+ focus = scrollOnPurpose(e) ? { agent, id: s.id, until: performance.now() + 5000 } : null;
182
+ }
183
+ show("sessions");
184
+ }
185
+
186
+ // bring the session asked for into sight, once its row is drawn
187
+ function toFocus() {
188
+ if (!focus || focus.agent !== data?.agent || !data.sessions) return;
189
+ if (performance.now() > focus.until) { focus = null; return; }
190
+ const r = page.querySelector(`.row.sm-sess[data-id="${CSS.escape(focus.id)}"]`);
191
+ focus = null;
192
+ if (!r) return;
193
+ readerScrolls(1000);
194
+ r.closest(".sm-item").scrollIntoView({ block: "nearest", behavior: "instant" });
195
+ readerLeaves(page);
196
+ }
197
+
198
+ const current = () => data?.agents.find((a) => a.agent === data.agent);
199
+ // the search of what was said, when it is of the agent and words shown
200
+ const search = () => found && found.agent === data?.agent && found.needle === needleOf(query) ? found : null;
201
+ const hitOf = (s) => search()?.hits.get(s.id);
202
+ const shown = () => {
203
+ const q = query.trim().toLowerCase();
204
+ const list = data?.sessions || [];
205
+ return q ? list.filter((s) => hitOf(s) || [s.title, s.cwd, s.id, ...(s.models || []).map((m) => m.model)].some((x) => (x || "").toLowerCase().includes(q))) : list;
206
+ };
207
+
208
+ // searchSoon searches what was said for the filter's words, once typing
209
+ // pauses; fewer than two letters search nothing
210
+ function searchSoon() {
211
+ clearTimeout(searchT);
212
+ const needle = needleOf(query);
213
+ if (!data?.agent || [...needle].length < 2) {
214
+ searchCtl?.abort();
215
+ searchCtl = null;
216
+ found = null;
217
+ return;
218
+ }
219
+ if (found && found.agent === data.agent && found.needle === needle && !found.busy && !found.error) return;
220
+ searchCtl?.abort();
221
+ searchCtl = null;
222
+ const mine = found = { agent: data.agent, needle, hits: new Map(), busy: true };
223
+ searchT = setTimeout(async () => {
224
+ const ctl = searchCtl = new AbortController();
225
+ try {
226
+ const r = await api(`sessions/search?agent=${encodeURIComponent(mine.agent)}&q=${encodeURIComponent(needle)}`, undefined, undefined, ctl.signal);
227
+ if (found !== mine) return;
228
+ found = { ...mine, busy: false, hits: new Map((r?.hits || []).map((h) => [h.id, h])), cut: !!r?.cut, unread: !!r?.unread, searched: r?.searched || 0, total: r?.total || 0 };
229
+ } catch (e) {
230
+ if (ctl.signal.aborted || found !== mine) return;
231
+ found = { ...mine, busy: false, error: e.message };
232
+ }
233
+ if (searchCtl === ctl) searchCtl = null;
234
+ if (view === "sessions" && !trashOn) { drawnIn.clear(); redrawList(); }
235
+ }, SEARCH_WAIT);
236
+ }
237
+
238
+ // what the search is doing, above the list, when it says more than the
239
+ // rows do
240
+ function searchNote() {
241
+ const f = search();
242
+ if (!f) return null;
243
+ let text = "";
244
+ if (f.busy) text = t("Searching what was said…");
245
+ else if (f.error) text = t("Couldn't search what was said: {error}", { error: f.error });
246
+ else if (f.unread) text = t("{agent}'s own files of its sessions can't be read here, so only what magpie recorded through the gateway is searched.", { agent: current()?.name || f.agent });
247
+ else if (f.cut) text = t("Searched what was said in the latest {n} of {total} sessions.", { n: f.searched, total: f.total });
248
+ if (!text) return null;
249
+ const p = el("p", "usage-note sm-note sm-search-note", text);
250
+ p.setAttribute("role", "status");
251
+ return p;
252
+ }
253
+
254
+ // the words round where a session said what was searched for
255
+ function hitLine(h) {
256
+ const d = el("div", "sub sm-hit");
257
+ d.append(h.before || "", el("mark", "", h.match), h.after || "");
258
+ d.title = (h.before || "") + h.match + (h.after || "");
259
+ return d;
260
+ }
261
+
262
+ function draw() {
263
+ const keepFocus = page.querySelector(".sm-agent-pick:focus, .sm-agents .opt:focus");
264
+ fitObserver?.disconnect();
265
+ if (page.querySelector(".sm-agent-pick.open")) closeProtoMenu();
266
+ page.classList.toggle("loading", !data?.sessions && !failed);
267
+ page.replaceChildren(head(), body());
268
+ const wrap = page.querySelector(".sm-switch");
269
+ const tabs = wrap.querySelector(".sm-agents");
270
+ const pick = wrap.querySelector(".sm-agent-pick");
271
+ const fit = () => {
272
+ const room = parseFloat(getComputedStyle(wrap).width);
273
+ if (!room) return;
274
+ const focused = document.activeElement;
275
+ // Measure the actual labels, including language, font and text zoom,
276
+ // rather than guessing a breakpoint or a number of agents.
277
+ tabs.hidden = false;
278
+ const compact = tabs.scrollWidth > Math.floor(room);
279
+ tabs.hidden = compact || !data?.agents.length;
280
+ pick.hidden = !compact || !current();
281
+ if (!compact && pick.classList.contains("open")) closeProtoMenu();
282
+ if (compact && tabs.contains(focused)) pick.focus({ preventScroll: true });
283
+ else if (!compact && focused === pick) tabs.querySelector(".on")?.focus({ preventScroll: true });
284
+ };
285
+ fitObserver = new ResizeObserver(fit);
286
+ fitObserver.observe(wrap);
287
+ fit();
288
+ if (keepFocus) (pick.hidden ? tabs.querySelector(".on") : pick)?.focus({ preventScroll: true });
289
+ }
290
+
291
+ function chooseAgent(name) {
292
+ if (name === data.agent && !trashOn) return;
293
+ trashOn = false;
294
+ selecting = false;
295
+ agent = name;
296
+ try { localStorage.setItem("magpie.sessionsAgent", name); } catch {}
297
+ picked.clear();
298
+ detail = "";
299
+ data = { ...data, agent: name, sessions: null };
300
+ draw();
301
+ load(name);
302
+ }
303
+
304
+ // The gateway conversations setting (#1355): a card above the list, shown
305
+ // only where it applies — when this agent has sessions seen through the
306
+ // gateway, in gateway mode, or once recording is on or text is kept. A
307
+ // switch that says what it keeps and where, and, while there is kept
308
+ // text, a delete that says what it deletes and asks first in magpie's own
309
+ // dialog (lee on Discord: a bare checkbox and a lone trash icon under the
310
+ // tabs read as stray controls).
311
+ const recordingApplies = () => typeof data?.recording === "boolean"
312
+ && (data.recording || data.recorded || (typeof gatewayMode !== "undefined" && gatewayMode) || !!data.sessions?.some((s) => s.gateway));
313
+
314
+ function recordingCard() {
315
+ const on = data.recording;
316
+ const card = el("div", "list row sm-record" + (on ? " on" : ""));
317
+ const sw = el("button", "lib-switch sm-record-switch" + (on ? " on" : ""));
318
+ sw.type = "button";
319
+ sw.setAttribute("role", "switch");
320
+ sw.setAttribute("aria-checked", String(on));
321
+ sw.disabled = recordingBusy;
322
+ sw.append(el("i"));
323
+ const words = el("div", "sm-record-words");
324
+ const name = el("span", "sm-record-name", t("Record gateway conversations"));
325
+ name.id = "smRecordName";
326
+ sw.setAttribute("aria-labelledby", name.id);
327
+ words.append(name, el("span", "sm-record-sub", t(on
328
+ ? "On: what agents send through the gateway, and the replies, are kept on this computer only, for up to 7 days (256 MiB), to read under each gateway session"
329
+ : "Off: gateway sessions show only what they used. Turned on, their text is kept on this computer only, for up to 7 days (256 MiB)")));
330
+ sw.onclick = async (e) => {
331
+ e.stopPropagation();
332
+ if (sw.disabled) return;
333
+ if (!on && !await confirmAction(t("Record gateway conversations"), t("Prompts, replies and tool results may contain private code and files. Store them on this server for up to 7 days (256 MiB total)? Recognized secrets are masked, but this is not a privacy guarantee."), t("Enable"))) return;
334
+ recordingBusy = true;
335
+ sw.disabled = true;
336
+ try {
337
+ const result = await api("sessions/recording", { on: !on });
338
+ data.recording = result.recording;
339
+ } catch (err) { status(err.message, "err"); }
340
+ finally { recordingBusy = false; draw(); }
341
+ };
342
+ card.append(sw, words);
343
+ if (data.recorded) {
344
+ const clear = el("button", "text sm-record-clear");
345
+ clear.type = "button";
346
+ clear.disabled = recordingBusy;
347
+ clear.append(svg(TRASH, 13, 1.4), el("span", "", t("Delete saved conversations…")));
348
+ clear.onclick = async (e) => {
349
+ e.stopPropagation();
350
+ if (!await confirmAction(t("Stop recording and clear saved conversations"), t("Delete all locally recorded gateway conversation content? Usage totals and native session files are kept."), t("Delete"))) return;
351
+ recordingBusy = true;
352
+ clear.disabled = true;
353
+ try { await api("sessions/recording", { clear: true }); talks.clear(); await load(); }
354
+ catch (err) { status(err.message, "err"); }
355
+ finally { recordingBusy = false; draw(); }
356
+ };
357
+ card.append(clear);
358
+ }
359
+ return card;
360
+ }
361
+
362
+ // The consent (RustCats on X: a new install showed every agent's history
363
+ // before anyone agreed to it). Until the reader says which agents'
364
+ // sessions magpie may read (data.reads.asked), it reads none of them and
365
+ // this card comes first: the agents found here, each with its own
366
+ // switch, off; Allow selected reads those picked, and Allow all every
367
+ // agent, those found later too. Settings › Privacy changes it later.
368
+ // In gateway mode no agent's files here are read (reads.gateway), so
369
+ // there is nothing to ask.
370
+ const consentApplies = () => data?.reads && data.reads.asked === false && !data.reads.gateway;
371
+
372
+ function consentCard() {
373
+ const found = data.reads.found || [];
374
+ const card = el("div", "sm-consent");
375
+ const title = el("b", "sm-consent-title", t("Show your agents' sessions?"));
376
+ title.id = "smConsentTitle";
377
+ card.setAttribute("role", "group");
378
+ card.setAttribute("aria-labelledby", title.id);
379
+ card.append(title, el("p", "sm-consent-sub", t("magpie reads no agent's session history until you allow it. Pick the agents whose sessions this page may show; what magpie's gateway served is counted either way. You can change this in Settings › Privacy.")));
380
+ if (found.length) {
381
+ const list = el("div", "list sm-consent-list");
382
+ for (const x of found) {
383
+ const on = consentPicked.has(x.agent);
384
+ const row = el("div", "row sm-consent-row");
385
+ const name = el("span", "sm-consent-name", x.name);
386
+ name.id = "smConsent-" + x.agent;
387
+ const sw = el("button", "lib-switch" + (on ? " on" : ""));
388
+ sw.type = "button";
389
+ sw.setAttribute("role", "switch");
390
+ sw.setAttribute("aria-checked", String(on));
391
+ sw.setAttribute("aria-labelledby", name.id);
392
+ sw.disabled = consentBusy;
393
+ sw.append(el("i"));
394
+ sw.onclick = (e) => {
395
+ e.stopPropagation();
396
+ if (consentPicked.has(x.agent)) consentPicked.delete(x.agent); else consentPicked.add(x.agent);
397
+ const now = consentPicked.has(x.agent);
398
+ sw.classList.toggle("on", now);
399
+ sw.setAttribute("aria-checked", String(now));
400
+ const some = card.querySelector(".sm-consent-some");
401
+ if (some) some.disabled = consentBusy || !consentPicked.size;
402
+ };
403
+ row.onclick = () => sw.click();
404
+ row.append(icon(x.icon), name, sw);
405
+ list.append(row);
406
+ }
407
+ card.append(list);
408
+ } else {
409
+ card.append(el("p", "sm-consent-none", t("No agent's sessions were found on this computer yet.")));
410
+ }
411
+ const acts = el("div", "sm-consent-acts");
412
+ const save = async (all) => {
413
+ consentBusy = true;
414
+ draw();
415
+ const agents = {};
416
+ for (const x of found) agents[x.agent] = all || consentPicked.has(x.agent);
417
+ try {
418
+ await api("sessions/reads", { all, agents });
419
+ consentPicked.clear();
420
+ await load();
421
+ } catch (err) { status(err.message, "err"); }
422
+ finally { consentBusy = false; draw(); }
423
+ };
424
+ if (found.length) {
425
+ const some = el("button", "text sm-consent-some", t("Allow selected"));
426
+ some.type = "button";
427
+ some.disabled = consentBusy || !consentPicked.size;
428
+ some.onclick = () => save(false);
429
+ acts.append(some);
430
+ }
431
+ const all = el("button", "text primary sm-consent-all", t("Allow all"));
432
+ all.type = "button";
433
+ all.disabled = consentBusy;
434
+ all.onclick = () => save(true);
435
+ acts.append(all);
436
+ card.append(acts);
437
+ return card;
438
+ }
439
+
440
+ function head() {
441
+ const h = el("div", "usage-head sm-head");
442
+ const wrap = el("div", "sm-switch");
443
+ const tabs = el("div", "segs regions sm-agents");
444
+ for (const x of data?.agents || []) {
445
+ const on = x.agent === data.agent && !trashOn;
446
+ const b = el("button", "opt" + (on ? " on" : ""));
447
+ b.type = "button";
448
+ b.setAttribute("aria-pressed", String(on));
449
+ b.append(icon(x.icon), el("span", "", x.name), el("em", "", String(x.count)));
450
+ b.onclick = () => chooseAgent(x.agent);
451
+ tabs.append(b);
452
+ }
453
+ const a = current();
454
+ const pick = el("button", "sess-pick sm-agent-pick");
455
+ pick.type = "button";
456
+ pick.hidden = !a;
457
+ pick.disabled = (data?.agents.length || 0) < 2;
458
+ pick.setAttribute("aria-haspopup", "menu");
459
+ pick.setAttribute("aria-expanded", "false");
460
+ if (a) {
461
+ pick.title = a.name;
462
+ pick.append(icon(a.icon), el("span", "", a.name), el("em", "", String(a.count)), svg(CHEV, 11, 1.6));
463
+ pick.onclick = (e) => {
464
+ e.stopPropagation();
465
+ if (pick.classList.contains("open")) return closeProtoMenu();
466
+ const opts = data.agents.map((x) => ({ v: x.agent, name: x.name, icon: x.icon, note: String(x.count), literalName: true }));
467
+ openProtoMenu(pick, opts, data.agent, (name) => {
468
+ pick.focus({ preventScroll: true });
469
+ chooseAgent(name);
470
+ }, "Agent", "sm-agent-menu");
471
+ iconItems(opts);
472
+ };
473
+ }
474
+ wrap.append(tabs, pick);
475
+ h.append(wrap);
476
+ const q = el("input", "sess-filter sm-filter");
477
+ q.type = "search";
478
+ q.spellcheck = false;
479
+ q.autocomplete = "off";
480
+ q.placeholder = t("Search sessions");
481
+ q.value = query;
482
+ q.hidden = trashOn;
483
+ q.oninput = () => { query = q.value; drawnIn.clear(); searchSoon(); redrawList(); };
484
+ q.onkeydown = (e) => { if (e.key === "Escape" && q.value) { e.stopPropagation(); q.value = ""; query = ""; drawnIn.clear(); searchSoon(); redrawList(); } };
485
+ const sel = el("button", "text sm-select" + (selecting ? " on" : ""), t(selecting ? "Done" : "Select"));
486
+ sel.type = "button";
487
+ sel.setAttribute("aria-pressed", String(selecting));
488
+ sel.hidden = trashOn || !a?.deletable || !data?.sessions?.some((s) => s.deletable);
489
+ sel.onclick = () => {
490
+ selecting = !selecting;
491
+ if (!selecting) picked.clear();
492
+ draw();
493
+ };
494
+ const tr = el("button", "text sm-trash-btn" + (trashOn ? " on" : ""));
495
+ tr.type = "button";
496
+ tr.setAttribute("aria-pressed", String(trashOn));
497
+ tr.append(svg(TRASH, 13, 1.4), el("span", "", t("Trash")));
498
+ if (data?.trash.length) tr.append(el("em", "", String(data.trash.length)));
499
+ tr.onclick = () => { trashOn = !trashOn; selecting = false; picked.clear(); draw(); };
500
+ h.append(q, sel, tr);
501
+ return h;
502
+ }
503
+
504
+ function body() {
505
+ const box = el("div", "sm-body");
506
+ if (failed) { box.append(el("div", "empty-state", failed)); return box; }
507
+ if (!data || (!trashOn && !data.sessions)) {
508
+ const l = el("div", "list sm-group");
509
+ for (let i = 0; i < 4; i++) { const r = el("div", "row"); r.append(el("span", "skeleton sk-line")); l.append(r); }
510
+ box.append(l);
511
+ return box;
512
+ }
513
+ if (trashOn) { box.append(...trash()); return box; }
514
+ if (consentApplies()) box.append(consentCard());
515
+ if (recordingApplies()) box.append(recordingCard());
516
+ if (!data.agents.length) {
517
+ if (consentApplies()) return box;
518
+ const e = el("div", "empty-state");
519
+ e.append(el("b", "", t("No sessions yet")), el("span", "", data.reads?.gateway
520
+ // gateway mode lists no session of this computer's agents
521
+ ? t("The sessions of agents on other computers that use this gateway show up here, by the folder they ran in.")
522
+ : t("Claude Code's, Codex's, Hermes's, OpenCode's and Pi's sessions on this computer show up here, by the folder they ran in.")));
523
+ box.append(e);
524
+ return box;
525
+ }
526
+ const a = current();
527
+ // what can't be deleted says so; the gateway's sessions, which can,
528
+ // say why they can't be resumed and what Delete removes of them
529
+ const locked = data.sessions.filter((s) => !s.deletable);
530
+ if (a && (locked.length || !a.deletable)) {
531
+ const canResume = locked.some((s) => s.resume);
532
+ const key = locked.some((s) => s.read_only)
533
+ ? canResume
534
+ ? "Some {agent} sessions are read only and cannot be deleted."
535
+ : "These {agent} sessions are read only; magpie can list them, but cannot resume or delete them."
536
+ : canResume
537
+ ? "magpie can list {agent}'s sessions and resume them, but not delete them: they aren't kept as files of their own."
538
+ : "magpie can list {agent}'s sessions, but cannot resume or delete them.";
539
+ box.append(el("p", "usage-note sm-note", t(key, { agent: a.name })));
540
+ }
541
+ if (a && data.sessions.some((s) => s.gateway)) {
542
+ box.append(el("p", "usage-note sm-note sm-gw-note", t(data.sessions.every((s) => s.gateway)
543
+ ? "These {agent} sessions were seen only through magpie's gateway: {agent}'s own files of them aren't on this computer (it may run on another one, in a container or with another config folder), so they can't be resumed here. Delete takes one off this list and moves any conversation magpie recorded of it to its trash; Usage keeps what it spent."
544
+ : "Sessions under \"Seen through the gateway\" have no {agent} files on this computer, so they can't be resumed here. Delete takes one off this list and moves any conversation magpie recorded of it to its trash; Usage keeps what it spent.", { agent: a.name })));
545
+ }
546
+ const prov = providerBar();
547
+ if (prov) box.append(prov);
548
+ box.append(selectBar(), el("div", "sm-tree"));
549
+ queueMicrotask(redrawList);
550
+ return box;
551
+ }
552
+
553
+ // Codex lists in its history only the sessions made with the provider it
554
+ // uses now; ones made with another (CC Switch's "custom") are there as
555
+ // files but hidden from it (#887). They can be moved to Codex's provider
556
+ // here, picked in a dialog first, and the last move undone.
557
+ const otherProvider = (s) => !s.read_only && s.provider && s.uses_provider && s.provider !== s.uses_provider;
558
+ let lastMove = null; // { agent, moved: [{ id, from, to }] }
559
+
560
+ function providerBar() {
561
+ if (data.agent !== "codex") return null;
562
+ const hidden = data.sessions.filter(otherProvider);
563
+ const undo = lastMove?.agent === data.agent && lastMove.moved.length ? lastMove : null;
564
+ if (!hidden.length && !undo) return null;
565
+ const bar = el("div", "row sm-provider");
566
+ if (hidden.length) {
567
+ const from = [...new Set(hidden.map((s) => s.provider))].join(", ");
568
+ const to = hidden[0].uses_provider;
569
+ bar.append(el("span", "sm-provider-text", t(hidden.length === 1
570
+ ? "1 Codex session was made with another provider ({from}): Codex lists only {to}'s sessions in its history now, so it doesn't show there."
571
+ : "{n} Codex sessions were made with another provider ({from}): Codex lists only {to}'s sessions in its history now, so they don't show there.", { n: hidden.length, from, to })));
572
+ const go = el("button", "text sm-provider-move", t("Move to {to}…", { to }));
573
+ go.type = "button";
574
+ go.onclick = (e) => { e.stopPropagation(); askProvider(hidden); };
575
+ bar.append(el("span", "grow"), go);
576
+ } else {
577
+ bar.append(el("span", "sm-provider-text", t(undo.moved.length === 1 ? "Moved 1 session to {to}" : "Moved {n} sessions to {to}", { n: undo.moved.length, to: undo.moved[0].to })), el("span", "grow"));
578
+ }
579
+ if (undo) {
580
+ const back = el("button", "text sm-provider-undo");
581
+ back.type = "button";
582
+ back.append(svg(UNDO, 13, 1.4), el("span", "", t("Undo")));
583
+ back.onclick = async (e) => {
584
+ e.stopPropagation();
585
+ back.disabled = true;
586
+ const out = await moveProviders(undo.moved.map((m) => ({ id: m.id, to: m.from })));
587
+ if (!out) { back.disabled = false; return; }
588
+ lastMove = null;
589
+ if (!out.refused.length) status(t(out.moved.length === 1 ? "Moved 1 session back" : "Moved {n} sessions back", { n: out.moved.length }), "ok");
590
+ await load();
591
+ };
592
+ bar.append(back);
593
+ }
594
+ return bar;
595
+ }
596
+
597
+ // moveProviders posts the moves, says what was refused, and gives the
598
+ // reply (null when the post failed)
599
+ async function moveProviders(moves) {
600
+ let out;
601
+ try {
602
+ out = await api("sessions/codex-provider", { moves });
603
+ } catch (err) {
604
+ status(err.message, "err");
605
+ return null;
606
+ }
607
+ if (out.refused.length) {
608
+ const first = out.refused[0], s = data.sessions.find((x) => x.id === first.id);
609
+ const active = out.refused.filter((r) => r.active);
610
+ status(active.length ? t(active.length === 1 ? "{title} is still being written to; close it in {agent} and try again in a minute" : "{n} sessions are still being written to; close them in {agent} and try again in a minute", { title: s?.title || first.id, n: active.length, agent: "Codex" })
611
+ : (s?.title || first.id) + ": " + first.error, "err");
612
+ }
613
+ return out;
614
+ }
615
+
616
+ // askProvider: the sessions to move, each ticked, to untick before Move
617
+ function askProvider(list) {
618
+ const to = list[0].uses_provider;
619
+ const chosen = new Set(list.map((s) => s.id));
620
+ const ed = el("div", "editor sm-ask sm-provider-ask");
621
+ const h = el("div", "ehead");
622
+ h.append(icon(current()?.icon || "generic"), el("b", "", t("Move sessions to {to}?", { to })));
623
+ ed.append(h);
624
+ const names = el("ul", "sm-ask-list sm-provider-list");
625
+ const go = el("button", "text primary", t("Move"));
626
+ // over a longer list: how many are picked, Select all (Deselect all
627
+ // once all are) and Invert selection (Cyrus on Discord)
628
+ const pick = el("div", "sm-provider-pick");
629
+ const n = el("span", "note sm-count");
630
+ const all = el("button", "text sm-pick-all");
631
+ const flip = el("button", "text sm-pick-invert", t("Invert selection"));
632
+ all.type = flip.type = "button";
633
+ pick.append(n, el("span", "grow"), all, flip);
634
+ if (list.length > 1) ed.append(pick);
635
+ const boxes = [];
636
+ const sync = () => {
637
+ for (const [c, id] of boxes) c.checked = chosen.has(id);
638
+ go.disabled = !chosen.size;
639
+ n.textContent = t("{n} selected", { n: chosen.size });
640
+ all.textContent = t(chosen.size === list.length ? "Deselect all" : "Select all");
641
+ };
642
+ all.onclick = (e) => { e.stopPropagation(); const on = chosen.size < list.length; for (const s of list) on ? chosen.add(s.id) : chosen.delete(s.id); sync(); };
643
+ flip.onclick = (e) => { e.stopPropagation(); for (const s of list) chosen.has(s.id) ? chosen.delete(s.id) : chosen.add(s.id); sync(); };
644
+ for (const s of list) {
645
+ const li = el("li");
646
+ const lab = el("label");
647
+ const c = el("input", "sm-check");
648
+ c.type = "checkbox";
649
+ c.onchange = () => { c.checked ? chosen.add(s.id) : chosen.delete(s.id); sync(); };
650
+ boxes.push([c, s.id]);
651
+ lab.append(c, el("span", "name", s.title || s.id), el("span", "sm-prov", s.provider), el("span", "note", ago(s.last)));
652
+ li.append(lab);
653
+ names.append(li);
654
+ }
655
+ sync();
656
+ ed.append(names);
657
+ ed.append(el("p", "lib-confirm", t("Only the provider each one names is changed, in its files and in Codex's database: its messages, id, title and archive stay as they are. A copy of its files is kept in magpie's trash folder first, and Undo moves them back. A session written to in the last minute is left alone.")));
658
+ const bar = el("div", "bar");
659
+ go.type = "button";
660
+ go.onclick = async (e) => {
661
+ e.stopPropagation();
662
+ go.disabled = true;
663
+ go.classList.add("busy");
664
+ const out = await moveProviders([...chosen].map((id) => ({ id, to })));
665
+ if (!out) { go.disabled = false; go.classList.remove("busy"); return; }
666
+ closeConfirmAsk();
667
+ lastMove = { agent: data.agent, moved: out.moved };
668
+ if (!out.refused.length) status(t(out.moved.length === 1 ? "Moved 1 session to {to}" : "Moved {n} sessions to {to}", { n: out.moved.length, to }), "ok");
669
+ await load();
670
+ };
671
+ const cancel = el("button", "text", t("Cancel"));
672
+ cancel.type = "button";
673
+ cancel.onclick = (e) => { e.stopPropagation(); closeConfirmAsk(); };
674
+ bar.append(el("span", "grow"), cancel, go);
675
+ ed.append(bar);
676
+ confirmAsk = ed;
677
+ openModal(ed);
678
+ $("#modal").classList.add("lib");
679
+ cancel.focus({ preventScroll: true });
680
+ }
681
+
682
+ // the bar over the tree while picking: pick every session shown, and
683
+ // delete those picked
684
+ function selectBar() {
685
+ const bar = el("div", "row-head sm-bar");
686
+ const a = current();
687
+ if (!a?.deletable || !selecting) { bar.hidden = true; return bar; }
688
+ const list = shown().filter((s) => s.deletable);
689
+ const all = el("input", "sm-check");
690
+ all.type = "checkbox";
691
+ all.checked = list.length > 0 && list.every((s) => picked.has(s.id));
692
+ all.indeterminate = !all.checked && list.some((s) => picked.has(s.id));
693
+ all.setAttribute("aria-label", t("Select every session shown"));
694
+ all.onchange = () => {
695
+ for (const s of list) all.checked ? picked.add(s.id) : picked.delete(s.id);
696
+ redrawList();
697
+ };
698
+ const n = el("span", "note sm-count", picked.size ? t("{n} selected", { n: picked.size }) : t(list.length === 1 ? "{n} session" : "{n} sessions", { n: list.length }));
699
+ const del = el("button", "text danger sm-delete");
700
+ del.type = "button";
701
+ del.disabled = !picked.size;
702
+ del.append(svg(TRASH, 13, 1.4), el("span", "", t("Delete")));
703
+ del.onclick = () => askDelete([...picked], wholeFolder([...picked]));
704
+ bar.append(all, n, el("span", "grow"), del);
705
+ return bar;
706
+ }
707
+
708
+ // wholeFolder: the folder (a cwd, "" for none) when ids are every
709
+ // session of that one folder, so the dialog names it
710
+ function wholeFolder(ids) {
711
+ const all = data?.sessions || [];
712
+ const set = new Set(ids);
713
+ const cwds = new Set(all.filter((s) => set.has(s.id)).map((s) => groupOf(s)));
714
+ if (cwds.size !== 1) return undefined;
715
+ const [cwd] = cwds;
716
+ return all.filter((s) => groupOf(s) === cwd).every((s) => set.has(s.id)) ? cwd : undefined;
717
+ }
718
+
719
+ function redrawList() {
720
+ const tree = page.querySelector(".sm-tree");
721
+ if (!tree || !data) return;
722
+ const bar = page.querySelector(".sm-bar");
723
+ if (bar) bar.replaceWith(selectBar());
724
+ const list = shown();
725
+ // the project of the latest session is open the first time an agent is
726
+ // shown
727
+ if (openedFor !== data.agent) {
728
+ openedFor = data.agent;
729
+ opened.clear();
730
+ drawnIn.clear();
731
+ const first = list.find((s) => !scratch(groupOf(s))) || list[0];
732
+ if (first) opened.add(groupOf(first));
733
+ }
734
+ nest(list);
735
+ // a session shown under another stays in sight: its folder and every
736
+ // session it is under are unfolded
737
+ for (const id of [detail, focus?.agent === data.agent ? focus.id : ""]) {
738
+ if (!id || !rootOf.has(id)) continue;
739
+ const byId = new Map(list.map((s) => [s.id, s]));
740
+ for (let p = byId.get(byId.get(id)?.parent_session), n = 0; p && n < MAX_DEPTH; p = byId.get(p.parent_session), n++) subsOpen.add(p.agent + "/" + p.id);
741
+ }
742
+ const groups = new Map();
743
+ for (const s of list) {
744
+ if (rootOf.has(s.id)) continue;
745
+ const k = groupOf(s);
746
+ if (!groups.has(k)) groups.set(k, []);
747
+ groups.get(k).push(s);
748
+ }
749
+ tree.replaceChildren();
750
+ folderBoxes.clear();
751
+ const note = searchNote();
752
+ if (note) tree.append(note);
753
+ if (!list.length) {
754
+ if (!search()?.busy) tree.append(el("div", "empty-state", query ? t("No session matches.") : t("No sessions yet")));
755
+ return;
756
+ }
757
+ const q = query.trim();
758
+ // the projects in one list, and the folders apps and the system made
759
+ // folded under them
760
+ const all = [...groups].filter(([k]) => !scratch(k));
761
+ const rest = [...groups].filter(([k]) => scratch(k));
762
+ if (all.length) tree.append(folderList(el("div", "list sm-folders"), all, FOLDERS, q));
763
+ if (rest.length) tree.append(othersBox(rest, q));
764
+ toFocus();
765
+ }
766
+
767
+ // folderList draws into box a page of folders, or as many as were drawn
768
+ // before, and down to the folder of the session opened to its details
769
+ function folderList(box, all, key, q) {
770
+ const top = rootOf.get(detail) || detail;
771
+ const at = detail ? all.findIndex(([, items]) => items.some((s) => s.id === top)) : -1;
772
+ const n = Math.min(all.length, Math.max(drawnIn.get(key) || SM_PAGE, at + 1));
773
+ for (const [cwd, items] of all.slice(0, n)) box.append(folder(cwd, items, q));
774
+ if (n < all.length) box.append(moreFolders(all, n, q, key));
775
+ return box;
776
+ }
777
+
778
+ // othersBox is the fold of the folders apps and the system made: shut
779
+ // until the reader opens it, a search finds something in it, or the
780
+ // session opened to its details is in it
781
+ function othersBox(rest, q) {
782
+ const box = el("div", "list sm-others");
783
+ const top = rootOf.get(detail) || detail;
784
+ const open = othersOpen || !!q || (!!detail && rest.some(([, items]) => items.some((s) => s.id === top)));
785
+ const r = el("div", "row sm-others-row");
786
+ const fold = el("button", "fold sm-others-fold");
787
+ fold.type = "button";
788
+ fold.setAttribute("aria-expanded", String(open));
789
+ const words = el("span", "sm-fwords");
790
+ words.append(el("span", "name", t("Other folders")), el("span", "sm-latest", t("Temporary folders, the disk's root and apps' hidden folders")));
791
+ fold.append(svg(CHEV_R, 10, 1.6), words);
792
+ fold.onclick = () => { othersOpen = !open; redrawList(); };
793
+ r.append(fold, el("span", "note", t(rest.length === 1 ? "{n} folder" : "{n} folders", { n: rest.length })), folderSlot());
794
+ box.append(r);
795
+ if (open) folderList(box, rest, OTHERS, q);
796
+ return box;
797
+ }
798
+
799
+ // nest finds the sessions shown that are under another one shown: a
800
+ // Codex thread, or a Claude branch, whose parent_session is, by a chain
801
+ // with no loop in it
802
+ function nest(list) {
803
+ const byId = new Map(list.map((s) => [s.id, s]));
804
+ kidsOf = new Map();
805
+ rootOf = new Map();
806
+ for (const s of list) {
807
+ let p = byId.get(s.parent_session), root = null;
808
+ const seen = new Set([s.id]);
809
+ while (p && !seen.has(p.id) && seen.size <= MAX_DEPTH) {
810
+ seen.add(p.id);
811
+ root = p;
812
+ p = byId.get(p.parent_session);
813
+ }
814
+ // a loop, or a chain deeper than is drawn, leaves it on top
815
+ if (!root || p) continue;
816
+ rootOf.set(s.id, root.id);
817
+ const kids = kidsOf.get(s.parent_session) || [];
818
+ kids.push(s);
819
+ kidsOf.set(s.parent_session, kids);
820
+ }
821
+ for (const kids of kidsOf.values()) kids.sort((a, b) => (a.start || a.last || "").localeCompare(b.start || b.last || ""));
822
+ }
823
+
824
+ // moreFolders adds the next page of folders above itself, as moreRow does
825
+ // a folder's sessions
826
+ function moreFolders(all, n, q, key) {
827
+ drawnIn.set(key, n);
828
+ const b = el("button", "text sess-page-more sm-more-folders");
829
+ b.type = "button";
830
+ b.dataset.unrolls = ""; // it goes down with the folders it opens
831
+ const label = () => t("Show {n} more folders", { n: Math.min(SM_PAGE, all.length - n) });
832
+ b.textContent = label();
833
+ b.onclick = (e) => {
834
+ e.stopPropagation();
835
+ const next = Math.min(all.length, n + SM_PAGE);
836
+ b.before(...all.slice(n, next).map(([cwd, items]) => folder(cwd, items, q)));
837
+ n = next;
838
+ drawnIn.set(key, n);
839
+ if (n >= all.length) b.remove();
840
+ else b.textContent = label();
841
+ };
842
+ return b;
843
+ }
844
+
845
+ // A folder's row: its name and where it is, the latest session's title
846
+ // under them, and when that was and how many there are on the right.
847
+ // What can be done to the whole folder is in its ⋯ menu, shown on hover.
848
+ function folder(cwd, items, q) {
849
+ const open = !!q || opened.has(cwd);
850
+ const g = el("div", "sm-group");
851
+ const r = el("div", "row sm-folder");
852
+ // the sessions under those on top are the folder's too
853
+ const members = [];
854
+ const under = (s, n) => { members.push(s); if (n < MAX_DEPTH) for (const k of kidsOf.get(s.id) || []) under(k, n + 1); };
855
+ for (const s of items) under(s, 0);
856
+ const writable = members.filter((s) => s.deletable);
857
+ // the folder's box picks every session of it shown, folded or not,
858
+ // for the bar's Delete; the bar still counts sessions (#527)
859
+ if (selecting && current()?.deletable && writable.length) {
860
+ const c = el("input", "sm-check sm-folder-check");
861
+ c.type = "checkbox";
862
+ // a session's own box ticked or not shows here at once
863
+ c.sync = () => {
864
+ c.checked = writable.every((s) => picked.has(s.id));
865
+ c.indeterminate = !c.checked && writable.some((s) => picked.has(s.id));
866
+ };
867
+ c.sync();
868
+ folderBoxes.set(cwd, c);
869
+ c.setAttribute("aria-label", t("Select every session in {folder}", { folder: groupName(cwd) }));
870
+ c.onclick = (e) => e.stopPropagation();
871
+ c.onchange = () => {
872
+ for (const s of writable) c.checked ? picked.add(s.id) : picked.delete(s.id);
873
+ redrawList();
874
+ };
875
+ r.append(c);
876
+ }
877
+ const fold = el("button", "fold");
878
+ fold.type = "button";
879
+ fold.setAttribute("aria-expanded", String(open));
880
+ const path = cwd === GW ? "" : cwd;
881
+ const line = el("span", "sm-fline");
882
+ line.append(el("span", "name", groupName(cwd)));
883
+ const where = path ? whereOf(path) : "";
884
+ if (where) line.append(el("span", "sub sm-path", where));
885
+ const words = el("span", "sm-fwords");
886
+ words.append(line);
887
+ const latest = members.reduce((x, s) => (s.last || "") > (x.last || "") ? s : x, members[0]);
888
+ if (latest) words.append(el("span", "sm-latest", latest.title || t("(no prompt)")));
889
+ fold.append(svg(CHEV_R, 10, 1.6), words);
890
+ fold.onclick = () => {
891
+ if (opened.has(cwd)) opened.delete(cwd); else opened.add(cwd);
892
+ redrawList();
893
+ };
894
+ const when = el("span", "sm-when");
895
+ if (latest?.last) when.append(el("span", "sm-ago", ago(latest.last)));
896
+ when.append(el("span", "note", t(members.length === 1 ? "{n} session" : "{n} sessions", { n: members.length })));
897
+ r.append(fold, when);
898
+ const acts = [];
899
+ const allInFolder = (data.sessions || []).filter((s) => s.cwd === cwd);
900
+ if (data.agent === "claude" && path && allInFolder.every((s) => !s.read_only && !s.gateway)
901
+ && new Set(allInFolder.map((s) => s.wsl || "")).size === 1) {
902
+ acts.push({ name: "Relocate project", icon: MOVE, run: () => askRelocate(cwd, allInFolder[0].wsl || "") });
903
+ }
904
+ if (current()?.deletable && writable.length) {
905
+ acts.push({ name: "Delete all sessions", icon: TRASH, sep: acts.length > 0, run: () => askDelete(writable.map((s) => s.id), wholeFolder(writable.map((s) => s.id))) });
906
+ }
907
+ if (acts.length) {
908
+ const more = el("button", "copy sm-folder-more");
909
+ more.type = "button";
910
+ more.title = t("More");
911
+ more.setAttribute("aria-label", t("More"));
912
+ more.setAttribute("aria-haspopup", "menu");
913
+ more.append(svg(DOTS, 14, 2));
914
+ more.onclick = (e) => {
915
+ e.stopPropagation();
916
+ if (more.classList.contains("open")) return closeAgentMenu();
917
+ openRowMenu(more, acts);
918
+ };
919
+ r.append(more);
920
+ r.oncontextmenu = (e) => { e.preventDefault(); openRowMenu(more, acts); };
921
+ } else r.append(folderSlot());
922
+
923
+ r.title = path;
924
+ g.append(r);
925
+ if (open) {
926
+ // a page, or as many as were drawn before, and down to the session
927
+ // opened to its details (from the Usage page's list, #752)
928
+ const top = rootOf.get(detail) || detail;
929
+ const at = detail ? items.findIndex((s) => s.id === top) : -1;
930
+ const n = Math.min(items.length, Math.max(drawnIn.get(cwd) || SM_PAGE, at + 1));
931
+ for (const s of items.slice(0, n)) g.append(item(s));
932
+ if (n < items.length) g.append(moreRow(cwd, items, n));
933
+ }
934
+ return g;
935
+ }
936
+
937
+ // moreRow adds the folder's next page of sessions above itself, leaving
938
+ // the rows drawn as they are, so what the reader looks at doesn't move
939
+ function moreRow(cwd, items, n) {
940
+ drawnIn.set(cwd, n);
941
+ const b = el("button", "text sess-page-more sm-more");
942
+ b.type = "button";
943
+ b.dataset.unrolls = ""; // it goes down with the rows it opens
944
+ const label = () => t("Show {n} more", { n: Math.min(SM_PAGE, items.length - n) });
945
+ b.textContent = label();
946
+ b.onclick = (e) => {
947
+ e.stopPropagation();
948
+ const next = Math.min(items.length, n + SM_PAGE);
949
+ b.before(...items.slice(n, next).map((s) => item(s)));
950
+ n = next;
951
+ drawnIn.set(cwd, n);
952
+ if (n >= items.length) b.remove();
953
+ else b.textContent = label();
954
+ };
955
+ return b;
956
+ }
957
+
958
+ // lanes says which of a row's controls some session of the list has, so
959
+ // a row without one keeps its room and every row's columns line up
960
+ const laneOf = new WeakMap();
961
+ function lanes() {
962
+ const list = data?.sessions || [];
963
+ let l = laneOf.get(list);
964
+ if (!l) {
965
+ const resume = list.some((s) => s.resume);
966
+ l = { resume, term: resume && !!data.terminal, carry: list.some((s) => s.carry?.length) };
967
+ laneOf.set(list, l);
968
+ }
969
+ return l;
970
+ }
971
+ // slot is a control's room, unseen and out of reach
972
+ function slot(tag, cls, text) {
973
+ const b = el(tag, "sm-slot " + cls, text);
974
+ b.setAttribute("aria-hidden", "true");
975
+ b.inert = true;
976
+ b.tabIndex = -1;
977
+ return b;
978
+ }
979
+ const folderSlot = () => slot("span", "copy sm-folder-slot");
980
+ // undeletableText is why a session magpie lists can't be deleted
981
+ function undeletableText(s, a) {
982
+ if (s.undeletable === "cursor-editor") return t("Cursor's editor keeps this chat in its own database, which it holds open while it runs: delete it in Cursor.");
983
+ return t("This {agent} session is read only, so magpie can't delete it.", { agent: a.name });
984
+ }
985
+
986
+ function item(s, depth = 0) {
987
+ const a = current();
988
+ const wrap = el("div", "sess-item sm-item" + (detail === s.id ? " open" : ""));
989
+ wrap.dataset.depth = String(depth);
990
+ const r = el("div", "row sess sm-sess");
991
+ r.dataset.id = s.id;
992
+ if (selecting && a?.deletable && s.deletable) {
993
+ const c = el("input", "sm-check");
994
+ c.type = "checkbox";
995
+ c.checked = picked.has(s.id);
996
+ c.setAttribute("aria-label", t("Select"));
997
+ c.onclick = (e) => e.stopPropagation();
998
+ c.onchange = () => { c.checked ? picked.add(s.id) : picked.delete(s.id); const bar = page.querySelector(".sm-bar"); if (bar) bar.replaceWith(selectBar()); folderBoxes.get(groupOf(s))?.sync(); };
999
+ r.append(c);
1000
+ }
1001
+ const who = el("div", "who");
1002
+ who.append(el("div", "name", s.title || t("(no prompt)")));
1003
+ const sub = el("div", "sub", [ago(s.last), sessModelsText(s), s.messages ? t(s.messages === 1 ? "{n} message" : "{n} messages", { n: s.messages }) : "",
1004
+ // a gateway session's size is the conversation magpie recorded of it,
1005
+ // and none is said when it recorded none; its title is its id already
1006
+ s.gateway ? (s.size ? t("{size} recorded", { size: fmtBytes(s.size) }) : "") : fmtBytes(s.size),
1007
+ s.title === s.id ? "" : s.id.slice(0, 8)].filter(Boolean).join(" · "));
1008
+ if (s.via?.length) sub.title = s.via.map(viaText).join("\n");
1009
+ if (s.wsl) sub.prepend(wslBadge(s), " ");
1010
+ if (otherProvider(s)) {
1011
+ const p = el("span", "sm-prov", s.provider);
1012
+ p.title = t("Made with {from}: Codex's history lists only {to}'s sessions now", { from: s.provider, to: s.uses_provider });
1013
+ sub.prepend(p, " ");
1014
+ }
1015
+ who.append(sub);
1016
+ const hit = hitOf(s);
1017
+ if (hit) who.append(hitLine(hit));
1018
+ // what it spent, as the Usage page's list shows it
1019
+ r.append(who, ...sessSpent(s));
1020
+ const lane = lanes();
1021
+ if (s.resume) {
1022
+ const res = el("button", "sess-resume", t("Resume"));
1023
+ res.type = "button";
1024
+ // as wide whichever it says
1025
+ res.dataset.wide = t("Resume");
1026
+ res.dataset.wide2 = t("Copied");
1027
+
1028
+ res.title = t("Copy the command that resumes it: {cmd}", { cmd: s.resume });
1029
+ res.onclick = async (e) => {
1030
+ e.stopPropagation();
1031
+ await copy(s.resume, t("Resume command"));
1032
+ res.textContent = t("Copied");
1033
+ res.classList.add("done");
1034
+ clearTimeout(res.copiedT);
1035
+ res.copiedT = setTimeout(() => { res.textContent = t("Resume"); res.classList.remove("done"); }, 1400);
1036
+ };
1037
+ r.append(res);
1038
+ if (lane.term) {
1039
+ const term = el("button", "copy sess-term");
1040
+ term.type = "button";
1041
+ term.title = t("Open in session terminal");
1042
+ term.append(svg(TERM, 13, 1.6));
1043
+ term.onclick = (e) => {
1044
+ e.stopPropagation();
1045
+ api("sessions/terminal", { agent: s.agent, id: s.id }).then(() => status(t("Opening in session terminal"), "ok"), (err) => status(err.message, "err"));
1046
+ };
1047
+ r.append(term);
1048
+ }
1049
+ } else if (lane.resume) {
1050
+ const res = slot("button", "sm-slot-resume", t("Resume"));
1051
+ res.dataset.wide = t("Resume");
1052
+ res.dataset.wide2 = t("Copied");
1053
+ r.append(res);
1054
+ if (lane.term) r.append(slot("span", "copy sm-slot-icon"));
1055
+ }
1056
+ if (s.carry?.length) r.append(carryPick(s));
1057
+ else if (lane.carry) {
1058
+ const b = slot("button", "sess-pick sm-slot-carry");
1059
+ b.append(el("span", "", t("Continue in")), svg(CHEV, 11, 1.6));
1060
+ r.append(b);
1061
+ }
1062
+ // every session of an agent magpie deletes from has a delete; one it
1063
+ // can't delete says why on it (ysm119 on Discord: some Cursor rows had
1064
+ // none, and every column after it moved)
1065
+ if (a?.deletable) {
1066
+ const del = el("button", "copy sm-del");
1067
+ del.type = "button";
1068
+ const why = s.deletable ? "" : undeletableText(s, a);
1069
+ del.title = why || t("Delete");
1070
+ del.setAttribute("aria-label", why ? t("Delete") + ": " + why : t("Delete"));
1071
+ del.append(svg(TRASH, 13, 1.4));
1072
+ if (why) {
1073
+ del.classList.add("off");
1074
+ del.setAttribute("aria-disabled", "true");
1075
+ del.onclick = (e) => { e.stopPropagation(); status(why, "err"); };
1076
+ } else del.onclick = (e) => { e.stopPropagation(); askDelete([s.id]); };
1077
+ r.append(del);
1078
+ }
1079
+ // only the session opened and the one closed are drawn again
1080
+ r.onclick = () => {
1081
+ if (window.getSelection()?.toString()) return;
1082
+ const was = detail;
1083
+ detail = detail === s.id ? "" : s.id;
1084
+ if (was && was !== s.id) {
1085
+ const old = page.querySelector(`.row.sm-sess[data-id="${CSS.escape(was)}"]`)?.closest(".sm-item");
1086
+ const prev = data.sessions.find((x) => x.id === was);
1087
+ if (old && prev) old.replaceWith(item(prev, +old.dataset.depth || 0));
1088
+ }
1089
+ wrap.replaceWith(item(s, depth));
1090
+ };
1091
+ wrap.append(r);
1092
+ if (detail === s.id) wrap.append(details(s));
1093
+ const kids = depth < MAX_DEPTH ? kidsOf.get(s.id) || [] : [];
1094
+ if (kids.length || s.subagents?.length) wrap.append(subagentsBox(s, kids, depth));
1095
+ return wrap;
1096
+ }
1097
+
1098
+ // subagentsBox is the fold under a session with its subagents: those
1099
+ // its agent started inside it, each opened to its conversation, and the
1100
+ // Codex threads it started, drawn as sessions; and the Claude sessions
1101
+ // forked from it that went on (role "branch"), named apart
1102
+ function subagentsBox(s, kids, depth) {
1103
+ const box = el("div", "sm-subs");
1104
+ const k = s.agent + "/" + s.id;
1105
+ const subs = subTree(s.subagents || []);
1106
+ const branches = kids.filter((c) => c.role === "branch").length;
1107
+ const n = kids.length + subs.length - branches;
1108
+ const words = [];
1109
+ if (branches) words.push(t(branches === 1 ? "{n} branch" : "{n} branches", { n: branches }));
1110
+ if (n || !branches) words.push(t(n === 1 ? "{n} subagent" : "{n} subagents", { n }));
1111
+ const draw = () => {
1112
+ const open = subsOpen.has(k) || (!!query.trim() && kids.length > 0);
1113
+ const fold = el("button", "fold sm-subs-fold");
1114
+ fold.type = "button";
1115
+ fold.setAttribute("aria-expanded", String(open));
1116
+ fold.append(svg(CHEV_R, 10, 1.6), el("span", "", words.join(" · ")));
1117
+ fold.onclick = (e) => {
1118
+ e.stopPropagation();
1119
+ if (subsOpen.has(k)) subsOpen.delete(k); else subsOpen.add(k);
1120
+ draw();
1121
+ };
1122
+ const out = [fold];
1123
+ if (open) {
1124
+ const list = el("div", "sm-subs-list");
1125
+ for (const c of kids) list.append(item(c, depth + 1));
1126
+ for (const [sa, d] of subs) list.append(subRow(s, sa, d));
1127
+ out.push(list);
1128
+ }
1129
+ box.replaceChildren(...out);
1130
+ };
1131
+ box.style.setProperty("--sub-depth", String(depth));
1132
+ draw();
1133
+ return box;
1134
+ }
1135
+
1136
+ // subTree orders a session's subagents as they started one another,
1137
+ // each with how deep it is under the session
1138
+ function subTree(subs) {
1139
+ const ids = new Set(subs.map((sa) => sa.id));
1140
+ const under = new Map();
1141
+ for (const sa of subs) {
1142
+ const p = sa.parent && ids.has(sa.parent) && sa.parent !== sa.id ? sa.parent : "";
1143
+ if (!under.has(p)) under.set(p, []);
1144
+ under.get(p).push(sa);
1145
+ }
1146
+ const out = [];
1147
+ const seen = new Set();
1148
+ const walk = (p, d) => {
1149
+ for (const sa of under.get(p) || []) {
1150
+ if (seen.has(sa.id)) continue;
1151
+ seen.add(sa.id);
1152
+ out.push([sa, Math.min(d, MAX_DEPTH)]);
1153
+ walk(sa.id, d + 1);
1154
+ }
1155
+ };
1156
+ walk("", 0);
1157
+ // one in a loop of parents is still shown, at the top
1158
+ for (const sa of subs) if (!seen.has(sa.id)) { seen.add(sa.id); out.push([sa, 0]); walk(sa.id, 1); }
1159
+ return out;
1160
+ }
1161
+
1162
+ // subRow is one subagent of s: what it was asked, the agent it ran as,
1163
+ // and a click shows what was said in it
1164
+ function subRow(s, sa, d) {
1165
+ const k = s.agent + "/" + s.id + "/" + sa.id;
1166
+ const wrap = el("div", "sm-sub");
1167
+ wrap.style.setProperty("--sub-in", String(d));
1168
+ const r = el("button", "row sm-sub-row");
1169
+ r.type = "button";
1170
+ r.dataset.sub = sa.id;
1171
+ const who = el("span", "who");
1172
+ who.append(el("span", "name", sa.title || sa.kind || sa.id),
1173
+ el("span", "sub", [sa.title ? sa.kind : "", sa.last || sa.start ? ago(sa.last || sa.start) : "", sa.id.slice(0, 8)].filter(Boolean).join(" · ")));
1174
+ r.append(who);
1175
+ const box = el("div", "sess-talk");
1176
+ box.dataset.talk = k;
1177
+ const show = () => {
1178
+ const on = talkOpen.has(k);
1179
+ r.setAttribute("aria-expanded", String(on));
1180
+ wrap.classList.toggle("open", on);
1181
+ box.hidden = !on;
1182
+ if (on) drawTalk(box, s, sa.id);
1183
+ };
1184
+ r.onclick = (e) => {
1185
+ e.stopPropagation();
1186
+ if (window.getSelection()?.toString()) return;
1187
+ if (talkOpen.has(k)) talkOpen.delete(k); else talkOpen.add(k);
1188
+ show();
1189
+ };
1190
+ wrap.append(r, box);
1191
+ show();
1192
+ return wrap;
1193
+ }
1194
+
1195
+ function details(s) {
1196
+ const d = el("div", "sess-detail");
1197
+ const line = (k, v, extra) => {
1198
+ const l = el("div", "sess-line");
1199
+ l.append(el("span", "k", k), v instanceof Node ? v : el("span", "v", v));
1200
+ if (extra) l.append(extra);
1201
+ d.append(l);
1202
+ };
1203
+ line(t("Time"), stamp(s.start || s.last) + " – " + stamp(s.last));
1204
+ if (s.wsl) line("WSL", s.wsl);
1205
+ if (s.cwd) line(t("Folder"), s.cwd);
1206
+ line(t("Session ID"), s.id, copyBtn(s.id, t("Session id")));
1207
+ if (s.resume) line(t("Resume"), el("code", "", s.resume), copyBtn(s.resume, t("Resume command")));
1208
+ for (const c of s.carry || []) {
1209
+ const agent = agentOf(c.agent).name;
1210
+ if (!c.convert) line(t("Continue in {agent}", { agent }), el("code", "", c.command), copyBtn(c.command, t("Command")));
1211
+ else {
1212
+ // made only when asked: a new session of that agent's, this one only read
1213
+ const b = copyBtn("", t("Command"));
1214
+ b.title = t("Convert, then copy the command");
1215
+ b.onclick = (e) => { e.stopPropagation(); carryOver(s, c.agent, false, b); };
1216
+ line(t("Continue in {agent}", { agent }), el("span", "v", t("A new {agent} session is made from this conversation", { agent })), b);
1217
+ }
1218
+ }
1219
+ sessUsageDetail(d, s, line);
1220
+ if (s.path) line(t("File"), s.path + (s.files > 1 ? " " + t("+{n} more", { n: s.files - 1 }) : ""));
1221
+ if (s.transcript) {
1222
+ const box = el("div", "sess-talk");
1223
+ box.dataset.talk = s.agent + "/" + s.id;
1224
+ const b = el("button", "text sess-talk-btn");
1225
+ b.type = "button";
1226
+ const show = () => {
1227
+ const on = talkOpen.has(s.agent + "/" + s.id);
1228
+ b.textContent = t(on ? "Hide conversation" : "Show conversation");
1229
+ b.setAttribute("aria-expanded", String(on));
1230
+ box.hidden = !on;
1231
+ if (on) drawTalk(box, s);
1232
+ };
1233
+ b.onclick = (e) => {
1234
+ e.stopPropagation();
1235
+ const k = s.agent + "/" + s.id;
1236
+ if (talkOpen.has(k)) talkOpen.delete(k); else talkOpen.add(k);
1237
+ show();
1238
+ };
1239
+ line(t("Conversation"), b, exportBtn(s));
1240
+ d.append(box);
1241
+ show();
1242
+ }
1243
+ return d;
1244
+ }
1245
+
1246
+ // exportBtn saves a session's whole conversation as Markdown (#1276),
1247
+ // read from the agent's own file by the server: in magpie web the browser
1248
+ // downloads it, in the app it goes to Downloads and the status says where.
1249
+ function exportBtn(s) {
1250
+ const b = el("button", "text sess-export", t("Export Markdown"));
1251
+ b.type = "button";
1252
+ b.title = t("Save the whole conversation as a Markdown file");
1253
+ const q = "agent=" + encodeURIComponent(s.agent) + "&id=" + encodeURIComponent(s.id);
1254
+ b.onclick = async (e) => {
1255
+ e.stopPropagation();
1256
+ if (web) {
1257
+ const a = el("a");
1258
+ a.href = "/api/sessions/markdown?" + q;
1259
+ a.download = "";
1260
+ a.click();
1261
+ return;
1262
+ }
1263
+ if (b.disabled) return;
1264
+ b.disabled = true;
1265
+ b.classList.add("busy");
1266
+ try {
1267
+ const r = await api("sessions/export?" + q, {});
1268
+ status(t("Saved to {path}", { path: r.path }), "ok");
1269
+ } catch (err) {
1270
+ status(err.message, "err");
1271
+ } finally {
1272
+ b.disabled = false;
1273
+ b.classList.remove("busy");
1274
+ }
1275
+ };
1276
+ return b;
1277
+ }
1278
+
1279
+ // agentOf is an agent as the page names it: of those with sessions, else
1280
+ // by its id
1281
+ // iconItems gives each agent in the menu just opened its icon, as its button has
1282
+ const iconItems = (opts) => document.querySelectorAll(".proto-menu .pm-item").forEach((b, i) => opts[i]?.icon && b.firstChild.after(icon(opts[i].icon)));
1283
+ const agentOf = (id) => data?.agents.find((x) => x.agent === id) || { agent: id, name: id, icon: id };
1284
+
1285
+ // carryPick is the other agents that can carry a session on (#845):
1286
+ // from its file as it is (oh-my-pi forks a Pi session), or from a new
1287
+ // session of theirs converted from it (Claude Code and Codex), in the
1288
+ // app's menu: copy the command, or open it in the session terminal.
1289
+ function carryPick(s) {
1290
+ const b = el("button", "sess-pick sess-carry");
1291
+ b.type = "button";
1292
+ b.setAttribute("aria-haspopup", "menu");
1293
+ b.setAttribute("aria-expanded", "false");
1294
+ b.title = t("Continue this session in another agent");
1295
+ b.append(el("span", "", t("Continue in")), svg(CHEV, 11, 1.6));
1296
+ b.onclick = (e) => {
1297
+ e.stopPropagation();
1298
+ if (b.classList.contains("open")) return closeProtoMenu();
1299
+ const opts = [];
1300
+ for (const c of s.carry) {
1301
+ const { name, icon: ic } = agentOf(c.agent);
1302
+ const title = c.convert ? t("A new {agent} session is made from this conversation", { agent: name }) : c.command;
1303
+ opts.push({ v: "copy\x01" + c.agent, name, icon: ic, literalName: true, note: c.convert ? "Convert, then copy the command" : "Copy the command", title });
1304
+ if (data.terminal) opts.push({ v: "term\x01" + c.agent, name, icon: ic, literalName: true, note: c.convert ? "Convert, then open in session terminal" : "Open in session terminal", title });
1305
+ }
1306
+ openProtoMenu(b, opts, "", (v) => {
1307
+ b.focus({ preventScroll: true });
1308
+ const [how, to] = v.split("\x01");
1309
+ const c = s.carry.find((x) => x.agent === to);
1310
+ if (!c) return;
1311
+ if (c.convert) carryOver(s, to, how === "term");
1312
+ else if (how === "copy") copy(c.command, t("Command"));
1313
+ else api("sessions/terminal", { agent: s.agent, id: s.id, in: to }).then(() => status(t("Opening in session terminal"), "ok"), (err) => status(err.message, "err"));
1314
+ }, "Continue in another agent", "sess-carry-menu", "right");
1315
+ iconItems(opts);
1316
+ };
1317
+ return b;
1318
+ }
1319
+
1320
+ // carryOver converts a session for another agent (Claude Code's for
1321
+ // Codex, Codex's for Claude Code) when the user picks it, then copies the
1322
+ // line that opens the new one, or opens it in the session terminal
1323
+ function carryOver(s, to, open, btn) {
1324
+ const agent = agentOf(to).name;
1325
+ api("sessions/carry", { agent: s.agent, id: s.id, to, open }).then((r) => {
1326
+ if (open) status(t("Made a new {agent} session from this one, opening in session terminal", { agent }), "ok");
1327
+ else copy(r.command, t("Command"), btn, t("Made a new {agent} session from this one, and copied the command that opens it", { agent }));
1328
+ }, (err) => status(err.message, "err"));
1329
+ }
1330
+
1331
+ // drawTalk fills box with what was said in a session, read from the
1332
+ // agent's own file when it is first shown (only read, never written)
1333
+ function drawTalk(box, s, sub = "") {
1334
+ const k = s.agent + "/" + s.id + (sub ? "/" + sub : "");
1335
+ const got = talks.get(k);
1336
+ if (!got) {
1337
+ box.replaceChildren(el("p", "cx-none", t("Reading…")));
1338
+ const pending = { busy: true };
1339
+ talks.set(k, pending);
1340
+ const finish = (value) => {
1341
+ // A refresh or clear invalidates older responses, even for the same ID.
1342
+ if (talks.get(k) !== pending) return;
1343
+ talks.set(k, value);
1344
+ if (!talkOpen.has(k)) return;
1345
+ for (const currentBox of page.querySelectorAll(".sess-talk")) {
1346
+ if (currentBox.dataset.talk === k) drawTalk(currentBox, s, sub);
1347
+ }
1348
+ };
1349
+ api("sessions/transcript?agent=" + encodeURIComponent(s.agent) + "&id=" + encodeURIComponent(s.id) + (sub ? "&sub=" + encodeURIComponent(sub) : ""))
1350
+ .then((tr) => finish({ tr }), (err) => finish({ err: err.message }));
1351
+ return;
1352
+ }
1353
+ if (got.busy) { box.replaceChildren(el("p", "cx-none", t("Reading…"))); return; }
1354
+ if (got.err) { box.replaceChildren(el("p", "cx-none", got.err)); talks.delete(k); return; }
1355
+ const parts = got.tr?.parts || [];
1356
+ const out = [];
1357
+ if (!parts.length) out.push(el("p", "cx-none", t(got.tr?.source === "gateway" ? "No saved gateway content for this session" : "Nothing was said here")));
1358
+ // each reply names its model once, at its first part, and again
1359
+ // only where the model changes inside it (#2253): the model its own
1360
+ // line in the agent's file names, so none where the file names none
1361
+ let said = "", fresh = true;
1362
+ for (const p of parts) {
1363
+ let model = "";
1364
+ if (p.role === "assistant") {
1365
+ if (p.model && (fresh || p.model !== said)) model = p.model;
1366
+ said = p.model || "";
1367
+ fresh = false;
1368
+ } else if (p.role === "user") fresh = true;
1369
+ out.push(ledSaid(p, model));
1370
+ }
1371
+ if (got.tr?.cut) out.push(el("p", "cx-none", t("There was more than is shown here")));
1372
+ out.push(el("p", "cx-src", t(got.tr?.source === "gateway" ? "Recorded gateway traffic only; earlier, expired or uncaptured content may be missing" : "Read from the agent's session file; magpie keeps no copy")));
1373
+ box.replaceChildren(...out);
1374
+ }
1375
+
1376
+ function askRelocate(from, wsl) {
1377
+ const ed = el("div", "editor sm-ask");
1378
+ const h = el("div", "ehead");
1379
+ h.append(icon("claude"), el("b", "", t("Relocate Claude Code project")));
1380
+ ed.append(h, el("p", "sub sm-ask-path", (wsl ? `WSL ${wsl}: ` : "") + from));
1381
+ ed.append(el("p", "lib-confirm", t("Close Claude Code and its editor sessions first. This moves the entire project's sessions, not just filtered or selected rows. Existing target storage is never overwritten. Desktop-only sessions are not changed.")));
1382
+ const label = el("label", "sm-ask-field", t("New absolute project path"));
1383
+ const input = el("input", "");
1384
+ input.type = "text";
1385
+ input.value = from;
1386
+ input.spellcheck = false;
1387
+ input.autocomplete = "off";
1388
+ input.style.width = "100%";
1389
+ label.append(input);
1390
+ ed.append(label);
1391
+ const info = el("p", "lib-confirm");
1392
+ info.setAttribute("aria-live", "polite");
1393
+ ed.append(info);
1394
+ const bar = el("div", "bar");
1395
+ const cancel = el("button", "text", t("Cancel"));
1396
+ const go = el("button", "text primary", t("Preview relocation"));
1397
+ cancel.type = go.type = "button";
1398
+ let preview = null;
1399
+ input.oninput = () => { preview = null; info.textContent = ""; go.textContent = t("Preview relocation"); };
1400
+ cancel.onclick = () => closeConfirmAsk();
1401
+ go.onclick = async () => {
1402
+ go.disabled = cancel.disabled = input.disabled = true;
1403
+ go.classList.add("busy");
1404
+ try {
1405
+ const out = await api("sessions/relocate-claude", { from, to: input.value, wsl, token: preview?.token || "" });
1406
+ if (out.backup) {
1407
+ info.textContent = t("Relocation complete. Original files and recovery manifest: {dir}", { dir: out.backup });
1408
+ go.hidden = true;
1409
+ input.disabled = true;
1410
+ cancel.textContent = t("Close");
1411
+ picked.clear();
1412
+ opened.add(out.to);
1413
+ await load();
1414
+ } else {
1415
+ preview = out;
1416
+ info.textContent = t("Move {n} sessions ({size}) from {from} to {to}. Originals are retained outside Claude's project list. Historical message text is unchanged.", {
1417
+ n: out.sessions, size: fmtBytes(out.bytes), from: out.from, to: out.to,
1418
+ });
1419
+ go.textContent = t("Confirm relocation");
1420
+ }
1421
+ } catch (err) {
1422
+ preview = null;
1423
+ info.textContent = err.message;
1424
+ go.textContent = t("Preview relocation");
1425
+ } finally {
1426
+ go.disabled = cancel.disabled = false;
1427
+ input.disabled = go.hidden;
1428
+ go.classList.remove("busy");
1429
+ }
1430
+ };
1431
+ bar.append(el("span", "grow"), cancel, go);
1432
+ ed.append(bar);
1433
+ confirmAsk = ed;
1434
+ openModal(ed);
1435
+ $("#modal").classList.add("lib");
1436
+ input.focus({ preventScroll: true });
1437
+ }
1438
+
1439
+ // askDelete asks in magpie's dialog before the sessions go to its trash;
1440
+ // folder (a cwd, "" for none) when they are every session of one folder
1441
+ function askDelete(ids, folder) {
1442
+ const byID = new Map((data?.sessions || []).map((s) => [s.id, s]));
1443
+ const list = ids.map((id) => byID.get(id)).filter(Boolean);
1444
+ if (!list.length) return;
1445
+ const a = current();
1446
+ const ed = el("div", "editor sm-ask");
1447
+ const h = el("div", "ehead");
1448
+ const whole = folder !== undefined && list.length > 1;
1449
+ // picked folders each whole: how many folders, as well as sessions
1450
+ const cwds = new Set(list.map((s) => groupOf(s)));
1451
+ const folders = !whole && cwds.size > 1 && (data?.sessions || []).every((s) => !cwds.has(groupOf(s)) || ids.includes(s.id)) ? cwds.size : 0;
1452
+ h.append(icon(a.icon), el("b", "", list.length === 1 ? t("Delete this session?")
1453
+ : whole ? t("Delete all {n} sessions in {folder}?", { n: list.length, folder: groupName(folder) })
1454
+ : folders ? t("Delete all {n} sessions in {k} folders?", { n: list.length, k: folders })
1455
+ : t("Delete {n} sessions?", { n: list.length })));
1456
+ ed.append(h);
1457
+ if (folder && folder !== GW) ed.append(el("p", "sub sm-ask-path", folder));
1458
+ const names = el("ul", "sm-ask-list");
1459
+ for (const s of list.slice(0, 5)) names.append(el("li", "", s.title || s.id));
1460
+ if (list.length > 5) names.append(el("li", "more", t("+{n} more", { n: list.length - 5 })));
1461
+ ed.append(names);
1462
+ // a gateway session has no files of its agent's here: say what goes
1463
+ if (list.some((s) => !s.gateway)) ed.append(el("p", "lib-confirm", t("Their files are moved to magpie's trash ({dir}), not erased: Trash puts them back. A session written to in the last minute is left alone, as {agent} may still be running it.", { dir: data.trashDir, agent: a.name })));
1464
+ if (list.some((s) => s.gateway)) ed.append(el("p", "lib-confirm sm-gw-confirm", t("A session seen only through the gateway leaves this list, and any conversation magpie recorded of it is moved to magpie's trash ({dir}): Trash puts it back. {agent}'s own files elsewhere are not touched, and Usage keeps what it spent. One with a request in the last minute is left alone.", { dir: data.trashDir, agent: a.name })));
1465
+ const bar = el("div", "bar");
1466
+ const go = el("button", "text primary danger-fill", t("Delete"));
1467
+ go.type = "button";
1468
+ go.onclick = async (e) => {
1469
+ e.stopPropagation();
1470
+ go.disabled = true;
1471
+ go.classList.add("busy");
1472
+ let out;
1473
+ try {
1474
+ out = await api("sessions/delete", { agent: data.agent, ids: list.map((s) => s.id) });
1475
+ } catch (err) {
1476
+ go.disabled = false;
1477
+ go.classList.remove("busy");
1478
+ status(err.message, "err");
1479
+ return;
1480
+ }
1481
+ closeConfirmAsk();
1482
+ for (const id of out.deleted) { picked.delete(id); if (detail === id) detail = ""; }
1483
+ const active = out.refused.filter((r) => r.active);
1484
+ if (out.refused.length) {
1485
+ const first = out.refused[0], s = byID.get(first.id);
1486
+ status(active.length ? t(active.length === 1 ? "{title} is still being written to; close it in {agent} and try again in a minute" : "{n} sessions are still being written to; close them in {agent} and try again in a minute", { title: s?.title || first.id, n: active.length, agent: a.name })
1487
+ : (s?.title || first.id) + ": " + first.error, "err");
1488
+ } else {
1489
+ status(t(out.deleted.length === 1 ? "Moved to magpie's trash" : "{n} sessions moved to magpie's trash", { n: out.deleted.length }), "ok");
1490
+ }
1491
+ await load();
1492
+ };
1493
+ const cancel = el("button", "text", t("Cancel"));
1494
+ cancel.type = "button";
1495
+ cancel.onclick = (e) => { e.stopPropagation(); closeConfirmAsk(); };
1496
+ bar.append(el("span", "grow"), cancel, go);
1497
+ ed.append(bar);
1498
+ confirmAsk = ed;
1499
+ openModal(ed);
1500
+ $("#modal").classList.add("lib");
1501
+ cancel.focus({ preventScroll: true });
1502
+ }
1503
+
1504
+ // askPurge asks in magpie's dialog before trashed sessions are erased for
1505
+ // good: the ones listed, or the whole trash (all)
1506
+ function askPurge(list, all) {
1507
+ if (!list.length) return;
1508
+ const ed = el("div", "editor sm-ask sm-ask-purge");
1509
+ const h = el("div", "ehead");
1510
+ h.append(svg(TRASH, 15, 1.4), el("b", "", all ? t("Empty magpie's trash?") : t("Delete this session forever?")));
1511
+ ed.append(h);
1512
+ const names = el("ul", "sm-ask-list");
1513
+ for (const x of list.slice(0, 5)) names.append(el("li", "", x.title || x.id));
1514
+ if (list.length > 5) names.append(el("li", "more", t("+{n} more", { n: list.length - 5 })));
1515
+ ed.append(names);
1516
+ ed.append(el("p", "lib-confirm", all ? t("Every session in magpie's trash is erased for good: it can't be restored.") : t("Its files are erased for good: it can't be restored.")));
1517
+ const bar = el("div", "bar");
1518
+ const go = el("button", "text primary danger-fill", all ? t("Empty trash") : t("Delete forever"));
1519
+ go.type = "button";
1520
+ go.onclick = async (e) => {
1521
+ e.stopPropagation();
1522
+ go.disabled = true;
1523
+ go.classList.add("busy");
1524
+ let out;
1525
+ try {
1526
+ out = await api("sessions/purge", all ? { all: true } : { keys: list.map((x) => x.key) });
1527
+ } catch (err) {
1528
+ go.disabled = false;
1529
+ go.classList.remove("busy");
1530
+ status(err.message, "err");
1531
+ return;
1532
+ }
1533
+ closeConfirmAsk();
1534
+ if (out.refused.length) {
1535
+ const first = out.refused[0], x = list.find((y) => y.key === first.key);
1536
+ status((x?.title || x?.id || first.key) + ": " + first.error, "err");
1537
+ } else {
1538
+ status(t(out.purged.length === 1 ? "Erased for good" : "{n} sessions erased for good", { n: out.purged.length }), "ok");
1539
+ }
1540
+ await load();
1541
+ };
1542
+ const cancel = el("button", "text", t("Cancel"));
1543
+ cancel.type = "button";
1544
+ cancel.onclick = (e) => { e.stopPropagation(); closeConfirmAsk(); };
1545
+ bar.append(el("span", "grow"), cancel, go);
1546
+ ed.append(bar);
1547
+ confirmAsk = ed;
1548
+ openModal(ed);
1549
+ $("#modal").classList.add("lib");
1550
+ cancel.focus({ preventScroll: true });
1551
+ }
1552
+
1553
+ function trash() {
1554
+ const out = [];
1555
+ const items = data.trash;
1556
+ if (items.length) {
1557
+ const bar = el("div", "row-head sm-bar sm-trash-bar");
1558
+ const empty = el("button", "text danger sm-empty");
1559
+ empty.type = "button";
1560
+ empty.append(svg(TRASH, 13, 1.4), el("span", "", t("Empty trash")));
1561
+ empty.onclick = () => askPurge(items, true);
1562
+ bar.append(el("span", "note sm-count", t(items.length === 1 ? "{n} session" : "{n} sessions", { n: items.length })), el("span", "grow"), empty);
1563
+ out.push(bar);
1564
+ }
1565
+ if (!items.length) {
1566
+ const e = el("div", "empty-state");
1567
+ e.append(el("b", "", t("Trash is empty")), el("span", "", t("Sessions deleted here wait in magpie's trash, to be restored.")));
1568
+ out.push(e);
1569
+ } else {
1570
+ const l = el("div", "list sm-group sm-trash");
1571
+ for (const x of items) {
1572
+ const r = el("div", "row sess");
1573
+ r.append(icon(x.icon));
1574
+ const who = el("div", "who");
1575
+ who.append(el("div", "name", x.title || x.id));
1576
+ who.append(el("div", "sub", [x.name, x.gateway ? t("Seen through the gateway") : x.cwd ? baseName(x.cwd) : "", t("deleted {when}", { when: ago(x.deleted) }),
1577
+ x.gateway ? (x.size ? t("{size} recorded", { size: fmtBytes(x.size) }) : "") : fmtBytes(x.size)].filter(Boolean).join(" · ")));
1578
+ who.title = (x.items || []).map((i) => i.from).join("\n");
1579
+ r.append(who);
1580
+ const b = el("button", "sess-resume sm-restore");
1581
+ b.type = "button";
1582
+ b.append(svg(UNDO, 12, 1.5), el("span", "", t("Restore")));
1583
+ b.onclick = async (e) => {
1584
+ e.stopPropagation();
1585
+ b.disabled = true;
1586
+ try {
1587
+ await api("sessions/restore", { key: x.key });
1588
+ status(t("{title} restored", { title: x.title || x.id }), "ok");
1589
+ } catch (err) {
1590
+ b.disabled = false;
1591
+ status(err.message, "err");
1592
+ return;
1593
+ }
1594
+ await load();
1595
+ };
1596
+ r.append(b);
1597
+ const del = el("button", "copy sm-del sm-purge");
1598
+ del.type = "button";
1599
+ del.title = t("Delete forever");
1600
+ del.setAttribute("aria-label", t("Delete forever"));
1601
+ del.append(svg(TRASH, 13, 1.4));
1602
+ del.onclick = (e) => { e.stopPropagation(); askPurge([x], false); };
1603
+ r.append(del);
1604
+ l.append(r);
1605
+ }
1606
+ out.push(l);
1607
+ }
1608
+ out.push(el("p", "usage-note", t("Deleted sessions are kept in {dir} until you erase them here; magpie never erases them by itself.", { dir: data.trashDir })));
1609
+ // the recording's promise holds in the trash too
1610
+ if (items.some((x) => x.gateway)) out.push(el("p", "usage-note sm-gw-trash-note", t("Conversations magpie recorded through the gateway still expire after 7 days here, and Delete saved conversations… erases them too.")));
1611
+ return out;
1612
+ }
1613
+
1614
+ ctx.slots.add("page", {
1615
+ key: "sessions",
1616
+ title: "Sessions",
1617
+ mount(main) { page = main; },
1618
+ open: () => load(),
1619
+ relang: () => load(),
1620
+ reveal,
1621
+ });
1622
+ });