synabun 2.0.9 → 2.0.10

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 (252) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/THIRD-PARTY-LICENSES.md +46 -4
  4. package/launcher.mjs +9 -3
  5. package/lib/start-launcher.js +6 -1
  6. package/mcp-server/package-lock.json +2 -2
  7. package/mcp-server/package.json +1 -1
  8. package/neural-interface/i18n/en.json +218 -7
  9. package/neural-interface/i18n/pt-BR.json +220 -9
  10. package/neural-interface/lib/account-usage.js +5 -3
  11. package/neural-interface/lib/app-controls-api.js +161 -0
  12. package/neural-interface/lib/assistant-api.js +21 -7
  13. package/neural-interface/lib/assistant-brains/opencode.js +13 -1
  14. package/neural-interface/lib/assistant-brains/opencode2-browser-policy/index.js +44 -0
  15. package/neural-interface/lib/assistant-brains/opencode2-route-gate/index.js +57 -0
  16. package/neural-interface/lib/assistant-catalog.js +17 -2
  17. package/neural-interface/lib/assistant-dispatch.js +3 -0
  18. package/neural-interface/lib/assistant-envelope.js +19 -1
  19. package/neural-interface/lib/assistant-runtime.js +127 -8
  20. package/neural-interface/lib/claude-model-catalog.js +64 -14
  21. package/neural-interface/lib/codex-settings-schema.js +109 -47
  22. package/neural-interface/lib/editor-completion-opencode.js +132 -35
  23. package/neural-interface/lib/exec-loop-command.js +8 -1
  24. package/neural-interface/lib/launch-cache.js +293 -0
  25. package/neural-interface/lib/login-start.js +524 -0
  26. package/neural-interface/lib/native-loop-providers.js +18 -3
  27. package/neural-interface/lib/native-loop-runtime.js +19 -0
  28. package/neural-interface/lib/opencode-db-schema.js +75 -0
  29. package/neural-interface/lib/opencode-history.js +16 -1
  30. package/neural-interface/lib/opencode-protocol.js +173 -0
  31. package/neural-interface/lib/opencode-provider-view.js +81 -0
  32. package/neural-interface/lib/opencode-runtime-config.js +10 -3
  33. package/neural-interface/lib/opencode-serve-records.js +606 -0
  34. package/neural-interface/lib/opencode-v2-client.js +33 -6
  35. package/neural-interface/lib/opencode-v2-ws-requests.js +19 -5
  36. package/neural-interface/lib/opencode-worker-activity.js +229 -0
  37. package/neural-interface/lib/opencode2-client.js +760 -0
  38. package/neural-interface/lib/opencode2-proxy.js +116 -0
  39. package/neural-interface/lib/opencode2-translate.js +1008 -0
  40. package/neural-interface/lib/process-identity.js +361 -0
  41. package/neural-interface/lib/session-cache-builder.js +26 -5
  42. package/neural-interface/lib/session-title-generator.js +18 -0
  43. package/neural-interface/lib/style-guide/api.js +4 -3
  44. package/neural-interface/lib/untrusted-file-headers.js +27 -0
  45. package/neural-interface/package-lock.json +1343 -45
  46. package/neural-interface/package.json +5 -1
  47. package/neural-interface/public/index.html +2 -2
  48. package/neural-interface/public/shared/agent-runtime-options.js +55 -11
  49. package/neural-interface/public/shared/assistant/asst-agents-dock.js +172 -17
  50. package/neural-interface/public/shared/assistant/asst-api.js +11 -3
  51. package/neural-interface/public/shared/assistant/asst-brain-picker.js +195 -80
  52. package/neural-interface/public/shared/assistant/asst-composer.js +1 -1
  53. package/neural-interface/public/shared/assistant/asst-hosts.js +49 -2
  54. package/neural-interface/public/shared/assistant/asst-menu.js +77 -6
  55. package/neural-interface/public/shared/assistant/asst-panel.js +197 -33
  56. package/neural-interface/public/shared/assistant/asst-rack-styles.js +28 -10
  57. package/neural-interface/public/shared/assistant/asst-redact.js +186 -0
  58. package/neural-interface/public/shared/assistant/asst-render.js +382 -36
  59. package/neural-interface/public/shared/assistant/asst-run-stage.js +231 -0
  60. package/neural-interface/public/shared/assistant/asst-sanitize.js +193 -3
  61. package/neural-interface/public/shared/assistant/asst-scene-styles.js +273 -0
  62. package/neural-interface/public/shared/assistant/asst-scenes.js +528 -0
  63. package/neural-interface/public/shared/assistant/asst-sidepanel-styles.js +3 -15
  64. package/neural-interface/public/shared/assistant/asst-state.js +14 -0
  65. package/neural-interface/public/shared/assistant/asst-styles.js +43 -0
  66. package/neural-interface/public/shared/assistant/asst-tool-kinds.js +13 -1
  67. package/neural-interface/public/shared/cc-dropdown.js +480 -0
  68. package/neural-interface/public/shared/cdx/cdx-panel.js +13 -1
  69. package/neural-interface/public/shared/cdx/cdx-render.js +15 -3
  70. package/neural-interface/public/shared/cdx/cdx-styles.js +4 -0
  71. package/neural-interface/public/shared/cdx/cdx-tabs.js +149 -35
  72. package/neural-interface/public/shared/cp/cp-markdown.js +4 -57
  73. package/neural-interface/public/shared/data-cache.js +223 -0
  74. package/neural-interface/public/shared/desktop-bridge.js +11 -0
  75. package/neural-interface/public/shared/desktop-titlebar-view.js +78 -1
  76. package/neural-interface/public/shared/desktop-titlebar.js +214 -28
  77. package/neural-interface/public/shared/desktop-update-view.js +15 -5
  78. package/neural-interface/public/shared/file-links.js +73 -8
  79. package/neural-interface/public/shared/html-shell.js +74 -80
  80. package/neural-interface/public/shared/launch-prefetch.js +59 -0
  81. package/neural-interface/public/shared/ocp-v2/ocp-v2-caps.js +3 -2
  82. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +7 -2
  83. package/neural-interface/public/shared/ocp-v2/ocp-v2-modelpicker.js +54 -17
  84. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +93 -2
  85. package/neural-interface/public/shared/ocp-v2/ocp-v2-projectbar.js +3 -2
  86. package/neural-interface/public/shared/ocp-v2/ocp-v2-providers.js +11 -9
  87. package/neural-interface/public/shared/ocp-v2/ocp-v2-rehydrate.js +15 -4
  88. package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +1 -1
  89. package/neural-interface/public/shared/ocp-v2/ocp-v2-send-logic.js +401 -0
  90. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +16 -9
  91. package/neural-interface/public/shared/ocp-v2/ocp-v2-sessions-logic.js +60 -0
  92. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +48 -19
  93. package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +195 -19
  94. package/neural-interface/public/shared/opencode-providers.js +39 -0
  95. package/neural-interface/public/shared/schedules-view.js +318 -0
  96. package/neural-interface/public/shared/settings/settings-app-pane.js +335 -0
  97. package/neural-interface/public/shared/settings/settings-app.js +243 -0
  98. package/neural-interface/public/shared/settings/settings-data.js +88 -0
  99. package/neural-interface/public/shared/settings/settings-ia.js +17 -0
  100. package/neural-interface/public/shared/sidepanel-session-status.js +49 -0
  101. package/neural-interface/public/shared/styles.css +540 -371
  102. package/neural-interface/public/shared/ui-assistant.js +27 -5
  103. package/neural-interface/public/shared/ui-automation-studio.js +67 -27
  104. package/neural-interface/public/shared/ui-bookmarks.js +2 -1
  105. package/neural-interface/public/shared/ui-claude-panel.js +76 -8
  106. package/neural-interface/public/shared/ui-codex-panel.js +1 -0
  107. package/neural-interface/public/shared/ui-detail.js +6 -6
  108. package/neural-interface/public/shared/ui-file-links.js +1 -0
  109. package/neural-interface/public/shared/ui-image-gallery.js +6 -5
  110. package/neural-interface/public/shared/ui-invite.js +18 -2
  111. package/neural-interface/public/shared/ui-link.js +9 -9
  112. package/neural-interface/public/shared/ui-menubar.js +232 -33
  113. package/neural-interface/public/shared/ui-native-loop-router.js +2 -0
  114. package/neural-interface/public/shared/ui-opencode-panel-v2.js +1 -0
  115. package/neural-interface/public/shared/ui-schedules-studio.js +2099 -869
  116. package/neural-interface/public/shared/ui-sessions.js +15 -4
  117. package/neural-interface/public/shared/ui-settings.js +87 -36
  118. package/neural-interface/public/shared/ui-sidebar.js +4 -4
  119. package/neural-interface/public/shared/ui-sidepanel-session-entry.js +13 -0
  120. package/neural-interface/public/shared/ui-sidepanel-sessions.js +55 -4
  121. package/neural-interface/public/shared/ui-sidepanel-tray.js +84 -8
  122. package/neural-interface/public/shared/ui-skills.js +70 -12
  123. package/neural-interface/public/shared/ui-sync.js +7 -0
  124. package/neural-interface/public/shared/ui-terminal.js +36 -20
  125. package/neural-interface/public/shared/ui-trash.js +8 -1
  126. package/neural-interface/public/shared/ui-tutorial-steps.js +7 -3
  127. package/neural-interface/public/shared/ui-tutorial.js +15 -5
  128. package/neural-interface/public/shared/ui-update.js +83 -14
  129. package/neural-interface/public/shared/ui-whiteboard.js +2 -1
  130. package/neural-interface/public/variant/2d/main.js +4 -1
  131. package/neural-interface/public/variant/3d/main.js +3 -0
  132. package/neural-interface/public/vendor/highlight/LICENSE +29 -0
  133. package/neural-interface/public/vendor/highlight/github-dark-dimmed-11.12.0.min.css +9 -0
  134. package/neural-interface/public/vendor/highlight/highlight-11.12.0.min.js +1262 -0
  135. package/neural-interface/public/vendor/highlight/manifest.json +5 -0
  136. package/neural-interface/public/vendor/html2canvas/LICENSE +22 -0
  137. package/neural-interface/public/vendor/html2canvas/html2canvas-1.4.1.min.js +20 -0
  138. package/neural-interface/public/vendor/html2canvas/manifest.json +4 -0
  139. package/neural-interface/public/vendor/marked/LICENSE.md +44 -0
  140. package/neural-interface/public/vendor/marked/manifest.json +4 -0
  141. package/neural-interface/public/vendor/marked/marked-14.1.4.esm.js +2502 -0
  142. package/neural-interface/public/vendor/mermaid/LICENSE +21 -0
  143. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/abnfDiagram-PAR5IXJI.mjs +1 -0
  144. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/architecture-7GRP2DOG-M7G3SWCO.mjs +1 -0
  145. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/architectureDiagram-2PNWDENW.mjs +36 -0
  146. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/blockDiagram-PUWGUBOA.mjs +129 -0
  147. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/c4Diagram-3NLNHZP5.mjs +38 -0
  148. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-2NMSUPJH.mjs +1 -0
  149. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-32MCUW2P.mjs +206 -0
  150. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-3NPNU5GP.mjs +1 -0
  151. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-3SMGFRGC.mjs +1 -0
  152. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-4CAJECRU.mjs +14 -0
  153. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-4OADUZ5Q.mjs +10 -0
  154. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-5IMINLNL.mjs +1 -0
  155. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-5IQCK5RY.mjs +3 -0
  156. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-5MVFYWFU.mjs +1 -0
  157. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-AQ6EADP3.mjs +1 -0
  158. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-AZZRMDJM.mjs +15 -0
  159. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-CLS4B6BI.mjs +168 -0
  160. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-DZP67EKU.mjs +70 -0
  161. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-EICP7K5X.mjs +1 -0
  162. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-GDY2UAB4.mjs +1 -0
  163. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-H7VHZCWX.mjs +1 -0
  164. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-IJBDOHL6.mjs +1 -0
  165. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-JFFVMJIA.mjs +1 -0
  166. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-JQRUD6KW.mjs +1 -0
  167. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-JZGDI5JK.mjs +1 -0
  168. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-KD6SPIPN.mjs +2 -0
  169. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-NF366YI6.mjs +231 -0
  170. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-NUDPKBEF.mjs +32 -0
  171. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-OJINMHTS.mjs +1 -0
  172. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-Q6NGPIAH.mjs +2 -0
  173. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-QJSWEUOL.mjs +132 -0
  174. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-R7JDMUKV.mjs +1 -0
  175. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-RCXAM2GK.mjs +1 -0
  176. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-RT4MAWR3.mjs +1 -0
  177. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-SFMINX4W.mjs +1 -0
  178. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-SLDQ463B.mjs +88 -0
  179. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-STOV2HOB.mjs +1 -0
  180. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-T5OCTHI4.mjs +1 -0
  181. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-TCEHX7R3.mjs +1 -0
  182. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VQC2EELJ.mjs +1 -0
  183. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VRZKL3EH.mjs +1 -0
  184. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VTHDQ7BV.mjs +1 -0
  185. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VY2OSTJR.mjs +2 -0
  186. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-WUBWJARI.mjs +1 -0
  187. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-YBMQ5JGJ.mjs +2 -0
  188. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-YDMLL4PJ.mjs +1 -0
  189. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-Z6V44ARX.mjs +62 -0
  190. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZJJ7VQJP.mjs +339 -0
  191. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZNLK7HRR.mjs +1 -0
  192. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZQN7UHIL.mjs +1 -0
  193. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZS6VBONO.mjs +174 -0
  194. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZUNWM646.mjs +1 -0
  195. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/classDiagram-FGAMPOII.mjs +1 -0
  196. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/classDiagram-v2-AI5RJEK4.mjs +1 -0
  197. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/cose-bilkent-Q72C2OJ3.mjs +1 -0
  198. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/cynefin-OW5HDTMX-6OCTWOGG.mjs +1 -0
  199. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/cynefinDiagram-2OOWUY4M.mjs +62 -0
  200. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/dagre-MPVFI544.mjs +4 -0
  201. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-AE4NWXFW.mjs +24 -0
  202. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-BUJID5UG.mjs +41 -0
  203. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-C44CZ5NI.mjs +24 -0
  204. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-EYOLEXXS.mjs +30 -0
  205. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-P7B6PWOJ.mjs +3 -0
  206. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/ebnfDiagram-FTPZ3PV6.mjs +1 -0
  207. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/erDiagram-H3ERR26V.mjs +99 -0
  208. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/eventmodeling-NTZA5JFV-WKSWQHFS.mjs +1 -0
  209. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/flowDiagram-YHGXBVSY.mjs +1 -0
  210. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/ganttDiagram-RWFTB7ED.mjs +292 -0
  211. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/gitGraph-4MIJSDKK-JH2HKYUO.mjs +1 -0
  212. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/gitGraphDiagram-MAR6GKOP.mjs +106 -0
  213. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/info-A6RAGUB7-3TX3OFWW.mjs +1 -0
  214. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/infoDiagram-LV547AFH.mjs +2 -0
  215. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/ishikawaDiagram-PUJQGNDS.mjs +70 -0
  216. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/journeyDiagram-EW5QJT4W.mjs +139 -0
  217. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/kanban-definition-JSQERAZB.mjs +89 -0
  218. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/katex-CZ4GXH2S.mjs +257 -0
  219. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/mindmap-definition-VBJCLQLM.mjs +96 -0
  220. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/packet-AYTQ26CC-MKII6P3Y.mjs +1 -0
  221. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/pegDiagram-7WILOQTJ.mjs +1 -0
  222. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/pie-WAS4IAKB-6B4OPMJ5.mjs +1 -0
  223. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/pieDiagram-EFA7CBSB.mjs +39 -0
  224. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/quadrantDiagram-WARVYA7P.mjs +7 -0
  225. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/radar-RG4KPBEZ-UPTR3XUL.mjs +1 -0
  226. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-74A4TZTK-MEFUPFMS.mjs +1 -0
  227. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-abnf-HS5TGJTU-R5YG2S5D.mjs +1 -0
  228. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-ebnf-LZEXJU2U-G5QEX2L6.mjs +1 -0
  229. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-peg-WCYAUIDC-H5I2ZRY2.mjs +1 -0
  230. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroadDiagram-5NIQVNLQ.mjs +1 -0
  231. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/requirementDiagram-SXREGOKZ.mjs +84 -0
  232. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/sankeyDiagram-VV74M2VP.mjs +40 -0
  233. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/sequenceDiagram-52ZFIFDL.mjs +162 -0
  234. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/sizeCapture-FGA5AGYP.mjs +1 -0
  235. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/stateDiagram-3QAKPIVI.mjs +1 -0
  236. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/stateDiagram-v2-HN2P4RDZ.mjs +1 -0
  237. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/swimlanes-QLVP42QD.mjs +1 -0
  238. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/swimlanesDiagram-JKPDBFCN.mjs +8 -0
  239. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/timeline-definition-GS45XAXW.mjs +120 -0
  240. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/treeView-Q6P3EWNA-ZRJGANNQ.mjs +1 -0
  241. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/treemap-WGGIJYW6-K64VN5AS.mjs +1 -0
  242. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/vennDiagram-QIQU2ZRJ.mjs +34 -0
  243. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/wardley-WFR3VGLG-D67OWCCH.mjs +1 -0
  244. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/wardleyDiagram-U6YNM66N.mjs +78 -0
  245. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/xychartDiagram-XJAZEDNM.mjs +7 -0
  246. package/neural-interface/public/vendor/mermaid/manifest.json +4 -0
  247. package/neural-interface/public/vendor/mermaid/mermaid-11.17.2.esm.min.mjs +14 -0
  248. package/neural-interface/scripts/build-vendor.mjs +84 -3
  249. package/neural-interface/server.js +1099 -286
  250. package/npm-shrinkwrap.json +2 -2
  251. package/package.json +1 -1
  252. package/skills/synabun/modules/schedule.md +1 -1
@@ -1,8 +1,13 @@
1
- // SynaBun Neural Interface - Dedicated Schedules Studio
1
+ // SynaBun Neural Interface — the Schedules window.
2
+ // A rail of views (Up next, All, Needs attention, Paused, Timers, Activity) and of groups, one
3
+ // list of schedules with their details on demand, and the editors in place of the list. What a
4
+ // view lists and how a schedule's state reads is decided in schedules-view.js (DOM-free).
2
5
 
3
6
  import { emit, on } from './state.js';
4
7
  import { storage } from './storage.js';
8
+ import { KEYS } from './constants.js';
5
9
  import { isGuest, hasPermission, showGuestToast } from './ui-sync.js';
10
+ import { registerLaunchPrefetch } from './launch-prefetch.js';
6
11
  import { sendToPanel } from './ui-claude-panel.js';
7
12
  import { getProviderMeta } from './provider-icons.js';
8
13
  import {
@@ -33,6 +38,7 @@ import {
33
38
  import {
34
39
  fetchLoopTemplates,
35
40
  fetchSchedules,
41
+ fetchSchedule,
36
42
  createSchedule,
37
43
  updateSchedule,
38
44
  deleteSchedule,
@@ -50,26 +56,77 @@ import {
50
56
  cancelQuickTimer,
51
57
  triggerQuickTimerNow,
52
58
  } from './api.js';
59
+ import {
60
+ LIST_SCOPES,
61
+ SORTS,
62
+ VIEW_SCOPES,
63
+ dayLabel,
64
+ describeCron,
65
+ formatAgo,
66
+ formatCountdown,
67
+ groupIdOf,
68
+ groupSummary,
69
+ inScope,
70
+ lastRunState,
71
+ launchFacts,
72
+ listJoin,
73
+ matchesQuery,
74
+ oneLine,
75
+ scopeCounts,
76
+ shortWhen,
77
+ sortSchedules,
78
+ whenLabel,
79
+ } from './schedules-view.js';
80
+ import {
81
+ closeDropdowns,
82
+ controlOf,
83
+ dropdownHTML,
84
+ setDropdownItems,
85
+ setDropdownValue,
86
+ wireDropdowns,
87
+ } from './cc-dropdown.js';
53
88
 
54
89
  const $ = (id) => document.getElementById(id);
55
90
  const PANEL_ID = 'schedules-studio-panel';
56
91
  const POLL_INTERVAL = 1000;
92
+ const LAYOUT_KEY = KEYS.PANEL_PREFIX + 'schedules-window';
93
+ const SCOPE_KEY = 'sched-view-scope';
94
+ const SORT_KEY = 'sched-view-sort';
95
+ const COLLAPSED_KEY = 'sched-collapsed-groups';
96
+ const DEFAULT_GROUP_COLOR = '#4fc3f7';
57
97
 
58
98
  let _panel = null;
59
99
  let _backdrop = null;
60
- let _docListeners = [];
100
+ let _abort = null; // the window's document/window listeners
101
+ let _opener = null; // focus goes back here when the window closes
61
102
  let _templates = [];
62
103
  let _schedules = [];
63
104
  let _groups = [];
64
105
  let _scheduleTimerData = {};
65
106
  let _quickTimers = [];
66
- let _tab = 'cron';
67
- let _selectedGroup = 'all';
68
- let _scheduleEditor = null;
69
- let _groupEditorId = null;
107
+ let _loading = false; // the window waits for a load (opening it, Try again)
108
+ let _pendingAction = null; // an editor asked for before the data is there opens once it is
109
+ let _loadError = null; // what the latest load could not fetch, as a sentence
110
+ let _loadSeq = 0;
111
+ const _loaded = { schedules: false, templates: false, groups: false }; // each list arrived once
112
+ let _timersSeq = 0;
113
+ let _timersLoaded = false;
114
+ let _timersError = null;
115
+ let _cliReport = null; // what a group's change of CLI did to its schedules (resetSchedulesForCli)
116
+ let _frame = null; // what the main area is built for: list, timers, activity, editor
117
+ let _scope = 'all';
118
+ let _sort = 'next';
119
+ let _query = '';
120
+ const _expanded = new Set();
121
+ const _collapsed = new Set();
122
+ let _scheduleEditor = null; // the draft while the schedule editor is open
123
+ let _groupEditorId = null; // the group being edited; null with a draft = a new group
70
124
  let _groupEditorDraft = null;
125
+ let _confirm = null; // { kind: 'schedule' | 'group', id, opener }
71
126
  let _draggingScheduleId = null;
72
127
  let _pollTimer = null;
128
+ let _refreshTimer = null;
129
+ let _lastAutoRefresh = 0;
73
130
  let _selectedQtMinutes = null;
74
131
  let _qtUsesBrowser = null;
75
132
  let _syncReady = false;
@@ -91,51 +148,85 @@ let _launchMcpProfile = storage.getItem('as-launch-mcp-profile') || 'full';
91
148
  let _launchAccount = storage.getItem('as-launch-account') || 'default'; // Codex ChatGPT account (CODEX_HOME)
92
149
  let _codexAccountsRequested = false; // lazy-fetch guard for account selects
93
150
 
94
- const _s = (d) => `<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
151
+ const _s = (d) => `<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${d}</svg>`;
95
152
 
96
153
  const ICONS = {
97
154
  clock: _s('<circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 2.5"/>'),
155
+ next: _s('<path d="M2.5 8h7.5"/><path d="M7 4.5 10.5 8 7 11.5"/><path d="M13.5 3.5v9"/>'),
98
156
  plus: _s('<path d="M8 3v10M3 8h10"/>'),
99
157
  bolt: _s('<path d="M9 2L4 9h4l-1 5 5-7H8l1-5z"/>'),
100
158
  edit: _s('<path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z"/>'),
101
159
  script: _s('<path d="M3.5 2h6.5l3 3v8.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5z"/><path d="M5.5 7h5M5.5 9.5h5M5.5 12h3"/>'),
102
160
  close: _s('<path d="M4 4l8 8M12 4l-8 8"/>'),
103
- pause: _s('<path d="M5 3v10M11 3v10"/>'),
161
+ pause: _s('<path d="M5.5 3.5v9M10.5 3.5v9"/>'),
104
162
  play: _s('<path d="M5 3l8 5-8 5V3z"/>'),
105
163
  back: _s('<path d="M10 3L5 8l5 5"/>'),
106
- down: _s('<path d="M3 6l5 5 5-5"/>'),
164
+ chevron: _s('<path d="M6 3.5 10.5 8 6 12.5"/>'),
165
+ up: _s('<path d="M8 12.5v-9M4.5 7 8 3.5 11.5 7"/>'),
166
+ down: _s('<path d="M8 3.5v9M4.5 9 8 12.5 11.5 9"/>'),
107
167
  folder: _s('<path d="M2.5 5h4l1 1.5h6v6a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z"/>'),
108
- list: _s('<path d="M5 4h8M5 8h8M5 12h8"/><path d="M2.5 4h.01M2.5 8h.01M2.5 12h.01"/>'),
168
+ list: _s('<path d="M5.5 4h8M5.5 8h8M5.5 12h8"/><path d="M2.5 4h.01M2.5 8h.01M2.5 12h.01"/>'),
169
+ alert: _s('<path d="M8 2.3 14.2 13H1.8L8 2.3z"/><path d="M8 6.5v3"/><path d="M8 11.4h.01"/>'),
170
+ timer: _s('<circle cx="8" cy="9" r="5"/><path d="M8 6.5V9l1.6 1"/><path d="M6.5 2h3"/>'),
171
+ activity: _s('<path d="M1.5 8h3l2-4.5 3 9 2-4.5h3"/>'),
172
+ search: _s('<circle cx="7" cy="7" r="4.5"/><path d="m10.5 10.5 3.5 3.5"/>'),
173
+ trash: _s('<path d="M2.5 4.5h11M6.5 4.5V3h3v1.5M4 4.5l.7 9h6.6l.7-9"/>'),
174
+ move: _s('<path d="M8 2v12M2 8h12"/><path d="M6 4 8 2l2 2M6 12l2 2 2-2M4 6 2 8l2 2M12 6l2 2-2 2"/>'),
109
175
  eye: _s('<path d="M1.5 8s2.5-4 6.5-4 6.5 4 6.5 4-2.5 4-6.5 4-6.5-4-6.5-4z"/><circle cx="8" cy="8" r="2"/>'),
110
176
  sparkle: _s('<path d="M8 2L9.2 6.8L14 8L9.2 9.2L8 14L6.8 9.2L2 8L6.8 6.8L8 2z"/>'),
111
177
  lock: _s('<rect x="4" y="7" width="8" height="7" rx="1"/><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2"/>'),
178
+ ok: _s('<circle cx="8" cy="8" r="6"/><path d="M5.5 8.2 7.2 9.9 10.6 6.3"/>'),
179
+ failed: _s('<circle cx="8" cy="8" r="6"/><path d="M5.9 5.9l4.2 4.2M10.1 5.9l-4.2 4.2"/>'),
180
+ started: _s('<circle cx="8" cy="8" r="6"/><path d="M6.9 5.7 10.3 8l-3.4 2.3z"/>'),
181
+ stopped: _s('<circle cx="8" cy="8" r="6"/><rect x="6" y="6" width="4" height="4" rx=".5"/>'),
182
+ never: _s('<circle cx="8" cy="8" r="6" stroke-dasharray="2.2 2.2"/>'),
183
+ };
184
+ const RESULT_ICON = { ok: ICONS.ok, failed: ICONS.failed, started: ICONS.started, stopped: ICONS.stopped, interrupted: ICONS.alert, never: ICONS.never, other: ICONS.never };
185
+
186
+ const VIEWS = {
187
+ upcoming: { label: 'Up next', icon: ICONS.next },
188
+ all: { label: 'All schedules', icon: ICONS.list },
189
+ attention: { label: 'Needs attention', icon: ICONS.alert },
190
+ paused: { label: 'Paused', icon: ICONS.pause },
191
+ timers: { label: 'Timers', icon: ICONS.timer },
192
+ activity: { label: 'Activity', icon: ICONS.activity },
112
193
  };
113
194
 
114
195
  const CRON_PRESET_GROUPS = [
115
196
  { group: 'Frequency', presets: [
116
- { label: 'Every 15 Min', cron: '*/15 * * * *', desc: 'Every 15 minutes' },
117
- { label: 'Every 30 Min', cron: '*/30 * * * *', desc: 'Every 30 minutes' },
118
- { label: 'Every Hour', cron: '0 * * * *', desc: 'Every hour at :00' },
119
- { label: 'Every 2 Hours', cron: '0 */2 * * *', desc: 'Every 2 hours at :00' },
120
- { label: 'Every 6 Hours', cron: '0 */6 * * *', desc: 'Every 6 hours at :00' },
197
+ { label: 'Every 15 min', cron: '*/15 * * * *' },
198
+ { label: 'Every 30 min', cron: '*/30 * * * *' },
199
+ { label: 'Every hour', cron: '0 * * * *' },
200
+ { label: 'Every 2 hours', cron: '0 */2 * * *' },
201
+ { label: 'Every 6 hours', cron: '0 */6 * * *' },
121
202
  ] },
122
203
  { group: 'Daily', presets: [
123
- { label: 'Morning 9am', cron: '0 9 * * *', desc: 'Every day at 09:00' },
124
- { label: 'Midday 12pm', cron: '0 12 * * *', desc: 'Every day at 12:00' },
125
- { label: 'Evening 6pm', cron: '0 18 * * *', desc: 'Every day at 18:00' },
126
- { label: '2x Daily', cron: '0 10,18 * * *', desc: 'Every day at 10:00 and 18:00' },
127
- { label: '3x Daily', cron: '0 9,14,19 * * *', desc: 'Every day at 09:00, 14:00, 19:00' },
204
+ { label: '09:00', cron: '0 9 * * *' },
205
+ { label: '12:00', cron: '0 12 * * *' },
206
+ { label: '18:00', cron: '0 18 * * *' },
207
+ { label: 'Twice a day', cron: '0 10,18 * * *' },
208
+ { label: 'Three times a day', cron: '0 9,14,19 * * *' },
128
209
  ] },
129
210
  { group: 'Weekly', presets: [
130
- { label: 'Weekdays 9am', cron: '0 9 * * 1-5', desc: 'Mon-Fri at 09:00' },
131
- { label: 'Weekdays 2x', cron: '0 10,18 * * 1-5', desc: 'Mon-Fri at 10:00 and 18:00' },
132
- { label: 'Weekends 10am', cron: '0 10 * * 0,6', desc: 'Sat-Sun at 10:00' },
133
- { label: 'Mon/Wed/Fri', cron: '0 9 * * 1,3,5', desc: 'Mon, Wed, Fri at 09:00' },
134
- { label: 'Tue/Thu', cron: '0 9 * * 2,4', desc: 'Tue, Thu at 09:00' },
211
+ { label: 'Weekdays 09:00', cron: '0 9 * * 1-5' },
212
+ { label: 'Weekdays twice', cron: '0 10,18 * * 1-5' },
213
+ { label: 'Weekends 10:00', cron: '0 10 * * 0,6' },
214
+ { label: 'Mon, Wed, Fri', cron: '0 9 * * 1,3,5' },
215
+ { label: 'Tue, Thu', cron: '0 9 * * 2,4' },
135
216
  ] },
136
217
  ];
137
218
 
138
219
  const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
220
+ const DAY_LONG = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
221
+
222
+ function readJson(key, fallback) {
223
+ try { const value = JSON.parse(storage.getItem(key) || 'null'); return value ?? fallback; } catch { return fallback; }
224
+ }
225
+
226
+ function readScope() {
227
+ const saved = storage.getItem(SCOPE_KEY);
228
+ return saved && (VIEW_SCOPES.includes(saved) || saved === 'ungrouped' || saved.startsWith('group:')) ? saved : 'all';
229
+ }
139
230
 
140
231
  function esc(value) {
141
232
  return String(value ?? '').replace(/[&<>"']/g, (c) => ({
@@ -143,11 +234,19 @@ function esc(value) {
143
234
  }[c]));
144
235
  }
145
236
 
237
+ const domId = (value) => String(value ?? '').replace(/[^\w-]/g, '_');
238
+ // A choice of a popup (cc-dropdown.js). The one shown is the last marked `selected`, else the
239
+ // first, as in a <select>: a saved value a list lacks is added as a choice of its own, marked.
240
+ const opt = (value, label, selected = false) => ({ value: value ?? '', label: String(label ?? ''), selected: !!selected });
241
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
242
+ const cssColor = (value) => (value && CSS.supports('color', String(value)) ? String(value) : 'var(--stg-off)');
243
+
146
244
  function showToast(msg) {
147
245
  const existing = document.querySelector('.as-toast');
148
246
  if (existing) existing.remove();
149
247
  const toast = document.createElement('div');
150
248
  toast.className = 'as-toast';
249
+ toast.setAttribute('role', 'status');
151
250
  toast.textContent = msg;
152
251
  document.body.appendChild(toast);
153
252
  requestAnimationFrame(() => toast.classList.add('show'));
@@ -165,46 +264,21 @@ function getGroup(id) {
165
264
  return _groups.find(g => g.id === id) || null;
166
265
  }
167
266
 
168
- function describeCronClient(cronStr) {
169
- const fields = String(cronStr || '').trim().split(/\s+/);
170
- if (fields.length !== 5) return cronStr || '';
171
- const [minute, hour, , , dayOfWeek] = fields;
172
- const pad = (n) => String(n).padStart(2, '0');
173
- let dayPart = '';
174
- if (dayOfWeek === '*') dayPart = 'Every day';
175
- else if (dayOfWeek === '1-5') dayPart = 'Weekdays';
176
- else if (dayOfWeek === '0,6') dayPart = 'Weekends';
177
- else dayPart = dayOfWeek.split(',').map(d => DAY_NAMES[+d] || d).join(', ');
178
-
179
- const minStep = minute.match(/^\*\/(\d+)$/);
180
- if (minStep && hour === '*') return `${dayPart}, every ${minStep[1]} minutes`;
181
- const hourStep = hour.match(/^\*\/(\d+)$/);
182
- if (hourStep) return `${dayPart}, every ${hourStep[1]} hours at :${pad(+minute)}`;
183
- if (hour.includes(',')) return `${dayPart} at ${hour.split(',').map(h => `${pad(+h)}:${pad(+minute)}`).join(', ')}`;
184
- if (hour === '*') return `${dayPart}, every hour at :${pad(+minute)}`;
185
- return `${dayPart} at ${pad(+hour)}:${pad(+minute)}`;
186
- }
187
-
188
- function formatNextRun(nextRun) {
189
- if (!nextRun) return 'N/A';
190
- const diffMs = new Date(nextRun) - Date.now();
191
- if (diffMs <= 0) return 'Overdue';
192
- const totalSec = Math.floor(diffMs / 1000);
193
- const days = Math.floor(totalSec / 86400);
194
- const hours = Math.floor((totalSec % 86400) / 3600);
195
- const minutes = Math.floor((totalSec % 3600) / 60);
196
- const seconds = totalSec % 60;
197
- if (days > 0) return hours ? `${days}d ${hours}h` : `${days}d`;
198
- if (hours > 0) return minutes ? `${hours}h ${minutes}m` : `${hours}h`;
199
- if (minutes > 0) return seconds ? `${minutes}m ${seconds}s` : `${minutes}m`;
200
- return `${seconds}s`;
201
- }
202
-
203
- function formatDateTime(value) {
204
- if (!value) return 'Never';
205
- try { return new Date(value).toLocaleString(); } catch { return value; }
267
+ function orderedGroups() {
268
+ return _groups.map((group, index) => ({ group, index }))
269
+ .sort((a, b) => ((a.group.order ?? a.index) - (b.group.order ?? b.index)) || (a.index - b.index))
270
+ .map(entry => entry.group);
206
271
  }
207
272
 
273
+ const groupIds = () => new Set(_groups.map(g => g.id));
274
+ const editorOpen = () => !!_scheduleEditor || !!_groupEditorDraft;
275
+ // Until the schedules, the automations and the groups have each loaded once, the list cannot say
276
+ // what is where and an editor could only offer less than was saved: neither is shown.
277
+ const dataIncomplete = () => !_loaded.schedules || !_loaded.templates || !_loaded.groups;
278
+ const editorsWait = () => _loading || dataIncomplete();
279
+ const countsKnown = () => !_loading && !dataIncomplete();
280
+ const listScope = () => LIST_SCOPES.includes(_scope) || _scope === 'ungrouped' || !!groupIdOf(_scope);
281
+
208
282
  function formatTimerCountdown(firesAtISO) {
209
283
  const diff = new Date(firesAtISO) - Date.now();
210
284
  if (diff <= 0) return 'firing';
@@ -217,16 +291,14 @@ function formatTimerCountdown(firesAtISO) {
217
291
  return `${mins}m`;
218
292
  }
219
293
 
220
- function scheduleStatusBadge(schedule) {
221
- if (!schedule.enabled) return '<span class="sched-badge sched-badge--paused">Paused</span>';
222
- if (schedule.lastRunResult === 'template_missing') return '<span class="sched-badge sched-badge--error">Missing Template</span>';
223
- if (schedule.lastRunResult?.startsWith('error:')) return '<span class="sched-badge sched-badge--error">Error</span>';
224
- return '<span class="sched-badge sched-badge--ok">Active</span>';
225
- }
294
+ // ── Opening and closing ──
226
295
 
227
296
  export function initSchedulesStudio() {
297
+ // Launch: the lists are read once the page has painted (launch-prefetch.js), so the first open paints from them.
298
+ registerLaunchPrefetch('schedules', () => (isGuest() && !hasPermission('automations') ? undefined : loadData()), { order: 74 });
228
299
  on('automations:open-schedules', (opts) => openPanel(opts || {}));
229
300
  on('schedules:open', (opts) => openPanel(opts || {}));
301
+ on('schedules:toggle', () => (_panel ? closePanel() : openPanel({})));
230
302
  setupSync();
231
303
  }
232
304
 
@@ -236,130 +308,286 @@ async function openPanel(opts = {}) {
236
308
  return;
237
309
  }
238
310
  if (_panel) {
239
- applyOpenOptions(opts);
240
- _panel.focus();
311
+ // Rebuilt only when the request changes what shows: an open editor keeps what is typed in it.
312
+ if (applyOpenOptions(opts)) render({ frame: true });
313
+ focusInitial();
241
314
  return;
242
315
  }
243
316
 
317
+ _opener = document.activeElement;
318
+ // The view, order and folded groups the user left (UI state, synced like the rest).
319
+ _scope = readScope();
320
+ _sort = SORTS.includes(storage.getItem(SORT_KEY)) ? storage.getItem(SORT_KEY) : 'next';
321
+ _collapsed.clear();
322
+ for (const key of readJson(COLLAPSED_KEY, [])) if (typeof key === 'string') _collapsed.add(key);
244
323
  _backdrop = document.createElement('div');
245
- _backdrop.className = 'studio-backdrop';
324
+ _backdrop.className = 'studio-backdrop sched-backdrop';
246
325
  document.body.appendChild(_backdrop);
247
326
 
248
- _panel = document.createElement('div');
327
+ _panel = document.createElement('section');
249
328
  _panel.className = 'schedules-studio-panel glass resizable';
250
329
  _panel.id = PANEL_ID;
330
+ _panel.setAttribute('role', 'region');
331
+ _panel.setAttribute('aria-labelledby', 'sched-title');
251
332
  _panel.innerHTML = buildPanelHTML();
252
333
  document.body.appendChild(_panel);
253
- _panel.style.left = Math.max(20, (window.innerWidth - 980) / 2) + 'px';
254
- _panel.style.top = Math.max(48, (window.innerHeight - 620) / 2) + 'px';
334
+ document.body.classList.add('schedules-window-open');
335
+ placePanel();
255
336
 
256
337
  wirePanel();
257
- await loadData();
338
+ // Before the options: an editor asked for waits for the data (applyOpenOptions). Lists that arrived once (an
339
+ // earlier open, the launch read; sync keeps them while closed) paint at once, and the load below brings them
340
+ // up to date in place (loadFinished → refresh); only a first open shows "Loading schedules…".
341
+ _loading = dataIncomplete();
258
342
  applyOpenOptions(opts);
259
- render();
343
+ render({ frame: true });
344
+ if (!_loading) revealExpanded(); // what loadFinished does after a first load
345
+ emit('schedules:window', { open: true });
346
+ startPolling();
347
+ const panel = _panel;
260
348
  requestAnimationFrame(() => {
349
+ if (_panel !== panel) return;
261
350
  _backdrop?.classList.add('open');
262
- _panel?.classList.add('open');
351
+ panel.classList.add('open');
352
+ focusInitial();
263
353
  });
264
- startPolling();
354
+ await loadData(); // loadFinished() takes it from there
265
355
  }
266
356
 
357
+ /** Applies what an open request asks for; true when that changes what the window shows. */
267
358
  function applyOpenOptions(opts = {}) {
268
- if (opts.tab) _tab = opts.tab;
269
- if (opts.groupId) _selectedGroup = opts.groupId;
270
- if (opts.action === 'new-schedule') {
271
- _scheduleEditor = {};
272
- _tab = 'cron';
359
+ const before = `${_scope}|${!!_scheduleEditor}|${!!_groupEditorDraft}`;
360
+ const fromTab = { cron: listScope() ? _scope : 'all', timers: 'timers', groups: 'all', activity: 'activity' };
361
+ if (opts.tab && fromTab[opts.tab]) setScope(fromTab[opts.tab], { keepEditors: true });
362
+ if (opts.scope) setScope(opts.scope, { keepEditors: true });
363
+ if (opts.groupId) setScope(`group:${opts.groupId}`, { keepEditors: true });
364
+ if ((opts.action === 'new-schedule' || opts.action === 'new-group') && editorsWait()) {
365
+ // An editor is built from the automations and groups: it opens once they have loaded.
366
+ _pendingAction = opts.action;
367
+ } else if (opts.action === 'new-schedule') {
368
+ closeEditors();
369
+ _scheduleEditor = { groupId: groupIdOf(_scope) };
273
370
  } else if (opts.action === 'new-group') {
274
- _tab = 'groups';
275
- _scheduleEditor = null;
371
+ closeEditors();
372
+ openGroupEditor(null);
276
373
  } else if (opts.action === 'quick-timer') {
277
- _tab = 'timers';
278
- _scheduleEditor = null;
374
+ closeEditors();
375
+ setScope('timers');
376
+ }
377
+ return before !== `${_scope}|${!!_scheduleEditor}|${!!_groupEditorDraft}` || !!opts.action;
378
+ }
379
+
380
+ function placePanel() {
381
+ const navH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--navbar-height')) || 48;
382
+ const saved = readJson(LAYOUT_KEY, null);
383
+ if (saved && saved.w > 0 && saved.h > 0) {
384
+ _panel.style.width = saved.w + 'px';
385
+ _panel.style.height = saved.h + 'px';
279
386
  }
280
- if (_panel) render();
387
+ const width = _panel.offsetWidth;
388
+ const height = _panel.offsetHeight;
389
+ const left = saved && Number.isFinite(saved.x) ? saved.x : (window.innerWidth - width) / 2;
390
+ const top = saved && Number.isFinite(saved.y) ? saved.y : navH + Math.max(8, (window.innerHeight - navH - height) / 2);
391
+ _panel.style.left = Math.round(Math.max(8, Math.min(left, window.innerWidth - width - 8))) + 'px';
392
+ _panel.style.top = Math.round(Math.max(navH + 8, Math.min(top, window.innerHeight - height - 8))) + 'px';
393
+ }
394
+
395
+ function saveLayout() {
396
+ if (!_panel) return;
397
+ const rect = _panel.getBoundingClientRect();
398
+ try {
399
+ storage.setItem(LAYOUT_KEY, JSON.stringify({
400
+ x: Math.round(rect.left), y: Math.round(rect.top), w: Math.round(rect.width), h: Math.round(rect.height),
401
+ }));
402
+ } catch { /* the default layout is used next time */ }
281
403
  }
282
404
 
283
405
  function closePanel() {
284
406
  if (!_panel) return;
407
+ saveLayout();
285
408
  stopPolling();
409
+ clearTimeout(_refreshTimer);
286
410
  closePromptScheduleModal();
287
- for (const [evt, fn] of _docListeners) document.removeEventListener(evt, fn);
288
- _docListeners = [];
411
+ _abort?.abort();
412
+ _abort = null;
289
413
  _backdrop?.remove();
290
414
  _backdrop = null;
291
415
  _panel.remove();
292
416
  _panel = null;
417
+ _frame = null;
418
+ _confirm = null;
419
+ _loading = false;
420
+ _pendingAction = null;
293
421
  _scheduleEditor = null;
422
+ _groupEditorId = null;
423
+ _groupEditorDraft = null;
294
424
  _draggingScheduleId = null;
425
+ document.body.classList.remove('schedules-window-open');
426
+ emit('schedules:window', { open: false });
427
+ const opener = _opener;
428
+ _opener = null;
429
+ if (opener?.isConnected && typeof opener.focus === 'function') opener.focus();
430
+ }
431
+
432
+ function focusInitial() {
433
+ if (!_panel) return;
434
+ const failed = _loadError && dataIncomplete() ? _panel.querySelector('#sched-list [data-action="retry-load"]') : null;
435
+ const target = (_scheduleEditor && $('sched-ed-name')) || (_groupEditorDraft && $('sched-ge-name')) || failed
436
+ || $('sched-search') || $('sched-load-note')?.querySelector('button') || $('sched-qt-template');
437
+ controlOf(target)?.focus({ preventScroll: true });
295
438
  }
296
439
 
297
440
  function buildPanelHTML() {
298
441
  return `
299
- <div class="resize-handle resize-handle-t" data-resize="t"></div>
300
- <div class="resize-handle resize-handle-r" data-resize="r"></div>
301
- <div class="resize-handle resize-handle-b" data-resize="b"></div>
302
- <div class="resize-handle resize-handle-l" data-resize="l"></div>
303
- <div class="resize-handle resize-handle-tl" data-resize="tl"></div>
304
- <div class="resize-handle resize-handle-tr" data-resize="tr"></div>
305
- <div class="resize-handle resize-handle-bl" data-resize="bl"></div>
306
- <div class="resize-handle resize-handle-br" data-resize="br"></div>
307
-
308
- <div class="sched-header drag-handle" data-drag="${PANEL_ID}">
442
+ ${['t', 'r', 'b', 'l', 'tl', 'tr', 'bl', 'br'].map(edge => `<div aria-hidden="true" class="resize-handle resize-handle-${edge}" data-resize="${edge}"></div>`).join('')}
443
+ <header class="sched-header drag-handle" data-drag="${PANEL_ID}">
309
444
  <div class="sched-header-left">
310
445
  <span class="sched-header-icon">${ICONS.clock}</span>
311
- <h3>Schedules</h3>
446
+ <h3 id="sched-title">Schedules</h3>
312
447
  <span class="sched-count" id="sched-total-count"></span>
313
448
  </div>
314
- <div class="sched-header-actions">
315
- <button class="sched-header-btn" data-action="schedule-new">${ICONS.plus} New Schedule</button>
316
- <button class="sched-header-btn" data-action="group-new">${ICONS.folder} New Group</button>
317
- </div>
318
- <button class="backdrop-toggle-btn" id="sched-backdrop-toggle" data-tooltip="Toggle backdrop">${ICONS.eye}</button>
319
- <button class="sched-close" id="sched-close">&times;</button>
320
- </div>
321
-
449
+ <button type="button" class="backdrop-toggle-btn" id="sched-backdrop-toggle" aria-pressed="false" aria-label="Hide the backdrop" data-tooltip="Hide the backdrop">${ICONS.eye}</button>
450
+ <button type="button" class="sched-close" id="sched-close" aria-label="Close Schedules" data-tooltip="Close (Esc)">${ICONS.close}</button>
451
+ </header>
322
452
  <div class="sched-body">
323
- <aside class="sched-sidebar" id="sched-sidebar"></aside>
324
- <main class="sched-main" id="sched-main"></main>
453
+ <nav class="sched-rail" id="sched-rail" aria-label="Schedule views and groups"></nav>
454
+ <section class="sched-main" aria-labelledby="sched-view-title">
455
+ <!-- A window too narrow for the rail: its views and groups as a popup, and New group. Outside
456
+ the frame below, which each view and editor replaces, so it is there in every one. -->
457
+ <div class="sched-narrow-nav" id="sched-narrow-nav">
458
+ <span class="sched-sr" id="sched-scope-label">View</span>
459
+ ${dropdownHTML('sched-scope-select', [], { classes: 'sched-scope-select', labelledBy: 'sched-scope-label', inputAttrs: 'data-change="scope"' })}
460
+ <button type="button" class="sched-btn" data-action="group-new" id="sched-narrow-new-group">${ICONS.plus}<span>New group</span></button>
461
+ </div>
462
+ <div class="sched-frame" id="sched-main"></div>
463
+ </section>
325
464
  </div>
465
+ <div class="sched-dialog-layer" id="sched-dialog-layer" hidden></div>
326
466
  `;
327
467
  }
328
468
 
329
469
  function wirePanel() {
330
- $('sched-close')?.addEventListener('click', closePanel);
470
+ _abort = new AbortController();
471
+ const { signal } = _abort;
472
+ $('sched-close')?.addEventListener('click', closePanel, { signal });
331
473
  $('sched-backdrop-toggle')?.addEventListener('click', () => {
332
- _backdrop?.classList.toggle('backdrop-hidden');
333
- $('sched-backdrop-toggle')?.classList.toggle('active', _backdrop?.classList.contains('backdrop-hidden'));
474
+ const hidden = !!_backdrop?.classList.toggle('backdrop-hidden');
475
+ const btn = $('sched-backdrop-toggle');
476
+ if (!btn) return;
477
+ btn.classList.toggle('active', hidden);
478
+ btn.setAttribute('aria-pressed', String(hidden));
479
+ const label = hidden ? 'Show the backdrop' : 'Hide the backdrop';
480
+ btn.setAttribute('aria-label', label);
481
+ btn.dataset.tooltip = label;
482
+ }, { signal });
483
+
484
+ _panel.addEventListener('click', handleClick, { signal });
485
+ _panel.addEventListener('change', handleChange, { signal });
486
+ _panel.addEventListener('input', handleInput, { signal });
487
+ _panel.addEventListener('dragstart', handleDragStart, { signal });
488
+ _panel.addEventListener('dragend', handleDragEnd, { signal });
489
+ _panel.addEventListener('dragover', handleDragOver, { signal });
490
+ _panel.addEventListener('dragleave', handleDragLeave, { signal });
491
+ _panel.addEventListener('drop', handleDrop, { signal });
492
+ _panel.addEventListener('keydown', handlePanelKeydown, { signal });
493
+ // The popups (cc-dropdown.js): one set of listeners for every one the window draws.
494
+ wireDropdowns(_panel, { signal });
495
+ // Capture: the menubar's own Escape (it closes an open menu) has run by the time a bubbling
496
+ // listener hears the key, so a menu that was open is seen here first.
497
+ window.addEventListener('keydown', handleEscape, { capture: true, signal });
498
+ }
499
+
500
+ // ── Data ──
501
+ // The list and the editors are built from three lists, the schedules, the automations and the
502
+ // groups, fetched together with a deadline each. A list that fails keeps what an earlier load
503
+ // brought (sync updates keep it current), so an open editor never loses a saved choice to it.
504
+ // The timers are fetched apart: a slow answer there holds the Timers view, nothing else.
505
+
506
+ const LOAD_DEADLINE = 15_000;
507
+
508
+ /** The request, or a failure (marked `timeout`) once it has had LOAD_DEADLINE to answer. */
509
+ function withDeadline(request) {
510
+ let timer;
511
+ const late = new Promise((_, reject) => {
512
+ timer = setTimeout(() => {
513
+ const err = new Error(`no answer within ${LOAD_DEADLINE / 1000} seconds`);
514
+ err.timeout = true;
515
+ reject(err);
516
+ }, LOAD_DEADLINE);
334
517
  });
335
-
336
- _panel.addEventListener('click', handleClick);
337
- _panel.addEventListener('change', handleChange);
338
- _panel.addEventListener('dragstart', handleDragStart);
339
- _panel.addEventListener('dragend', handleDragEnd);
340
- _panel.addEventListener('dragover', handleDragOver);
341
- _panel.addEventListener('drop', handleDrop);
342
-
343
- const onEsc = (e) => {
344
- if (e.key !== 'Escape' || !_panel) return;
345
- if (_promptModal) return;
346
- if (_scheduleEditor) {
347
- _scheduleEditor = null;
348
- render();
349
- return;
350
- }
351
- closePanel();
352
- };
353
- document.addEventListener('keydown', onEsc);
354
- _docListeners.push(['keydown', onEsc]);
518
+ return Promise.race([request, late]).finally(() => clearTimeout(timer));
355
519
  }
356
520
 
521
+ const failureReason = (result) => String(result.reason?.message || 'an unexpected answer').replace(/\.$/, '');
522
+
357
523
  async function loadData() {
358
- try { _templates = await fetchLoopTemplates(); } catch { _templates = []; }
359
- try { _schedules = await fetchSchedules(); } catch { _schedules = []; }
360
- try { _groups = await fetchScheduleGroups(); } catch { _groups = []; }
361
- try { _scheduleTimerData = await fetchScheduleTimers(); } catch { _scheduleTimerData = {}; }
362
- try { _quickTimers = await fetchQuickTimers(); } catch { _quickTimers = []; }
524
+ const seq = ++_loadSeq;
525
+ loadTimers();
526
+ const [schedules, templates, groups] = await Promise.allSettled([
527
+ withDeadline(fetchSchedules()), withDeadline(fetchLoopTemplates()), withDeadline(fetchScheduleGroups()),
528
+ ]);
529
+ if (seq !== _loadSeq) return; // a newer load is under way: it finishes for both
530
+ const failed = [];
531
+ if (schedules.status === 'fulfilled' && Array.isArray(schedules.value)) {
532
+ _schedules = schedules.value;
533
+ _loaded.schedules = true;
534
+ } else failed.push(['schedules', failureReason(schedules)]);
535
+ if (templates.status === 'fulfilled' && Array.isArray(templates.value)) {
536
+ _templates = templates.value;
537
+ _loaded.templates = true;
538
+ } else failed.push(['automations', failureReason(templates)]);
539
+ if (groups.status === 'fulfilled' && Array.isArray(groups.value)) {
540
+ _groups = groups.value;
541
+ _loaded.groups = true;
542
+ if (groupIdOf(_scope) && !getGroup(groupIdOf(_scope))) setScope('all', { keepEditors: true });
543
+ } else failed.push(['groups', failureReason(groups)]);
544
+ _loadError = failed.length
545
+ ? `The ${listJoin(failed.map(([what]) => what))} did not load: ${[...new Set(failed.map(([, why]) => why))].join('; ')}.`
546
+ : null;
547
+ loadFinished();
548
+ }
549
+
550
+ /** The one end of every load: opening the window, Try again and a live refresh all finish here. */
551
+ function loadFinished() {
552
+ if (!_panel) return;
553
+ const gated = _loading;
554
+ _loading = false;
555
+ let opened = false;
556
+ if (_pendingAction && !dataIncomplete()) {
557
+ const action = _pendingAction;
558
+ _pendingAction = null;
559
+ applyOpenOptions({ action });
560
+ opened = true;
561
+ }
562
+ if (!gated && !opened) { refresh(); return; }
563
+ const focusWasHere = document.activeElement === document.body || _panel.contains(document.activeElement)
564
+ || document.activeElement?.id === 'menubar-schedules-btn';
565
+ render({ frame: true });
566
+ if (gated) revealExpanded();
567
+ if (opened || (focusWasHere && !_panel.contains(document.activeElement))) focusInitial();
568
+ }
569
+
570
+ /** The timers waiting to fire: only the Timers view and its count read them. */
571
+ async function loadTimers() {
572
+ const seq = ++_timersSeq;
573
+ const [timers, quick] = await Promise.allSettled([withDeadline(fetchScheduleTimers()), withDeadline(fetchQuickTimers())]);
574
+ if (seq !== _timersSeq) return;
575
+ const failed = [];
576
+ if (timers.status === 'fulfilled' && timers.value && typeof timers.value === 'object') _scheduleTimerData = timers.value;
577
+ else failed.push(failureReason(timers));
578
+ if (quick.status === 'fulfilled' && Array.isArray(quick.value)) _quickTimers = quick.value;
579
+ else failed.push(failureReason(quick));
580
+ _timersLoaded = true;
581
+ _timersError = failed.length ? `The timers did not load: ${[...new Set(failed)].join('; ')}.` : null;
582
+ if (!_panel) return;
583
+ renderRail();
584
+ renderActiveTimersInto();
585
+ }
586
+
587
+ /** Fetch again, soon (several sync events tend to arrive together). */
588
+ function scheduleRefresh(delay = 400) {
589
+ clearTimeout(_refreshTimer);
590
+ _refreshTimer = setTimeout(() => { loadData(); }, delay);
363
591
  }
364
592
 
365
593
  function startPolling() {
@@ -376,190 +604,652 @@ function stopPolling() {
376
604
  _pollTimer = null;
377
605
  }
378
606
 
379
- function render() {
607
+ // ── Rendering ──
608
+
609
+ function setScope(scope, { keepEditors = false } = {}) {
610
+ _scope = scope;
611
+ if (!keepEditors) closeEditors();
612
+ try { storage.setItem(SCOPE_KEY, scope); } catch { /* not remembered */ }
613
+ }
614
+
615
+ function closeEditors() {
616
+ _scheduleEditor = null;
617
+ _groupEditorId = null;
618
+ _groupEditorDraft = null;
619
+ }
620
+
621
+ /** Everything the window shows. `frame` rebuilds the main area's skeleton as well as its content. */
622
+ function render({ frame = false } = {}) {
380
623
  if (!_panel) return;
381
624
  const count = $('sched-total-count');
382
- if (count) count.textContent = String(_schedules.length);
383
- renderSidebar();
384
- if (_scheduleEditor) renderScheduleEditor();
385
- else renderMain();
625
+ if (count) count.textContent = countsKnown() ? String(_schedules.length) : '';
626
+ renderRail();
627
+ renderMain(frame);
386
628
  }
387
629
 
388
- function renderSidebar() {
389
- const sidebar = $('sched-sidebar');
390
- if (!sidebar) return;
630
+ /** A sync update: the editors keep what is being typed; everything else follows the data. */
631
+ function refresh() {
632
+ if (!_panel) return;
633
+ if (editorOpen()) {
634
+ const count = $('sched-total-count');
635
+ if (count) count.textContent = countsKnown() ? String(_schedules.length) : '';
636
+ renderRail();
637
+ return;
638
+ }
639
+ render();
640
+ }
391
641
 
392
- const ungroupedCount = _schedules.filter(s => !s.groupId || !getGroup(s.groupId)).length;
393
- const activeCount = _schedules.filter(s => s.enabled).length;
394
- const groupRows = _groups.map(group => {
395
- const count = _schedules.filter(s => s.groupId === group.id).length;
396
- return `
397
- <button class="sched-group-row${_selectedGroup === group.id ? ' active' : ''}" data-action="select-group" data-group-id="${esc(group.id)}" data-drop-group="${esc(group.id)}">
398
- <span class="sched-group-color" style="background:${esc(group.color || 'rgba(255,255,255,0.22)')}"></span>
399
- <span class="sched-group-name">${esc(group.name)}</span>
400
- ${group.mutualExclusion ? `<span class="sched-group-mutex-icon" data-tooltip="Mutual exclusion active">${ICONS.lock}</span>` : ''}
401
- <span class="sched-group-count">${count}</span>
402
- </button>`;
642
+ function renderRail() {
643
+ const rail = $('sched-rail');
644
+ if (!rail) return;
645
+ const now = Date.now();
646
+ const counts = scopeCounts(_schedules, now);
647
+ const timers = _quickTimers.length + Object.keys(_scheduleTimerData).length;
648
+ const ids = groupIds();
649
+ const view = (scope, count, { alert = false } = {}) => {
650
+ const { label, icon } = VIEWS[scope];
651
+ const current = _scope === scope;
652
+ const countHTML = count == null || !countsKnown() ? '' : `<span class="sched-nav-count${alert && count ? ' sched-nav-count--alert' : ''}">${count}</span>`;
653
+ return `<button type="button" class="sched-nav-item" data-action="set-scope" data-scope="${scope}"${current ? ' aria-current="page"' : ''}>
654
+ <span class="sched-nav-icon">${icon}</span><span class="sched-nav-label">${label}</span>${countHTML}</button>`;
655
+ };
656
+ const groupRows = orderedGroups().map(group => {
657
+ const members = _schedules.filter(s => s.groupId === group.id);
658
+ const summary = groupSummary(members, now);
659
+ const scope = `group:${group.id}`;
660
+ const name = `${group.name}: ${plural(summary.total, 'schedule')}${summary.attention ? `, ${summary.attention} need attention` : ''}${group.mutualExclusion ? ', one at a time' : ''}`;
661
+ return `<button type="button" class="sched-nav-item" data-action="set-scope" data-scope="${esc(scope)}" data-drop-group="${esc(group.id)}" aria-label="${esc(name)}"${_scope === scope ? ' aria-current="page"' : ''}>
662
+ <span class="sched-nav-icon"><span class="sched-dot" style="--dot:${esc(cssColor(group.color))}"></span></span>
663
+ <span class="sched-nav-label">${esc(group.name)}</span>
664
+ ${group.mutualExclusion ? `<span class="sched-nav-flag" data-tooltip="One at a time">${ICONS.lock}</span>` : ''}
665
+ ${summary.attention ? `<span class="sched-nav-flag sched-nav-flag--alert" data-tooltip="${esc(plural(summary.attention, 'schedule'))} need attention">${ICONS.alert}${summary.attention}</span>` : ''}
666
+ <span class="sched-nav-count">${summary.total}</span>
667
+ </button>`;
403
668
  }).join('');
404
-
405
- sidebar.innerHTML = `
406
- <div class="sched-sidebar-summary">
407
- <div><span>${activeCount}</span><label>Active</label></div>
408
- <div><span>${_groups.length}</span><label>Groups</label></div>
669
+ const ungrouped = _schedules.filter(s => !s.groupId || !ids.has(s.groupId)).length;
670
+
671
+ // Redrawn on every live update: the focused item and the scroll position stay.
672
+ const active = document.activeElement;
673
+ const focusKey = active && rail.contains(active)
674
+ ? (active.dataset.scope ? `[data-scope="${CSS.escape(active.dataset.scope)}"]` : active.dataset.action ? `[data-action="${active.dataset.action}"]` : null)
675
+ : null;
676
+ const scroll = rail.scrollTop;
677
+ rail.innerHTML = `
678
+ ${view('upcoming', counts.upcoming)}
679
+ ${view('all', counts.all)}
680
+ ${view('attention', counts.attention, { alert: true })}
681
+ ${view('paused', counts.paused)}
682
+ <div class="sched-rail-sep" role="presentation"></div>
683
+ ${view('timers', timers || null)}
684
+ ${view('activity', null)}
685
+ <div class="sched-rail-head"><span id="sched-rail-groups">Groups</span><button type="button" class="sched-icon-btn" data-action="group-new" aria-label="New group" data-tooltip="New group"${editorsWait() ? ' disabled' : ''}>${ICONS.plus}</button></div>
686
+ <div role="group" aria-labelledby="sched-rail-groups">
687
+ ${groupRows || (countsKnown() ? '<div class="sched-rail-empty">No groups yet.</div>' : '')}
688
+ <button type="button" class="sched-nav-item" data-action="set-scope" data-scope="ungrouped" data-drop-group="" aria-label="Ungrouped${countsKnown() ? `: ${esc(plural(ungrouped, 'schedule'))}` : ''}"${_scope === 'ungrouped' ? ' aria-current="page"' : ''}>
689
+ <span class="sched-nav-icon">${ICONS.folder}</span><span class="sched-nav-label">Ungrouped</span><span class="sched-nav-count">${countsKnown() ? ungrouped : ''}</span>
690
+ </button>
409
691
  </div>
410
- <button class="sched-group-row${_selectedGroup === 'all' ? ' active' : ''}" data-action="select-group" data-group-id="all" data-drop-group="">
411
- <span class="sched-group-icon">${ICONS.list}</span>
412
- <span class="sched-group-name">All Schedules</span>
413
- <span class="sched-group-count">${_schedules.length}</span>
414
- </button>
415
- <button class="sched-group-row${_selectedGroup === 'ungrouped' ? ' active' : ''}" data-action="select-group" data-group-id="ungrouped" data-drop-group="">
416
- <span class="sched-group-icon">${ICONS.folder}</span>
417
- <span class="sched-group-name">Ungrouped</span>
418
- <span class="sched-group-count">${ungroupedCount}</span>
419
- </button>
420
- <div class="sched-sidebar-label">Groups</div>
421
- <div class="sched-group-list">${groupRows || '<div class="sched-sidebar-empty">No schedule groups yet.</div>'}</div>
422
- <button class="sched-sidebar-prompt" data-action="schedule-prompt" data-tooltip="Let AI build a schedule for you">${ICONS.sparkle} Prompt a Schedule</button>
423
- <button class="sched-sidebar-new" data-action="group-new">${ICONS.plus} Create Group</button>
424
692
  `;
693
+ rail.scrollTop = scroll;
694
+ if (focusKey) rail.querySelector(focusKey)?.focus({ preventScroll: true });
695
+ renderNarrowNav();
425
696
  }
426
697
 
427
- function renderMain() {
698
+ function renderMain(rebuild = false) {
428
699
  const main = $('sched-main');
429
700
  if (!main) return;
430
- const tabs = [
431
- ['cron', 'Cron'],
432
- ['timers', 'Timers'],
433
- ['groups', 'Groups'],
434
- ['activity', 'Activity'],
435
- ].map(([id, label]) => `<button class="sched-tab${_tab === id ? ' active' : ''}" data-action="set-tab" data-tab="${id}">${label}</button>`).join('');
701
+ // An editor that is on screen gives its typed values to the draft before it is drawn again.
702
+ if (_groupEditorDraft) {
703
+ if (rebuild || _frame !== 'group-editor') {
704
+ if (_frame === 'group-editor') collectGroupEditorDraft();
705
+ renderGroupEditor(_groupEditorId ? getGroup(_groupEditorId) : null);
706
+ }
707
+ return;
708
+ }
709
+ if (_scheduleEditor) {
710
+ if (rebuild || _frame !== 'schedule-editor') {
711
+ if (_frame === 'schedule-editor') collectScheduleEditorDraft();
712
+ renderScheduleEditor();
713
+ }
714
+ return;
715
+ }
716
+ if (_scope === 'timers') {
717
+ if (rebuild || _frame !== 'timers') renderTimersView();
718
+ else { renderNote(); renderActiveTimersInto(); }
719
+ return;
720
+ }
721
+ const frame = _scope === 'activity' ? 'activity' : 'list';
722
+ if (rebuild || _frame !== frame) buildListFrame(frame);
723
+ renderViewHead();
724
+ if (frame === 'activity') renderActivityList();
725
+ else renderList();
726
+ }
436
727
 
728
+ function buildListFrame(frame) {
729
+ const main = $('sched-main');
730
+ _frame = frame;
731
+ const activity = frame === 'activity';
732
+ // A rebuild (the data arriving, a view switch) must not drop a search being typed.
733
+ const search = document.activeElement?.id === 'sched-search' ? document.activeElement : null;
734
+ const caret = search ? [search.selectionStart, search.selectionEnd] : null;
437
735
  main.innerHTML = `
438
- <div class="sched-tabs">${tabs}</div>
439
- <div class="sched-content" id="sched-content"></div>
736
+ <div class="sched-view-head" id="sched-view-head"></div>
737
+ <div class="sched-toolbar">
738
+ <div class="sched-search">
739
+ ${ICONS.search}
740
+ <label class="sched-sr" for="sched-search">${activity ? 'Search runs' : 'Search schedules'}</label>
741
+ <input id="sched-search" type="search" autocomplete="off" spellcheck="false"
742
+ placeholder="${activity ? 'Search runs by schedule or automation…' : 'Search by name, automation, group or time…'}" value="${esc(_query)}">
743
+ <button type="button" class="sched-icon-btn" data-action="clear-search" aria-label="Clear search"${_query ? '' : ' hidden'}>${ICONS.close}</button>
744
+ <kbd aria-hidden="true">/</kbd>
745
+ </div>
746
+ ${activity ? '' : `<span class="sched-sort" id="sched-sort-wrap"><span class="sched-sr" id="sched-sort-label">Order</span>
747
+ ${dropdownHTML('sched-sort', [
748
+ opt('next', 'Next run first', _sort === 'next'),
749
+ opt('name', 'Name', _sort === 'name'),
750
+ opt('last', 'Last run first', _sort === 'last'),
751
+ ], { labelledBy: 'sched-sort-label', inputAttrs: 'data-change="sort"' })}</span>`}
752
+ </div>
753
+ <div class="sched-load-note" id="sched-load-note" role="status"></div>
754
+ <div class="sched-list" id="sched-list" tabindex="-1"></div>
755
+ <footer class="sched-footer">
756
+ <span id="sched-status" role="status" aria-live="polite" aria-atomic="true"></span>
757
+ ${activity ? '' : `<span class="sched-footer-hint">${ICONS.move}Drag a schedule onto a group in the sidebar to move it</span>`}
758
+ </footer>
440
759
  `;
760
+ if (caret) {
761
+ const input = $('sched-search');
762
+ input?.focus({ preventScroll: true });
763
+ try { input?.setSelectionRange(caret[0], caret[1]); } catch { /* type=search keeps its caret */ }
764
+ }
765
+ }
766
+
767
+ function headActions({ group = null } = {}) {
768
+ // An editor needs the automations and groups: nothing opens one before they have loaded.
769
+ const wait = editorsWait() ? ' disabled' : '';
770
+ return `<div class="sched-view-actions">
771
+ ${group ? `<button type="button" class="sched-btn" data-action="group-edit" data-id="${esc(group.id)}"${wait}>${ICONS.edit}<span>Edit group</span></button>` : ''}
772
+ <button type="button" class="sched-btn sched-btn--text" data-action="schedule-prompt" data-tooltip="Describe the schedule to Claude Code in a side panel">${ICONS.sparkle}<span>Prompt a schedule</span></button>
773
+ <button type="button" class="sched-btn sched-btn--primary" data-action="schedule-new"${wait}>${ICONS.plus}<span>New schedule</span></button>
774
+ </div>`;
775
+ }
776
+
777
+ function groupOverridesText(group) {
778
+ const parts = [];
779
+ if (group.profile) parts.push(CLI_PROFILES.find(c => c.id === group.profile)?.label || group.profile);
780
+ if (group.model) parts.push(group.model);
781
+ if (group.contextMode && group.contextMode !== 'default') parts.push(`context ${group.contextMode}`);
782
+ if (group.effort) parts.push(`thinking ${group.effort}`);
783
+ if (group.mcpProfile) parts.push(`MCP ${group.mcpProfile}`);
784
+ if (group.usesBrowser === true) parts.push('browser on');
785
+ else if (group.usesBrowser === false) parts.push('browser off');
786
+ return parts.join(' · ');
787
+ }
788
+
789
+ function renderViewHead() {
790
+ const head = $('sched-view-head');
791
+ if (!head) return;
792
+ const now = Date.now();
793
+ const counts = scopeCounts(_schedules, now);
794
+ let title = VIEWS[_scope]?.label || 'Schedules';
795
+ let dot = '';
796
+ let sub = '';
797
+ let meta = '';
798
+ let group = null;
799
+ if (_scope === 'all') {
800
+ sub = `${plural(counts.all, 'schedule')} · ${counts.active} active · ${counts.paused} paused${counts.attention ? ` · ${counts.attention} need attention` : ''}`;
801
+ } else if (_scope === 'upcoming') {
802
+ // One row per schedule, at its next run (a schedule that runs three times today is listed once).
803
+ sub = counts.upcoming
804
+ ? `${counts.upcoming === 1 ? '1 schedule runs' : `${counts.upcoming} schedules run`} in the next 24 hours, each at its next run, soonest first.`
805
+ : 'Nothing runs in the next 24 hours.';
806
+ } else if (_scope === 'attention') {
807
+ sub = 'Active schedules whose last run failed or that are late. Open one to see why.';
808
+ } else if (_scope === 'paused') {
809
+ sub = 'Paused schedules keep their settings. Turn one on to put it back on its timetable.';
810
+ } else if (_scope === 'activity') {
811
+ sub = 'The latest run of each schedule, newest first. Open one to see its schedule.';
812
+ } else if (_scope === 'ungrouped') {
813
+ title = 'Ungrouped';
814
+ sub = 'Schedules in no group. Drag one onto a group in the sidebar to file it.';
815
+ } else if (groupIdOf(_scope)) {
816
+ group = getGroup(groupIdOf(_scope));
817
+ if (group) {
818
+ title = group.name;
819
+ dot = `<span class="sched-dot" style="--dot:${esc(cssColor(group.color))}" aria-hidden="true"></span>`;
820
+ const summary = groupSummary(_schedules.filter(s => s.groupId === group.id), now);
821
+ sub = `${plural(summary.total, 'schedule')} · ${summary.active} active · ${summary.paused} paused${summary.attention ? ` · ${summary.attention} need attention` : ''}${group.mutualExclusion ? ' · One at a time' : ''}`;
822
+ const overrides = groupOverridesText(group);
823
+ if (overrides) meta = `<p class="sched-view-meta sched-group-overrides">Group launch settings: ${esc(overrides)}</p>`;
824
+ }
825
+ }
826
+ if (_loading) sub = 'Loading schedules…';
827
+ else if (dataIncomplete()) sub = '';
828
+ // A live update changes counts, not the head's controls: keep them (and the focus on them).
829
+ const key = `${_scope}|${title}|${dot}|${meta}|${editorsWait()}`;
830
+ if (head.dataset.key === key) {
831
+ const subEl = head.querySelector('.sched-view-sub');
832
+ if (subEl && subEl.textContent !== sub) subEl.textContent = sub;
833
+ } else {
834
+ head.dataset.key = key;
835
+ head.innerHTML = `
836
+ <div class="sched-view-titles">
837
+ <h2 id="sched-view-title">${dot}${esc(title)}</h2>
838
+ <p class="sched-view-sub">${esc(sub)}</p>
839
+ ${meta}
840
+ </div>
841
+ ${headActions({ group })}
842
+ `;
843
+ }
844
+ }
441
845
 
442
- if (_tab === 'cron') renderCronTab();
443
- else if (_tab === 'timers') renderTimersTab();
444
- else if (_tab === 'groups') renderGroupsTab();
445
- else renderActivityTab();
846
+ /** The rail as a popup with its counts, and New group, for a window too narrow to show the rail. */
847
+ function renderNarrowNav() {
848
+ const scope = $('sched-scope-select');
849
+ if (scope) {
850
+ const counts = scopeCounts(_schedules, Date.now());
851
+ const timers = _quickTimers.length + Object.keys(_scheduleTimerData).length;
852
+ const n = (value) => (!countsKnown() || !value && value !== 0 ? '' : ` (${value})`);
853
+ const views = [['upcoming', counts.upcoming], ['all', counts.all], ['attention', counts.attention], ['paused', counts.paused], ['timers', timers || null], ['activity', null]]
854
+ .map(([id, count]) => opt(id, `${VIEWS[id].label}${n(count)}`));
855
+ const groups = orderedGroups().map(g => opt(`group:${g.id}`, `${g.name}${n(_schedules.filter(s => s.groupId === g.id).length)}`));
856
+ const entries = [{ group: 'Views', options: views }, { group: 'Groups', options: [...groups, opt('ungrouped', 'Ungrouped')] }];
857
+ // Redrawn only when it changes (an open popup stays open, its highlighted choice too).
858
+ const key = JSON.stringify(entries);
859
+ if (scope._key !== key) { setDropdownItems(scope, entries); scope._key = key; }
860
+ setDropdownValue(scope, _scope);
861
+ }
862
+ const newGroup = $('sched-narrow-new-group');
863
+ if (newGroup) newGroup.disabled = editorsWait();
864
+ }
865
+
866
+ function setStatus(text) {
867
+ const status = $('sched-status');
868
+ if (status) status.textContent = text;
869
+ }
870
+
871
+ /**
872
+ * The line above the list. A load that failed: without every list, the list views show the
873
+ * failure in place of the list; with an earlier copy of each, this line says so above the copy
874
+ * (and in Timers, which has none). Otherwise, on a group's page, what its change of CLI did to
875
+ * its schedules, until dismissed.
876
+ */
877
+ function renderNote() {
878
+ const note = $('sched-load-note');
879
+ if (!note) return;
880
+ const listView = _frame === 'list' || _frame === 'activity';
881
+ const failed = _loading || !_loadError || (listView && dataIncomplete()) ? ''
882
+ : dataIncomplete() ? _loadError : `${_loadError} Showing what loaded before.`;
883
+ const report = !failed && _frame === 'list' ? cliReportNote() : null;
884
+ const kind = failed ? 'load' : report?.kind || '';
885
+ const text = failed || report?.text || '';
886
+ const key = `${kind}|${text}`;
887
+ if (note.dataset.key === key) return; // unchanged: a live update does not announce it again
888
+ note.dataset.key = key;
889
+ note.classList.toggle('sched-load-note--ok', kind === 'ok');
890
+ note.classList.toggle('sched-load-note--busy', kind === 'busy');
891
+ const icon = kind === 'ok' ? ICONS.ok : kind === 'busy' ? ICONS.clock : ICONS.alert;
892
+ const action = kind === 'load' ? '<button type="button" class="sched-btn" data-action="retry-load">Try again</button>'
893
+ : kind === 'busy' ? '' : '<button type="button" class="sched-btn" data-action="dismiss-report">Dismiss</button>';
894
+ note.innerHTML = text ? `${icon}<span>${esc(text)}</span>${action}` : '';
895
+ }
896
+
897
+ /** What the last change of a group's CLI did to its schedules, for that group's page. */
898
+ function cliReportNote() {
899
+ const r = _cliReport;
900
+ if (!r || _scope !== `group:${r.groupId}`) return null;
901
+ if (r.pending) return { kind: 'busy', text: "Clearing the old CLI's settings from the group's schedules…" };
902
+ const cli = r.profile ? CLI_PROFILES.find(p => p.id === r.profile)?.label || r.profile : null;
903
+ const kept = r.changed.length + r.failed.length;
904
+ const its = (list) => (list.length === 1 ? 'its' : 'their');
905
+ const parts = [cli ? `The group now runs ${cli}.` : 'The group no longer sets a CLI.'];
906
+ if (r.reset.length) parts.push(`Cleared the old CLI's settings on ${listJoin(r.reset)}.`);
907
+ else if (!kept) parts.push('None of its schedules had settings of the old CLI left.');
908
+ if (r.changed.length) parts.push(`${listJoin(r.changed)} changed meanwhile and kept ${its(r.changed)} settings.`);
909
+ if (r.failed.length) parts.push(`${listJoin(r.failed)} could not be checked or updated and kept ${its(r.failed)} settings.`);
910
+ if (kept) parts.push(`Open ${kept === 1 ? 'it' : 'them'} to choose ${kept === 1 ? 'its' : 'their'} settings.`);
911
+ return { kind: kept ? 'warn' : 'ok', text: parts.join(' ') };
912
+ }
913
+
914
+ function loadFailedHTML() {
915
+ const waiting = _pendingAction ? ` The ${_pendingAction === 'new-group' ? 'new group' : 'new schedule'} opens once they load.` : '';
916
+ return emptyState(ICONS.alert, 'Schedules could not be loaded', `${_loadError || 'Not everything loaded.'}${waiting}`,
917
+ '<button type="button" class="sched-btn" data-action="retry-load">Try again</button>');
918
+ }
919
+
920
+ /** Keep the place: scroll position and the focused control survive a re-render of the list. */
921
+ function captureListState(list) {
922
+ const active = document.activeElement;
923
+ const inside = active && list.contains(active);
924
+ return {
925
+ scroll: list.scrollTop,
926
+ focus: inside ? {
927
+ id: active.id || null,
928
+ scheduleId: active.closest('[data-schedule-id]')?.dataset.scheduleId || null,
929
+ groupId: active.closest('[data-group-section]')?.dataset.groupSection || null,
930
+ action: active.dataset.action || active.dataset.change || null,
931
+ } : null,
932
+ };
446
933
  }
447
934
 
448
- function schedulesForSelectedGroup() {
449
- if (_selectedGroup === 'all') return [..._schedules];
450
- if (_selectedGroup === 'ungrouped') return _schedules.filter(s => !s.groupId || !getGroup(s.groupId));
451
- return _schedules.filter(s => s.groupId === _selectedGroup);
935
+ function restoreListState(list, saved) {
936
+ list.scrollTop = saved.scroll;
937
+ const f = saved.focus;
938
+ if (!f) return;
939
+ let target = f.id ? document.getElementById(f.id) : null;
940
+ if (!target && f.scheduleId) {
941
+ const row = [...list.querySelectorAll('[data-schedule-id]')].find(el => el.dataset.scheduleId === f.scheduleId);
942
+ target = row && f.action ? controlOf(row.querySelector(`[data-action="${f.action}"], [data-change="${f.action}"]`)) : row?.querySelector('.sched-row-toggle');
943
+ }
944
+ if (!target && f.groupId) {
945
+ const section = [...list.querySelectorAll('[data-group-section]')].find(el => el.dataset.groupSection === f.groupId);
946
+ target = section?.querySelector(`[data-action="${f.action}"]`) || section?.querySelector('.sched-group-toggle');
947
+ }
948
+ target?.focus({ preventScroll: true });
452
949
  }
453
950
 
454
- function sortSchedules(list) {
455
- return [...list].sort((a, b) => {
456
- if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
457
- const an = a.nextRun ? new Date(a.nextRun).getTime() : Infinity;
458
- const bn = b.nextRun ? new Date(b.nextRun).getTime() : Infinity;
459
- if (an !== bn) return an - bn;
460
- return String(a.name || '').localeCompare(String(b.name || ''));
461
- });
951
+ function scheduleExtras(s) {
952
+ return [getTemplate(s.templateId)?.name, getGroup(s.groupId)?.name];
462
953
  }
463
954
 
464
- function renderCronTab() {
465
- const content = $('sched-content');
466
- if (!content) return;
955
+ function visibleSchedules() {
956
+ const now = Date.now();
957
+ const ids = groupIds();
958
+ return _schedules.filter(s => inScope(s, _scope, { now, groupIds: ids }) && matchesQuery(s, _query, scheduleExtras(s)));
959
+ }
467
960
 
468
- const active = _schedules.filter(s => s.enabled).length;
469
- const paused = _schedules.length - active;
470
- const overdue = _schedules.filter(s => s.enabled && s.nextRun && new Date(s.nextRun) < new Date()).length;
471
- const title = _selectedGroup === 'all' ? 'All Schedules'
472
- : _selectedGroup === 'ungrouped' ? 'Ungrouped'
473
- : getGroup(_selectedGroup)?.name || 'Group';
961
+ function renderList() {
962
+ const list = $('sched-list');
963
+ if (!list) return;
964
+ const saved = captureListState(list);
965
+ const clear = _panel?.querySelector('[data-action="clear-search"]');
966
+ if (clear) clear.hidden = !_query;
967
+ // Up next is in time order by definition: no order to choose there.
968
+ const sortWrap = $('sched-sort-wrap');
969
+ if (sortWrap) sortWrap.hidden = _scope === 'upcoming';
970
+ renderNote();
474
971
 
475
- content.innerHTML = `
476
- <div class="sched-stat-strip">
477
- <div><span>${active}</span><label>Active</label></div>
478
- <div><span>${paused}</span><label>Paused</label></div>
479
- <div><span>${overdue}</span><label>Overdue</label></div>
480
- <button class="sched-primary" data-action="schedule-new">${ICONS.plus} New Cron</button>
972
+ if (_loading) {
973
+ list.innerHTML = emptyState(ICONS.clock, 'Loading schedules…', '');
974
+ setStatus('Loading…');
975
+ return;
976
+ }
977
+ if (dataIncomplete()) {
978
+ list.innerHTML = loadFailedHTML();
979
+ setStatus('Not loaded');
980
+ return;
981
+ }
982
+
983
+ const now = Date.now();
984
+ const shown = visibleSchedules();
985
+ const scopeTotal = _schedules.filter(s => inScope(s, _scope, { now, groupIds: groupIds() })).length;
986
+ if (!_schedules.length) {
987
+ list.innerHTML = emptyState(ICONS.clock, 'No schedules yet', 'A schedule launches one of your automations on a timetable.',
988
+ `<button type="button" class="sched-btn sched-btn--primary" data-action="schedule-new">${ICONS.plus}<span>New schedule</span></button>
989
+ <button type="button" class="sched-btn" data-action="schedule-prompt">${ICONS.sparkle}<span>Prompt a schedule</span></button>`);
990
+ } else if (!shown.length && _query.trim()) {
991
+ const elsewhere = _scope !== 'all' && _schedules.some(s => matchesQuery(s, _query, scheduleExtras(s)));
992
+ list.innerHTML = emptyState(ICONS.search, `No schedules match “${_query.trim()}”`,
993
+ elsewhere ? 'Some do in other views.' : 'Try another word, or clear the search.',
994
+ `<button type="button" class="sched-btn" data-action="clear-search">Clear search</button>
995
+ ${elsewhere ? '<button type="button" class="sched-btn" data-action="search-all">Search all schedules</button>' : ''}`);
996
+ } else if (!shown.length) {
997
+ list.innerHTML = scopeEmptyState();
998
+ } else if (_scope === 'all') {
999
+ list.innerHTML = renderGroupedList(shown, now);
1000
+ } else if (_scope === 'upcoming') {
1001
+ list.innerHTML = renderUpcomingList(shown, now);
1002
+ } else {
1003
+ const rows = sortSchedules(shown, _sort).map(s => rowHTML(s, { now, showGroup: !groupIdOf(_scope) })).join('');
1004
+ list.innerHTML = `<div class="sched-box"><ul class="sched-rows" aria-label="${esc(VIEWS[_scope]?.label || 'Schedules')}">${rows}</ul></div>`;
1005
+ }
1006
+ setStatus(_query.trim() ? `${plural(shown.length, 'schedule')} match “${_query.trim()}”` : `Showing ${shown.length} of ${plural(scopeTotal, 'schedule')}`);
1007
+ restoreListState(list, saved);
1008
+ }
1009
+
1010
+ function emptyState(icon, title, text, actions = '') {
1011
+ return `<div class="sched-empty">
1012
+ <div class="sched-empty-icon">${icon}</div>
1013
+ <h3>${esc(title)}</h3>
1014
+ ${text ? `<p>${esc(text)}</p>` : ''}
1015
+ ${actions ? `<div class="sched-empty-actions">${actions}</div>` : ''}
1016
+ </div>`;
1017
+ }
1018
+
1019
+ function scopeEmptyState() {
1020
+ if (_scope === 'attention') return emptyState(ICONS.ok, 'Nothing needs attention', 'The last run of every active schedule worked, and none is late.');
1021
+ if (_scope === 'paused') return emptyState(ICONS.pause, 'No paused schedules', 'Every schedule is on its timetable.');
1022
+ if (_scope === 'upcoming') return emptyState(ICONS.next, 'Nothing runs in the next 24 hours', 'Active schedules show here once their next run is less than a day away.');
1023
+ if (_scope === 'ungrouped') return emptyState(ICONS.folder, 'Every schedule is in a group', '');
1024
+ return emptyState(ICONS.folder, 'No schedules in this group yet', 'Drag one onto the group in the sidebar, or create one here.',
1025
+ `<button type="button" class="sched-btn sched-btn--primary" data-action="schedule-new">${ICONS.plus}<span>New schedule</span></button>`);
1026
+ }
1027
+
1028
+ /** All schedules: one box per group, in the groups' order, then the ungrouped ones. */
1029
+ function renderGroupedList(shown, now) {
1030
+ const ids = groupIds();
1031
+ const sections = orderedGroups().map(group => ({ group, items: shown.filter(s => s.groupId === group.id) }));
1032
+ sections.push({ group: null, items: shown.filter(s => !s.groupId || !ids.has(s.groupId)) });
1033
+ return sections.filter(section => section.items.length).map(({ group, items }) => {
1034
+ const key = group ? group.id : '__ungrouped';
1035
+ const collapsed = !_query.trim() && _collapsed.has(key);
1036
+ const listId = `sched-rows-${domId(key)}`;
1037
+ const all = group ? _schedules.filter(s => s.groupId === group.id) : items;
1038
+ const summary = groupSummary(all, now);
1039
+ const meta = [
1040
+ summary.attention ? `<span class="is-alert">${ICONS.alert} ${summary.attention} need attention</span>` : '',
1041
+ summary.paused ? `<span>${summary.paused} paused</span>` : '',
1042
+ group?.mutualExclusion ? `<span>${ICONS.lock} One at a time</span>` : '',
1043
+ ].filter(Boolean).join('<span aria-hidden="true">·</span>');
1044
+ const name = group ? group.name : 'Ungrouped';
1045
+ return `<section class="sched-box${collapsed ? ' is-collapsed' : ''}" data-group-section="${esc(key)}">
1046
+ <div class="sched-group-head" data-drop-group="${group ? esc(group.id) : ''}">
1047
+ <button type="button" class="sched-group-toggle" data-action="group-collapse" data-key="${esc(key)}" aria-expanded="${!collapsed}" aria-controls="${listId}">
1048
+ <span class="sched-group-chevron">${ICONS.chevron}</span>
1049
+ ${group ? `<span class="sched-dot" style="--dot:${esc(cssColor(group.color))}" aria-hidden="true"></span>` : `<span class="sched-nav-icon">${ICONS.folder}</span>`}
1050
+ <span class="sched-group-name">${esc(name)}</span>
1051
+ <span class="sched-group-count">${items.length === all.length ? items.length : `${items.length} of ${all.length}`}</span>
1052
+ </button>
1053
+ ${meta ? `<span class="sched-group-meta">${meta}</span>` : ''}
1054
+ ${group ? `<button type="button" class="sched-icon-btn" data-action="group-edit" data-id="${esc(group.id)}" aria-label="Edit group ${esc(name)}" data-tooltip="Edit group">${ICONS.edit}</button>` : ''}
1055
+ </div>
1056
+ <ul class="sched-rows" id="${listId}" aria-label="${esc(name)}"${collapsed ? ' hidden' : ''}>
1057
+ ${sortSchedules(items, _sort).map(s => rowHTML(s, { now, showGroup: false })).join('')}
1058
+ </ul>
1059
+ </section>`;
1060
+ }).join('');
1061
+ }
1062
+
1063
+ /** Up next: the runs of the coming day in time order, under Today / Tomorrow. */
1064
+ function renderUpcomingList(shown, now) {
1065
+ const ordered = sortSchedules(shown, 'next');
1066
+ const days = new Map();
1067
+ for (const s of ordered) {
1068
+ const label = dayLabel(s.nextRun, now) || 'Later';
1069
+ if (!days.has(label)) days.set(label, []);
1070
+ days.get(label).push(s);
1071
+ }
1072
+ return [...days.entries()].map(([label, items], i) => `
1073
+ <section class="sched-section" aria-labelledby="sched-day-${i}">
1074
+ <h3 class="sched-section-title" id="sched-day-${i}">${esc(label)}<span>${plural(items.length, 'schedule')}</span></h3>
1075
+ <div class="sched-box"><ul class="sched-rows">${items.map(s => rowHTML(s, { now, showGroup: true })).join('')}</ul></div>
1076
+ </section>`).join('');
1077
+ }
1078
+
1079
+ function nextRunHTML(s, now) {
1080
+ if (!s.enabled) return '<span class="sched-next" data-state="paused"><span class="sched-next-rel">Paused</span></span>';
1081
+ const countdown = formatCountdown(s.nextRun, now);
1082
+ const when = s.nextRun ? whenLabel(s.nextRun, now) : '';
1083
+ return `<span class="sched-next" data-state="${countdown.state}" data-countdown="${esc(s.nextRun || '')}"${when ? ` title="${esc(when)}"` : ''}>
1084
+ <span class="sched-next-rel">${esc(countdown.text)}</span>${s.nextRun ? `<span class="sched-next-abs">${esc(shortWhen(s.nextRun, now))}</span>` : ''}</span>`;
1085
+ }
1086
+
1087
+ function lastRunHTML(s, now, run = lastRunState(s), { ago: withAgo = true } = {}) {
1088
+ const ago = withAgo && s.lastRun ? formatAgo(s.lastRun, now) : '';
1089
+ const title = s.lastRun ? new Date(s.lastRun).toLocaleString() : '';
1090
+ return `<span class="sched-last" data-kind="${run.kind}"${title ? ` title="${esc(title)}"` : ''}>
1091
+ <span class="sched-last-word">${RESULT_ICON[run.kind] || ICONS.never}<span>${esc(run.label)}</span></span>${ago ? `<span class="sched-last-ago">${esc(ago)}</span>` : ''}</span>`;
1092
+ }
1093
+
1094
+ function rowHTML(s, { now = Date.now(), showGroup = false } = {}) {
1095
+ const run = lastRunState(s);
1096
+ const failing = run.kind === 'failed';
1097
+ const open = _expanded.has(s.id);
1098
+ const group = getGroup(s.groupId);
1099
+ const detailId = `sched-detail-${domId(s.id)}`;
1100
+ const groupBit = showGroup && group
1101
+ ? `<span class="sched-row-group"><span class="sched-dot" style="--dot:${esc(cssColor(group.color))}" aria-hidden="true"></span>${esc(group.name)}</span>`
1102
+ : '';
1103
+ // The summary line drags (onto a group); the details stay selectable, an error message copyable.
1104
+ return `<li class="sched-row${s.enabled ? '' : ' is-paused'}${failing ? ' is-failing' : ''}${open ? ' is-open' : ''}" data-schedule-id="${esc(s.id)}">
1105
+ <div class="sched-row-line" data-action="schedule-expand" data-id="${esc(s.id)}" draggable="true">
1106
+ <span class="sched-switch-cell">
1107
+ <button type="button" class="sched-switch" role="switch" aria-checked="${!!s.enabled}" data-action="schedule-toggle" data-id="${esc(s.id)}"
1108
+ aria-label="Run ${esc(s.name)} on schedule" data-tooltip="${s.enabled ? 'Pause' : 'Resume'}"></button>
1109
+ </span>
1110
+ <button type="button" class="sched-row-toggle" data-action="schedule-expand" data-id="${esc(s.id)}" aria-expanded="${open}"${open ? ` aria-controls="${detailId}"` : ''}>
1111
+ <span class="sched-row-name">${esc(s.name)}</span>
1112
+ <span class="sched-row-sub">${groupBit}${groupBit ? '<span aria-hidden="true">·</span>' : ''}<span>${esc(describeCron(s.cron))}</span></span>
1113
+ ${failing ? `<span class="sched-row-reason">${esc(oneLine(run.reason))}</span>` : ''}
1114
+ </button>
1115
+ ${nextRunHTML(s, now)}
1116
+ ${lastRunHTML(s, now, run)}
1117
+ <span class="sched-row-chevron" aria-hidden="true">${ICONS.chevron}</span>
481
1118
  </div>
482
- <div class="sched-section-head">
483
- <h4>${esc(title)}</h4>
484
- <span>${schedulesForSelectedGroup().length} schedules</span>
1119
+ ${open ? `<div class="sched-detail" id="${detailId}">${detailHTML(s, now, run)}</div>` : ''}
1120
+ </li>`;
1121
+ }
1122
+
1123
+ function detailHTML(s, now, run) {
1124
+ const template = getTemplate(s.templateId);
1125
+ const group = getGroup(s.groupId);
1126
+ const presets = getCachedMcpProfilePresets();
1127
+ const mcpLabels = Object.fromEntries(Object.entries(presets).map(([id, p]) => [id, p.label || id]));
1128
+ const facts = launchFacts(s, { group, template, profiles: CLI_PROFILES, mcpLabels });
1129
+ const sourceNote = (source) => (source === 'group' ? ' <span class="sched-source">(group)</span>'
1130
+ : source === 'automation' ? ' <span class="sched-source">(automation)</span>'
1131
+ : source === 'default' ? ' <span class="sched-source">(default)</span>' : '');
1132
+ const next = s.enabled
1133
+ ? (s.nextRun ? `${esc(whenLabel(s.nextRun, now))} · <span data-countdown="${esc(s.nextRun)}">${esc(formatCountdown(s.nextRun, now).text)}</span>` : 'Not scheduled')
1134
+ : 'Paused';
1135
+ const last = run.kind === 'never' ? 'Not run yet'
1136
+ : `<span class="${run.kind === 'failed' ? 'is-failed' : ''}">${esc(run.label)}</span>${s.lastRun ? ` · ${esc(whenLabel(s.lastRun, now))}` : ''}`;
1137
+ // Any entry counts: with day themes, the server runs a schedule only on the days that have one.
1138
+ const themes = [1, 2, 3, 4, 5, 6, 0].filter(d => s.dayThemes?.[String(d)]).map(d => DAY_NAMES[d]);
1139
+ const groupOptions = [
1140
+ opt('', 'Ungrouped', !s.groupId),
1141
+ ...orderedGroups().map(g => opt(g.id, g.name, s.groupId === g.id)),
1142
+ ];
1143
+ const fact = (label, value, cls = '') => `<div class="sched-fact"><dt>${esc(label)}</dt><dd${cls ? ` class="${cls}"` : ''}>${value}</dd></div>`;
1144
+ return `
1145
+ <div class="sched-detail-grid">
1146
+ <section class="sched-facts-block">
1147
+ <h4 class="sched-facts-title">Runs</h4>
1148
+ <dl class="sched-facts">
1149
+ ${fact('Automation', template ? esc(template.name) : '<span class="is-failed">Missing automation</span>')}
1150
+ ${fact('Timetable', `${esc(describeCron(s.cron))} <span class="sched-source">(${esc(s.cron || '')})</span>`)}
1151
+ ${fact('Next run', next)}
1152
+ ${fact('Last run', last)}
1153
+ ${run.reason ? fact(run.kind === 'failed' ? 'Error' : 'Detail', esc(oneLine(run.reason))) : ''}
1154
+ ${fact('Runs so far', esc(String(s.runCount || 0)))}
1155
+ ${fact('Time zone', esc(s.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone))}
1156
+ ${themes.length ? fact('Day themes', `${esc(themes.join(', '))} <span class="sched-source">(runs on these days only)</span>`) : ''}
1157
+ </dl>
1158
+ </section>
1159
+ <section class="sched-facts-block">
1160
+ <h4 class="sched-facts-title">Launch settings</h4>
1161
+ <dl class="sched-facts">
1162
+ ${facts.map(f => fact(f.label, `${esc(f.value)}${sourceNote(f.source)}`, f.mono ? 'sched-mono' : '')).join('')}
1163
+ </dl>
1164
+ </section>
485
1165
  </div>
486
- <div class="sched-card-list" id="sched-card-list"></div>
487
- `;
488
- renderScheduleCards($('sched-card-list'), schedulesForSelectedGroup());
1166
+ <div class="sched-detail-bar">
1167
+ <div class="sched-detail-group"><span aria-hidden="true">Group</span>
1168
+ ${dropdownHTML(`sched-move-${domId(s.id)}`, groupOptions, { label: `Group of ${s.name}`, inputAttrs: `data-change="move-group" data-id="${esc(s.id)}"` })}
1169
+ </div>
1170
+ <span class="sched-detail-spacer"></span>
1171
+ <button type="button" class="sched-btn" data-action="schedule-test" data-id="${esc(s.id)}" data-tooltip="Start one run now; the timetable is unchanged">${ICONS.bolt}<span>Run now</span></button>
1172
+ <button type="button" class="sched-btn" data-action="schedule-edit-prompt" data-id="${esc(s.id)}" data-template-id="${esc(s.templateId || '')}" data-tooltip="Open its automation in Automation Studio">${ICONS.script}<span>Edit prompt</span></button>
1173
+ <button type="button" class="sched-btn" data-action="schedule-edit" data-id="${esc(s.id)}">${ICONS.edit}<span>Edit schedule</span></button>
1174
+ <button type="button" class="sched-btn sched-btn--danger" data-action="schedule-delete" data-id="${esc(s.id)}">${ICONS.trash}<span>Delete…</span></button>
1175
+ </div>`;
489
1176
  }
490
1177
 
491
- function renderScheduleCards(container, list) {
492
- if (!container) return;
493
- const schedules = sortSchedules(list);
494
- if (!schedules.length) {
495
- container.innerHTML = `
496
- <div class="sched-empty">
497
- <div>No schedules here.</div>
498
- <button class="sched-primary" data-action="schedule-new">${ICONS.plus} Create Schedule</button>
499
- </div>`;
1178
+ function renderActivityList() {
1179
+ const list = $('sched-list');
1180
+ if (!list) return;
1181
+ const saved = captureListState(list);
1182
+ const clear = _panel?.querySelector('[data-action="clear-search"]');
1183
+ if (clear) clear.hidden = !_query;
1184
+ renderNote();
1185
+ if (_loading || dataIncomplete()) {
1186
+ list.innerHTML = _loading ? emptyState(ICONS.clock, 'Loading schedules…', '') : loadFailedHTML();
500
1187
  return;
501
1188
  }
1189
+ const now = Date.now();
1190
+ const ran = _schedules.filter(s => s.lastRun);
1191
+ const shown = sortSchedules(ran.filter(s => matchesQuery(s, _query, scheduleExtras(s))), 'last');
1192
+ if (!shown.length) {
1193
+ list.innerHTML = _query.trim()
1194
+ ? emptyState(ICONS.search, `No runs match “${_query.trim()}”`, 'Try another word, or clear the search.', '<button type="button" class="sched-btn" data-action="clear-search">Clear search</button>')
1195
+ : emptyState(ICONS.activity, 'No runs yet', 'Each schedule\'s latest run shows here once it has run.');
1196
+ setStatus(_query.trim() ? 'No matches' : 'No runs yet');
1197
+ restoreListState(list, saved);
1198
+ return;
1199
+ }
1200
+ list.innerHTML = `<div class="sched-box"><ul class="sched-rows sched-rows--activity" aria-label="Latest runs">${shown.map(s => {
1201
+ const run = lastRunState(s);
1202
+ const template = getTemplate(s.templateId);
1203
+ const sub = run.kind === 'failed' && run.reason ? oneLine(run.reason) : template?.name || 'Missing automation';
1204
+ const next = s.enabled ? formatCountdown(s.nextRun, now).text : 'Paused';
1205
+ return `<li class="sched-row" data-schedule-id="${esc(s.id)}">
1206
+ <button type="button" class="sched-activity-btn" data-action="reveal-schedule" data-id="${esc(s.id)}" aria-label="${esc(`${s.name}: ${run.label} ${formatAgo(s.lastRun, now)}. Show it in All schedules`)}">
1207
+ ${lastRunHTML(s, now, run, { ago: false })}
1208
+ <span class="sched-activity-main"><span class="sched-row-name">${esc(s.name)}</span><span class="sched-row-sub"><span>${esc(sub)}</span></span></span>
1209
+ <span class="sched-activity-when"><span>${esc(formatAgo(s.lastRun, now))}</span><span>${esc(whenLabel(s.lastRun, now))}</span></span>
1210
+ <span class="sched-activity-when sched-activity-next"><span>${s.enabled ? 'Next' : ''}</span><span>${esc(next)}</span></span>
1211
+ <span class="sched-row-chevron" aria-hidden="true">${ICONS.chevron}</span>
1212
+ </button>
1213
+ </li>`;
1214
+ }).join('')}</ul></div>`;
1215
+ setStatus(`${plural(shown.length, 'run')}${_query.trim() ? ` match “${_query.trim()}”` : ''}`);
1216
+ restoreListState(list, saved);
1217
+ }
502
1218
 
503
- const groupOptions = [
504
- '<option value="">Ungrouped</option>',
505
- ..._groups.map(g => `<option value="${esc(g.id)}">%NAME%</option>`.replace('%NAME%', esc(g.name))),
506
- ].join('');
507
-
508
- container.innerHTML = schedules.map(s => {
509
- const tpl = getTemplate(s.templateId);
510
- const group = getGroup(s.groupId);
511
- const profile = s.profile ? CLI_PROFILES.find(p => p.id === s.profile)?.label || s.profile : 'Template default';
512
- const runtimeParts = [
513
- profile,
514
- s.model,
515
- s.contextMode && s.contextMode !== 'default' ? `context:${s.contextMode}` : '',
516
- s.effort ? `think:${s.effort}` : '',
517
- s.mcpProfile ? `mcp:${s.mcpProfile}` : '',
518
- s.usesBrowser === true ? 'browser:on' : s.usesBrowser === false ? 'browser:off' : '',
519
- ].filter(Boolean);
520
- return `
521
- <article class="sched-card${s.enabled ? '' : ' is-paused'}" draggable="true" data-schedule-id="${esc(s.id)}">
522
- <div class="sched-card-main">
523
- <div class="sched-card-title-row">
524
- <h4>${esc(s.name)}</h4>
525
- ${scheduleStatusBadge(s)}
526
- </div>
527
- <div class="sched-card-meta">${esc(tpl?.name || 'Missing template')} &middot; ${esc(describeCronClient(s.cron))}</div>
528
- <div class="sched-card-sub">
529
- <span>Next: <span class="sched-countdown" data-countdown="${esc(s.nextRun || '')}">${esc(formatNextRun(s.nextRun))}</span></span>
530
- <span>Runs: ${s.runCount || 0}</span>
531
- <span>${esc(runtimeParts.join(' · '))}</span>
532
- ${group ? `<span>${esc(group.name)}</span>` : '<span>Ungrouped</span>'}
533
- </div>
534
- </div>
535
- <div class="sched-card-controls">
536
- <select class="sched-card-group-select" data-schedule-id="${esc(s.id)}" data-value="${esc(s.groupId || '')}">
537
- ${groupOptions}
538
- </select>
539
- <button class="sched-icon-btn" data-action="schedule-toggle" data-id="${esc(s.id)}" data-tooltip="${s.enabled ? 'Pause' : 'Enable'}">${s.enabled ? ICONS.pause : ICONS.play}</button>
540
- <button class="sched-icon-btn" data-action="schedule-test" data-id="${esc(s.id)}" data-tooltip="Run now">${ICONS.bolt}</button>
541
- <button class="sched-icon-btn" data-action="schedule-edit-prompt" data-id="${esc(s.id)}" data-template-id="${esc(s.templateId || '')}" data-tooltip="Edit prompt">${ICONS.script}</button>
542
- <button class="sched-icon-btn" data-action="schedule-edit" data-id="${esc(s.id)}" data-tooltip="Edit schedule">${ICONS.edit}</button>
543
- <button class="sched-icon-btn danger" data-action="schedule-delete" data-id="${esc(s.id)}" data-tooltip="Delete">${ICONS.close}</button>
544
- </div>
545
- </article>`;
546
- }).join('');
1219
+ /** After a reopen, the schedule that was open shows again. */
1220
+ function revealExpanded() {
1221
+ const id = [..._expanded].at(-1);
1222
+ if (!id) return;
1223
+ const row = [..._panel.querySelectorAll('.sched-row')].find(el => el.dataset.scheduleId === id);
1224
+ row?.scrollIntoView({ block: 'nearest' });
1225
+ }
547
1226
 
548
- container.querySelectorAll('.sched-card-group-select').forEach(select => {
549
- select.value = select.dataset.value || '';
550
- });
1227
+ function revealSchedule(id) {
1228
+ const schedule = _schedules.find(s => s.id === id);
1229
+ if (!schedule) return;
1230
+ _query = '';
1231
+ setScope('all');
1232
+ if (schedule.groupId) _collapsed.delete(schedule.groupId);
1233
+ _expanded.add(id);
1234
+ render({ frame: true });
1235
+ const row = [..._panel.querySelectorAll('.sched-row')].find(el => el.dataset.scheduleId === id);
1236
+ row?.scrollIntoView({ block: 'center' });
1237
+ row?.querySelector('.sched-row-toggle')?.focus({ preventScroll: true });
551
1238
  }
552
1239
 
553
- function renderTimersTab() {
554
- const content = $('sched-content');
555
- if (!content) return;
1240
+ // ── Timers ──
1241
+
1242
+ function renderTimersView() {
1243
+ const main = $('sched-main');
1244
+ if (!main) return;
1245
+ _frame = 'timers';
556
1246
  const selectedTemplate = $('sched-qt-template')?.value || '';
557
1247
  const customMinutes = $('sched-qt-custom-min')?.value || '';
558
1248
  const selectedSchedule = $('sched-existing-timer-schedule')?.value || '';
559
1249
  const scheduleMinutes = $('sched-existing-timer-min')?.value || '';
560
- const templateOptions = _templates.map(t => `<option value="${esc(t.id)}"${selectedTemplate === t.id ? ' selected' : ''}>${esc(t.name)}</option>`).join('');
561
- const scheduleOptions = _schedules.map(s => `<option value="${esc(s.id)}"${selectedSchedule === s.id ? ' selected' : ''}>${esc(s.name)}</option>`).join('');
562
- const profileOptions = CLI_PROFILES.map(p => `<option value="${p.id}"${p.id === _launchProfile ? ' selected' : ''}>${p.label}</option>`).join('');
1250
+ const templateOptions = [opt('', 'Choose an automation…'), ..._templates.map(t => opt(t.id, t.name, selectedTemplate === t.id))];
1251
+ const scheduleOptions = [opt('', 'Choose a schedule…'), ...sortSchedules(_schedules, 'name').map(s => opt(s.id, s.name, selectedSchedule === s.id))];
1252
+ const profileOptions = CLI_PROFILES.map(p => opt(p.id, p.label, p.id === _launchProfile));
563
1253
  const modelOptions = renderModelOptions(_launchProfile, _launchModel, { accountId: _launchAccount });
564
1254
  const contextOptions = renderContextOptions(_launchProfile, _launchModel, _launchContextMode, { accountId: _launchAccount });
565
1255
  const effortOptions = renderEffortOptions(_launchProfile, _launchEffort);
@@ -568,50 +1258,70 @@ function renderTimersTab() {
568
1258
  const showEffort = profileSupportsEffort(_launchProfile);
569
1259
  const showMcp = _launchProfile !== 'claude-code';
570
1260
  const showAccount = _launchProfile === 'codex';
1261
+ const usesBrowser = _qtUsesBrowser !== null ? _qtUsesBrowser : !!getTemplate(selectedTemplate)?.usesBrowser;
571
1262
  const qtPresetButtons = [5, 15, 30, 60, 120, 240]
572
- .map(min => `<button class="sched-time-chip${_selectedQtMinutes === min ? ' active' : ''}" data-action="qt-select" data-minutes="${min}">${min >= 60 ? `${min / 60}h` : `${min}m`}</button>`)
1263
+ .map(min => `<button type="button" class="sched-chip" data-action="qt-select" data-minutes="${min}" aria-pressed="${_selectedQtMinutes === min}">${min >= 60 ? `${min / 60} h` : `${min} min`}</button>`)
573
1264
  .join('');
574
1265
 
575
1266
  const modelLoadKey = `${_launchProfile}:${_launchAccount}`;
576
1267
  if (isDynamicModelProfile(_launchProfile) && !_modelLoadRequests.has(modelLoadKey)) {
577
1268
  _modelLoadRequests.add(modelLoadKey);
578
- ensureModelsForProfile(_launchProfile, false, _launchAccount).then(() => { if (_panel && _tab === 'timers') renderTimersTab(); });
1269
+ ensureModelsForProfile(_launchProfile, false, _launchAccount).then(() => { if (_panel && _frame === 'timers') renderTimersView(); });
579
1270
  }
580
- if (showAccount) ensureCodexAccountsLoaded(() => { if (_panel && _tab === 'timers') renderTimersTab(); });
1271
+ if (showAccount) ensureCodexAccountsLoaded(() => { if (_panel && _frame === 'timers') renderTimersView(); });
581
1272
  if (showMcp && !Object.keys(getCachedMcpProfilePresets()).length) {
582
- fetchMcpProfilePresets().then(() => { if (_panel && _tab === 'timers') renderTimersTab(); });
583
- }
584
-
585
- content.innerHTML = `
586
- ${renderActiveTimers()}
587
- <section class="sched-tool-section">
588
- <div class="sched-section-head"><h4>Quick Timer</h4><span>Launch a template once after a delay</span></div>
589
- <div class="sched-form-grid">
590
- <label class="sched-field wide"><span>Automation</span><select id="sched-qt-template"><option value="">Select automation...</option>${templateOptions}</select></label>
591
- <label class="sched-field"><span>CLI</span><select id="sched-qt-profile">${profileOptions}</select></label>
592
- <label class="sched-field"><span>Model</span><select id="sched-qt-model">${modelOptions}</select></label>
593
- <label class="sched-field"><span>Context</span><select id="sched-qt-context">${contextOptions}</select></label>
594
- <label class="sched-field" style="${showEffort ? '' : 'display:none'}"><span>Think</span><select id="sched-qt-effort">${effortOptions}</select></label>
595
- <label class="sched-field" style="${showMcp ? '' : 'display:none'}"><span>MCP</span><select id="sched-qt-mcp">${mcpOptions}</select></label>
596
- <label class="sched-field" id="sched-qt-account-field" style="${showAccount ? '' : 'display:none'}"><span>OpenAI Account</span><select id="sched-qt-account">${accountOptions}</select></label>
597
- <label class="sched-toggle"><input type="checkbox" id="sched-qt-browser" ${_qtUsesBrowser ? 'checked' : ''}><span>Uses browser</span></label>
598
- </div>
599
- <div class="sched-time-row">
600
- ${qtPresetButtons}
601
- <input class="sched-min-input" id="sched-qt-custom-min" type="number" min="1" max="1440" value="${esc(customMinutes)}" placeholder="min">
602
- <button class="sched-primary" data-action="qt-go">${ICONS.play} Go</button>
603
- <button class="sched-secondary" data-action="qt-now">${ICONS.bolt} Run Now</button>
1273
+ fetchMcpProfilePresets().then(() => { if (_panel && _frame === 'timers') renderTimersView(); });
1274
+ }
1275
+
1276
+ main.innerHTML = `
1277
+ <div class="sched-view-head">
1278
+ <div class="sched-view-titles">
1279
+ <h2 id="sched-view-title">Timers</h2>
1280
+ <p class="sched-view-sub">Run an automation, or a saved schedule, once after a delay.</p>
604
1281
  </div>
605
- </section>
606
- <section class="sched-tool-section">
607
- <div class="sched-section-head"><h4>Saved Schedule Timer</h4><span>Fire an existing cron schedule once after a delay</span></div>
608
- <div class="sched-form-grid compact">
609
- <label class="sched-field wide"><span>Schedule</span><select id="sched-existing-timer-schedule"><option value="">Select schedule...</option>${scheduleOptions}</select></label>
610
- <label class="sched-field"><span>Minutes</span><input id="sched-existing-timer-min" type="number" min="1" max="1440" value="${esc(scheduleMinutes)}" placeholder="15"></label>
611
- <button class="sched-primary align-end" data-action="schedule-timer-create">Set Timer</button>
1282
+ </div>
1283
+ <div class="sched-load-note" id="sched-load-note" role="status"></div>
1284
+ <div class="sched-editor-body">
1285
+ <div class="sched-form">
1286
+ <section class="sched-form-section" aria-labelledby="sched-timers-active-title">
1287
+ <h3 class="sched-form-title" id="sched-timers-active-title">Waiting to run</h3>
1288
+ <div id="sched-active-timers"></div>
1289
+ </section>
1290
+ <section class="sched-form-section" aria-labelledby="sched-qt-title">
1291
+ <h3 class="sched-form-title" id="sched-qt-title">Run an automation once</h3>
1292
+ <div class="sched-form-group">
1293
+ ${dropdownRow('sched-qt-template', 'Automation', templateOptions)}
1294
+ ${dropdownRow('sched-qt-profile', 'CLI', profileOptions)}
1295
+ ${dropdownRow('sched-qt-model', 'Model', modelOptions)}
1296
+ ${dropdownRow('sched-qt-context', 'Context', contextOptions)}
1297
+ ${dropdownRow('sched-qt-effort', 'Thinking', effortOptions, { hidden: !showEffort })}
1298
+ ${dropdownRow('sched-qt-mcp', 'MCP tools', mcpOptions, { hidden: !showMcp })}
1299
+ ${dropdownRow('sched-qt-account', 'OpenAI account', accountOptions, { hidden: !showAccount, rowId: 'sched-qt-account-field' })}
1300
+ ${formRow('sched-qt-browser', 'Uses the browser', toggleHTML('sched-qt-browser', usesBrowser), { toggle: true })}
1301
+ ${formRow('sched-qt-custom-min', 'Start in', `<span class="sched-chip-list" role="group" aria-label="Delay">${qtPresetButtons}</span>
1302
+ <input class="sched-input" id="sched-qt-custom-min" type="number" min="1" max="1440" value="${esc(customMinutes)}" placeholder="min" aria-label="Minutes from now">`, { top: true })}
1303
+ </div>
1304
+ <div class="sched-form-actions">
1305
+ <button type="button" class="sched-btn" data-action="qt-now">${ICONS.bolt}<span>Run now</span></button>
1306
+ <button type="button" class="sched-btn sched-btn--primary" data-action="qt-go">${ICONS.timer}<span>Start timer</span></button>
1307
+ </div>
1308
+ </section>
1309
+ <section class="sched-form-section" aria-labelledby="sched-st-title">
1310
+ <h3 class="sched-form-title" id="sched-st-title">Fire a saved schedule once</h3>
1311
+ <p class="sched-form-intro">Runs it with its own settings; its timetable is unchanged.</p>
1312
+ <div class="sched-form-group">
1313
+ ${dropdownRow('sched-existing-timer-schedule', 'Schedule', scheduleOptions)}
1314
+ ${formRow('sched-existing-timer-min', 'Minutes from now', `<input class="sched-input" id="sched-existing-timer-min" type="number" min="1" max="1440" value="${esc(scheduleMinutes)}" placeholder="15">`)}
1315
+ </div>
1316
+ <div class="sched-form-actions">
1317
+ <button type="button" class="sched-btn sched-btn--primary" data-action="schedule-timer-create">Set timer</button>
1318
+ </div>
1319
+ </section>
612
1320
  </div>
613
- </section>
1321
+ </div>
614
1322
  `;
1323
+ renderNote();
1324
+ renderActiveTimersInto();
615
1325
 
616
1326
  const qtProfile = $('sched-qt-profile');
617
1327
  qtProfile?.addEventListener('change', () => {
@@ -624,15 +1334,15 @@ function renderTimersTab() {
624
1334
  storage.setItem('as-launch-context-mode', _launchContextMode);
625
1335
  _launchEffort = 'off';
626
1336
  storage.setItem('as-launch-effort', _launchEffort);
627
- renderTimersTab();
1337
+ renderTimersView();
628
1338
  });
629
1339
  $('sched-qt-model')?.addEventListener('change', (e) => {
630
1340
  _launchModel = e.target.value || null;
631
1341
  storage.setItem('as-launch-model', _launchModel);
632
1342
  _launchContextMode = 'default';
633
1343
  storage.setItem('as-launch-context-mode', _launchContextMode);
634
- const select = $('sched-qt-context');
635
- if (select) select.innerHTML = renderContextOptions(_launchProfile, _launchModel, _launchContextMode, { accountId: _launchAccount });
1344
+ const contextPopup = $('sched-qt-context');
1345
+ if (contextPopup) setDropdownItems(contextPopup, renderContextOptions(_launchProfile, _launchModel, _launchContextMode, { accountId: _launchAccount }));
636
1346
  });
637
1347
  $('sched-qt-context')?.addEventListener('change', (e) => {
638
1348
  _launchContextMode = e.target.value || 'default';
@@ -655,10 +1365,10 @@ function renderTimersTab() {
655
1365
  const models = automationModelOptions('codex', getModelsForProfile('codex', _launchAccount));
656
1366
  if (!models.some((model) => model.id === _launchModel)) _launchModel = (models.find((model) => model.isDefault) || models[0])?.id || null;
657
1367
  storage.setItem('as-launch-model', _launchModel || '');
658
- const modelSelect = $('sched-qt-model');
659
- if (modelSelect) modelSelect.innerHTML = renderModelOptions('codex', _launchModel, { accountId: _launchAccount });
660
- const contextSelect = $('sched-qt-context');
661
- if (contextSelect) contextSelect.innerHTML = renderContextOptions('codex', _launchModel, _launchContextMode, { accountId: _launchAccount });
1368
+ const modelPopup = $('sched-qt-model');
1369
+ if (modelPopup) setDropdownItems(modelPopup, renderModelOptions('codex', _launchModel, { accountId: _launchAccount }));
1370
+ const contextPopup = $('sched-qt-context');
1371
+ if (contextPopup) setDropdownItems(contextPopup, renderContextOptions('codex', _launchModel, _launchContextMode, { accountId: _launchAccount }));
662
1372
  });
663
1373
  });
664
1374
  $('sched-qt-browser')?.addEventListener('change', (e) => { _qtUsesBrowser = e.target.checked; });
@@ -672,52 +1382,40 @@ function renderTimersTab() {
672
1382
  });
673
1383
  }
674
1384
 
1385
+ // The choices of each launch popup (cc-dropdown.js entries, `opt`).
675
1386
  function renderModelOptions(profile, selected, { showService = false, accountId = 'default' } = {}) {
676
1387
  const models = automationModelOptions(profile, getModelsForProfile(profile, accountId));
677
1388
  selected = normalizeAutomationSelection(profile, selected).model || '';
678
1389
  if (!models.length) {
679
1390
  // List not loaded yet — preserve any stored selection so saving the form cannot
680
- // blank it (a blank <select> makes save send model:null, which DELETES the override).
681
- if (selected) {
682
- return [
683
- '<option value="">Template/default</option>',
684
- `<option value="${esc(selected)}" selected>${esc(selected)}</option>`,
685
- ].join('');
686
- }
687
- return '<option value="" selected>Default</option>';
1391
+ // blank it (a blank popup makes save send model:null, which DELETES the override).
1392
+ if (selected) return [opt('', 'Template/default'), opt(selected, selected, true)];
1393
+ return [opt('', 'Default', true)];
688
1394
  }
689
1395
  const known = !!selected && models.some(m => modelMatchesSelector(m, selected));
690
1396
  const current = selected || '';
691
1397
  const opts = [
692
- `<option value=""${!current ? ' selected' : ''}>Template/default</option>`,
693
- ...models.map(m => {
694
- const val = modelSelectorValue(m);
695
- const label = formatModelOptionLabel(m, {
696
- includeService: showService && profile === 'opencode',
697
- });
698
- return `<option value="${esc(val)}"${modelMatchesSelector(m, current) ? ' selected' : ''}>${esc(label)}</option>`;
699
- }),
1398
+ opt('', 'Template/default', !current),
1399
+ ...models.map(m => opt(modelSelectorValue(m), formatModelOptionLabel(m, {
1400
+ includeService: showService && profile === 'opencode',
1401
+ }), modelMatchesSelector(m, current))),
700
1402
  ];
701
1403
  // Preserve a stored model that isn't in the (still-loading or curated) list so the
702
1404
  // dropdown never silently blanks it and a save can't wipe the persisted override.
703
- if (selected && !known) {
704
- opts.push(`<option value="${esc(selected)}" selected>${esc(selected)} (saved)</option>`);
705
- }
706
- return opts.join('');
1405
+ if (selected && !known) opts.push(opt(selected, `${selected} (saved)`, true));
1406
+ return opts;
707
1407
  }
708
1408
 
709
1409
  function renderContextOptions(profile, model, selected, { accountId = 'default', inherit = false } = {}) {
710
1410
  const models = getModelsForProfile(profile, accountId);
711
1411
  const choices = automationContextOptions(profile, model, models);
712
1412
  const current = selected || (inherit ? '' : 'default');
713
- const options = inherit ? [`<option value=""${!current ? ' selected' : ''}>No override</option>`] : [];
714
- for (const choice of choices) {
715
- options.push(`<option value="${esc(choice.id)}"${current === choice.id ? ' selected' : ''}>${esc(choice.label)}</option>`);
716
- }
1413
+ const options = inherit ? [opt('', 'No override', !current)] : [];
1414
+ for (const choice of choices) options.push(opt(choice.id, choice.label, current === choice.id));
717
1415
  if (current && !choices.some((choice) => choice.id === current)) {
718
- options.push(`<option value="${esc(current)}" selected>${esc(current)} (unavailable — choose another)</option>`);
1416
+ options.push(opt(current, `${current} (unavailable — choose another)`, true));
719
1417
  }
720
- return options.join('');
1418
+ return options;
721
1419
  }
722
1420
 
723
1421
  function contextChoiceAvailable(profile, model, contextMode, accountId = 'default') {
@@ -731,96 +1429,192 @@ function modelChoiceAvailable(profile, model, accountId = 'default') {
731
1429
  return !models.length || models.some((entry) => entry.id === model);
732
1430
  }
733
1431
 
734
- function renderEffortOptions(profile, selected) {
1432
+ // `inheritLabel` adds the "no override" choice ('') of the editors, apart from an explicit 'off'
1433
+ // (Claude Code: no thinking), which is a value of its own: a run takes it over the group's level.
1434
+ function renderEffortOptions(profile, selected, { inheritLabel = null } = {}) {
735
1435
  const levels = getEffortLevelsForProfile(profile);
736
- if (!levels.length) return '<option value="off" selected>Default</option>';
737
- const current = selected && levels.some(e => e.id === selected) ? selected : null;
738
- return levels.map(e => `<option value="${esc(e.id)}"${e.id === current ? ' selected' : ''}>${esc(e.label)}${e.desc ? ' - ' + esc(e.desc) : ''}</option>`).join('');
1436
+ const value = selected || '';
1437
+ if (!inheritLabel && !levels.length) return [opt('off', 'Default', true)];
1438
+ const known = !!value && levels.some(e => e.id === value);
1439
+ const opts = inheritLabel ? [opt('', inheritLabel, !value)] : [];
1440
+ for (const e of levels) opts.push(opt(e.id, `${e.label}${e.desc ? ' - ' + e.desc : ''}`, e.id === value));
1441
+ // A saved level this CLI does not list (set for another CLI, or by hand) stays selected,
1442
+ // so saving the form cannot drop it (the model, MCP and account popups do the same).
1443
+ if (value && !known) opts.push(opt(value, `${value} (saved)`, true));
1444
+ return opts;
739
1445
  }
740
1446
 
741
1447
  function renderMcpOptions(selected) {
742
1448
  const presets = getCachedMcpProfilePresets();
743
1449
  const entries = Object.entries(presets);
744
1450
  if (!entries.length) {
745
- if (!selected) return '<option value="">Loading...</option>';
746
- return `<option value="${esc(selected)}" selected>${esc(selected)} (loading...)</option>`;
1451
+ if (!selected) return [opt('', 'Loading...')];
1452
+ return [opt(selected, `${selected} (loading...)`, true)];
747
1453
  }
748
- const current = selected ? (presets[selected] ? selected : (presets.full ? 'full' : entries[0][0])) : null;
749
- return entries.map(([id, p]) => {
1454
+ const known = !!selected && !!presets[selected];
1455
+ const options = entries.map(([id, p]) => {
750
1456
  const label = p.label || id;
751
1457
  const tools = p.tools != null ? ` - ${p.tools} tools` : '';
752
- return `<option value="${esc(id)}"${id === current ? ' selected' : ''}>${esc(label)}${esc(tools)}</option>`;
753
- }).join('');
1458
+ return opt(id, `${label}${tools}`, known && id === selected);
1459
+ });
1460
+ // A saved tool list that is not a preset ("browser,browser_twitter") stays selected, so saving
1461
+ // the form cannot quietly replace it with another preset (the model popup does the same).
1462
+ if (selected && !known) options.push(opt(selected, `${selected} (saved)`, true));
1463
+ return options;
754
1464
  }
755
1465
 
756
- // Render <option>s for the Codex account selector. `allowInherit` adds a
1466
+ // The choices of the Codex account popup. `allowInherit` adds a
757
1467
  // "Template/group default" option (used by schedule/group forms); the timers
758
1468
  // tab passes false so it always resolves to a concrete account.
759
1469
  function renderAccountOptions(selected, allowInherit = false) {
760
1470
  const accounts = getCachedCodexAccounts();
761
- const current = accounts.some(a => a.id === selected) ? selected : (allowInherit ? '' : 'default');
1471
+ const known = !!selected && accounts.some(a => a.id === selected);
1472
+ // A saved account the list does not show (the list has not loaded yet, or the account is gone)
1473
+ // stays selected: the editors collect the popup before they redraw, and a blank one would clear it.
1474
+ const keep = allowInherit && !!selected && !known;
1475
+ const current = known || keep ? selected : (allowInherit ? '' : 'default');
762
1476
  const opts = accounts.map(a => {
763
1477
  const label = a.label || a.email || (a.isDefault ? 'Default' : a.id);
764
1478
  const plan = a.planType ? ` (${a.planType})` : '';
765
- return `<option value="${esc(a.id)}"${a.id === current ? ' selected' : ''}>${esc(label)}${esc(plan)}</option>`;
1479
+ return opt(a.id, `${label}${plan}`, a.id === current);
766
1480
  });
767
- if (allowInherit) {
768
- opts.unshift(`<option value=""${!current ? ' selected' : ''}>Template/group default</option>`);
769
- }
770
- return opts.join('');
1481
+ if (allowInherit) opts.unshift(opt('', 'Template/group default', !current));
1482
+ if (keep) opts.push(opt(selected, `${selected} (saved)`, true));
1483
+ return opts;
771
1484
  }
772
1485
 
773
- // Kick off a one-time fetch of accounts; re-render the active tab when ready.
1486
+ // Kick off a one-time fetch of accounts; re-render the active view when ready.
774
1487
  function ensureCodexAccountsLoaded(rerender) {
775
1488
  if (_codexAccountsRequested) return;
776
1489
  _codexAccountsRequested = true;
777
1490
  fetchCodexAccounts().then(() => { if (_panel) rerender?.(); }).catch(() => {});
778
1491
  }
779
1492
 
780
- function renderActiveTimers() {
1493
+ function renderActiveTimersInto() {
1494
+ const box = $('sched-active-timers');
1495
+ if (!box) return;
781
1496
  const scheduleTimers = Object.entries(_scheduleTimerData).map(([scheduleId, timer]) => {
782
1497
  const schedule = _schedules.find(s => s.id === scheduleId);
1498
+ const name = schedule?.name || 'Schedule';
783
1499
  return `
784
- <div class="sched-active-timer">
785
- <span>${esc(schedule?.name || 'Schedule')}</span>
786
- <strong>${formatTimerCountdown(timer.firesAt)}</strong>
787
- <button class="sched-icon-btn" data-action="schedule-timer-cancel" data-id="${esc(scheduleId)}">${ICONS.close}</button>
1500
+ <div class="sched-timer-row" data-timer-id="${esc(scheduleId)}" data-timer-kind="schedule">
1501
+ <span class="sched-timer-name">${esc(name)}<span class="sched-timer-meta">Saved schedule, once</span></span>
1502
+ <strong class="sched-timer-count" data-fires-at="${esc(timer.firesAt)}">${formatTimerCountdown(timer.firesAt)}</strong>
1503
+ <button type="button" class="sched-btn" data-action="schedule-timer-cancel" data-id="${esc(scheduleId)}" aria-label="Cancel the timer for ${esc(name)}">Cancel</button>
788
1504
  </div>`;
789
1505
  }).join('');
790
1506
 
791
- const quickTimers = _quickTimers.map(timer => `
792
- <div class="sched-active-timer">
793
- <span>${esc(timer.templateName)}${timer.profile ? ` · ${esc(timer.profile)}` : ''}${timer.model ? ` · ${esc(timer.model)}` : ''}${timer.contextMode && timer.contextMode !== 'default' ? ` · context:${esc(timer.contextMode)}` : ''}${timer.effort ? ` · think:${esc(timer.effort)}` : ''}${timer.mcpProfile ? ` · mcp:${esc(timer.mcpProfile)}` : ''}</span>
794
- <strong>${formatTimerCountdown(timer.firesAt)}</strong>
795
- <button class="sched-icon-btn" data-action="qt-cancel" data-id="${esc(timer.id)}">${ICONS.close}</button>
796
- </div>
797
- `).join('');
1507
+ const quickTimers = _quickTimers.map(timer => {
1508
+ const meta = [
1509
+ timer.profile ? CLI_PROFILES.find(p => p.id === timer.profile)?.label || timer.profile : '',
1510
+ timer.model,
1511
+ timer.contextMode && timer.contextMode !== 'default' ? `context ${timer.contextMode}` : '',
1512
+ timer.effort ? `thinking ${timer.effort}` : '',
1513
+ timer.mcpProfile ? `MCP ${timer.mcpProfile}` : '',
1514
+ ].filter(Boolean).join(' · ');
1515
+ return `
1516
+ <div class="sched-timer-row" data-timer-id="${esc(timer.id)}" data-timer-kind="quick">
1517
+ <span class="sched-timer-name">${esc(timer.templateName)}<span class="sched-timer-meta">${esc(meta || 'Automation, once')}</span></span>
1518
+ <strong class="sched-timer-count" data-fires-at="${esc(timer.firesAt)}">${formatTimerCountdown(timer.firesAt)}</strong>
1519
+ <button type="button" class="sched-btn" data-action="qt-cancel" data-id="${esc(timer.id)}" aria-label="Cancel the timer for ${esc(timer.templateName)}">Cancel</button>
1520
+ </div>`;
1521
+ }).join('');
798
1522
 
799
- if (!scheduleTimers && !quickTimers) return '';
800
- return `<section class="sched-active-timers">${quickTimers}${scheduleTimers}</section>`;
1523
+ // The timers load on their own: until they answer (or when they could not), say so here.
1524
+ const note = _timersError
1525
+ ? `<div class="sched-load-note">${ICONS.alert}<span>${esc(_timersError)}</span><button type="button" class="sched-btn" data-action="retry-timers">Try again</button></div>`
1526
+ : '';
1527
+ const empty = _timersError ? '' : `<p class="sched-form-intro">${_timersLoaded ? 'No timers are waiting.' : 'Loading timers…'}</p>`;
1528
+ box.innerHTML = note + (scheduleTimers || quickTimers ? `<div class="sched-form-group">${quickTimers}${scheduleTimers}</div>` : empty);
801
1529
  }
802
1530
 
803
- function renderGroupOverrideSummary(group) {
804
- const parts = [];
805
- if (group.profile) {
806
- const p = CLI_PROFILES.find(c => c.id === group.profile);
807
- parts.push(p ? p.label : group.profile);
1531
+ // ── Form pieces (Settings grouped rows) ──
1532
+
1533
+ function formRow(id, label, control, { help = '', hidden = false, top = false, rowId = '', toggle = false, labelId = '' } = {}) {
1534
+ // A switch sits at the end of its row, its explanation under the label (as in Settings).
1535
+ if (toggle) {
1536
+ return `<div class="sched-form-row sched-form-row--switch"${rowId ? ` id="${rowId}"` : ''}${hidden ? ' hidden' : ''}>
1537
+ <div class="sched-form-stack">
1538
+ <label class="sched-form-label" for="${id}">${esc(label)}</label>
1539
+ ${help ? `<p class="sched-help" id="${id}-help">${help}</p>` : ''}
1540
+ </div>
1541
+ <div class="sched-form-control">${control}</div>
1542
+ </div>`;
808
1543
  }
809
- if (group.model) parts.push(group.model);
810
- if (group.contextMode && group.contextMode !== 'default') parts.push(`context:${group.contextMode}`);
811
- if (group.effort) parts.push(`think:${group.effort}`);
812
- if (group.mcpProfile) parts.push(`mcp:${group.mcpProfile}`);
813
- if (group.usesBrowser === true) parts.push('browser:on');
814
- else if (group.usesBrowser === false) parts.push('browser:off');
815
- return parts.length ? `<span class="sched-group-overrides">${esc(parts.join(' · '))}</span>` : '';
1544
+ // A popup is named by its label (aria-labelledby): its value lives in a hidden input no label can point at.
1545
+ const labelAttrs = labelId ? `id="${labelId}"` : `for="${id}"`;
1546
+ return `<div class="sched-form-row${top || help ? ' sched-form-row--top' : ''}"${rowId ? ` id="${rowId}"` : ''}${hidden ? ' hidden' : ''}>
1547
+ <label class="sched-form-label" ${labelAttrs}>${esc(label)}</label>
1548
+ <div class="sched-form-stack">
1549
+ <div class="sched-form-control">${control}</div>
1550
+ ${help ? `<p class="sched-help" id="${id}-help">${help}</p>` : ''}
1551
+ </div>
1552
+ </div>`;
1553
+ }
1554
+
1555
+ /** A row with a popup (cc-dropdown.js): its value is the hidden input `id`, its choices `entries`. */
1556
+ function dropdownRow(id, label, entries, rowOpts = {}) {
1557
+ return formRow(id, label, dropdownHTML(id, entries, { labelledBy: `${id}-label` }), { ...rowOpts, labelId: `${id}-label` });
1558
+ }
1559
+
1560
+ function toggleHTML(id, checked, describedBy = '') {
1561
+ return `<label class="sched-toggle"><input type="checkbox" id="${id}" role="switch"${checked ? ' checked' : ''}${describedBy ? ` aria-describedby="${describedBy}"` : ''}><span class="sched-toggle-track" aria-hidden="true"></span></label>`;
1562
+ }
1563
+
1564
+ // An editor is drawn again when a list it needs arrives (models, MCP presets, accounts), which
1565
+ // can be while the user types: the focused field (and its caret), the scroll position and an
1566
+ // opened Day themes section are put back as they were.
1567
+ function redrawEditor(main, html) {
1568
+ const active = document.activeElement;
1569
+ const focusId = active && main.contains(active) ? active.id : '';
1570
+ const caret = focusId && typeof active.selectionStart === 'number' ? [active.selectionStart, active.selectionEnd] : null;
1571
+ const scroll = main.querySelector('.sched-editor-body')?.scrollTop ?? 0;
1572
+ const opened = main.querySelector('details.sched-form-section')?.open;
1573
+ main.innerHTML = html;
1574
+ const body = main.querySelector('.sched-editor-body');
1575
+ if (body) body.scrollTop = scroll;
1576
+ const details = main.querySelector('details.sched-form-section');
1577
+ if (details && opened) details.open = true;
1578
+ const field = focusId ? document.getElementById(focusId) : null;
1579
+ if (field) {
1580
+ field.focus({ preventScroll: true });
1581
+ if (caret) try { field.setSelectionRange(caret[0], caret[1]); } catch { /* not a text field */ }
1582
+ }
1583
+ }
1584
+
1585
+ function editorHead({ back, title, sub, titleId }) {
1586
+ return `<header class="sched-editor-head">
1587
+ <button type="button" class="sched-icon-btn" data-action="${back}" aria-label="Back to the list" data-tooltip="Back (Esc)">${ICONS.back}</button>
1588
+ <div class="sched-editor-titles"><h2 id="${titleId}">${esc(title)}</h2>${sub ? `<p>${esc(sub)}</p>` : ''}</div>
1589
+ </header>`;
1590
+ }
1591
+
1592
+ // ── Group editor ──
1593
+
1594
+ function openGroupEditor(id) {
1595
+ _scheduleEditor = null;
1596
+ const group = id ? getGroup(id) : null;
1597
+ _groupEditorId = group ? group.id : null;
1598
+ // `_original` is the group as it was when the editor opened: a save sends only what the user
1599
+ // changed from it, so a change made elsewhere meanwhile (a sync update) is never undone.
1600
+ _groupEditorDraft = group
1601
+ ? { name: group.name, color: group.color, mutualExclusion: !!group.mutualExclusion, profile: group.profile || null, model: group.model || null, contextMode: group.contextMode || null, effort: group.effort || null, mcpProfile: group.mcpProfile || null, codexAccountId: group.codexAccountId || null, usesBrowser: group.usesBrowser, _original: JSON.parse(JSON.stringify(group)) }
1602
+ : { name: '', color: DEFAULT_GROUP_COLOR, mutualExclusion: false, profile: null, model: null, contextMode: null, effort: null, mcpProfile: null, codexAccountId: null, usesBrowser: undefined };
1603
+ ensureModelsForProfile(_groupEditorDraft.profile || 'claude-code', false, _groupEditorDraft.codexAccountId || 'default').then(() => {
1604
+ if (_panel && _groupEditorDraft) {
1605
+ collectGroupEditorDraft();
1606
+ renderGroupEditor(_groupEditorId ? getGroup(_groupEditorId) : null);
1607
+ }
1608
+ });
816
1609
  }
817
1610
 
818
1611
  function collectGroupEditorDraft() {
819
1612
  if (!_groupEditorDraft || !$('sched-ge-name')) return;
820
1613
  const browserRaw = $('sched-ge-browser')?.value || '';
821
1614
  Object.assign(_groupEditorDraft, {
822
- name: $('sched-ge-name')?.value || _groupEditorDraft.name,
1615
+ name: $('sched-ge-name')?.value ?? _groupEditorDraft.name,
823
1616
  color: $('sched-ge-color')?.value || _groupEditorDraft.color,
1617
+ mutualExclusion: $('sched-ge-mutex')?.checked ?? _groupEditorDraft.mutualExclusion,
824
1618
  profile: $('sched-ge-profile')?.value || null,
825
1619
  model: $('sched-ge-model')?.value || null,
826
1620
  contextMode: $('sched-ge-context')?.value || null,
@@ -832,179 +1626,159 @@ function collectGroupEditorDraft() {
832
1626
  }
833
1627
 
834
1628
  function renderGroupEditor(group) {
835
- const d = _groupEditorDraft || group;
1629
+ const main = $('sched-main');
1630
+ if (!main || !_groupEditorDraft) return;
1631
+ _frame = 'group-editor';
1632
+ // A group deleted elsewhere while it is edited is still this group (a save then says it is gone).
1633
+ const creating = !_groupEditorId;
1634
+ const d = _groupEditorDraft;
836
1635
  const profileForModels = d.profile || 'claude-code';
837
1636
  const accountId = d.codexAccountId || 'default';
838
1637
  const profileOptions = [
839
- `<option value=""${!d.profile ? ' selected' : ''}>No override</option>`,
840
- ...CLI_PROFILES.map(p => `<option value="${p.id}"${d.profile === p.id ? ' selected' : ''}>${p.label}</option>`),
841
- ].join('');
1638
+ opt('', 'No override', !d.profile),
1639
+ ...CLI_PROFILES.map(p => opt(p.id, p.label, d.profile === p.id)),
1640
+ ];
842
1641
  const modelOptions = renderModelOptions(profileForModels, d.model || '', { showService: true, accountId });
843
1642
  const contextOptions = renderContextOptions(profileForModels, d.model, d.contextMode, { accountId, inherit: true });
844
- const effortOptions = renderEffortOptions(profileForModels, d.effort || '');
1643
+ const effortOptions = renderEffortOptions(profileForModels, d.effort || '', { inheritLabel: 'No override' });
845
1644
  const mcpOptions = renderMcpOptions(d.mcpProfile || '');
846
1645
  const accountOptions = renderAccountOptions(d.codexAccountId || '', true);
847
1646
  const showEffort = profileSupportsEffort(profileForModels);
848
1647
  const showMcp = profileForModels !== 'claude-code';
849
1648
  const showAccount = profileForModels === 'codex';
850
- if (showAccount) ensureCodexAccountsLoaded(() => { collectGroupEditorDraft(); renderGroupsTab(); });
851
- if (showMcp && !Object.keys(getCachedMcpProfilePresets()).length) {
852
- fetchMcpProfilePresets().then(() => { if (_panel && _groupEditorId) { collectGroupEditorDraft(); renderGroupsTab(); } });
853
- }
1649
+ const rerender = () => { if (_panel && _groupEditorDraft) { collectGroupEditorDraft(); renderGroupEditor(_groupEditorId ? getGroup(_groupEditorId) : null); } };
1650
+ if (showAccount) ensureCodexAccountsLoaded(rerender);
1651
+ if (showMcp && !Object.keys(getCachedMcpProfilePresets()).length) fetchMcpProfilePresets().then(rerender);
854
1652
  const browserValue = d.usesBrowser === true ? 'true' : d.usesBrowser === false ? 'false' : '';
855
- return `
856
- <div class="sched-group-editor-body">
857
- <div class="sched-form-grid compact">
858
- <label class="sched-field wide"><span>Name</span><input id="sched-ge-name" type="text" value="${esc(d.name)}"></label>
859
- <label class="sched-field"><span>Color</span><input id="sched-ge-color" type="color" value="${esc(d.color || '#4fc3f7')}"></label>
860
- </div>
861
- <div class="sched-section-head" style="margin-top:10px"><h4>Launch Overrides</h4><span>Applies to all schedules in this group (schedule-level overrides take priority)</span></div>
862
- <div class="sched-form-grid">
863
- <label class="sched-field"><span>CLI</span><select id="sched-ge-profile">${profileOptions}</select></label>
864
- <label class="sched-field"><span>Model</span><select id="sched-ge-model">${modelOptions}</select></label>
865
- <label class="sched-field"><span>Context</span><select id="sched-ge-context">${contextOptions}</select></label>
866
- <label class="sched-field" style="${showEffort ? '' : 'display:none'}"><span>Think</span><select id="sched-ge-effort">
867
- <option value=""${!d.effort ? ' selected' : ''}>No override</option>
868
- ${effortOptions}
869
- </select></label>
870
- <label class="sched-field" style="${showMcp ? '' : 'display:none'}"><span>MCP</span><select id="sched-ge-mcp">
871
- <option value=""${!d.mcpProfile ? ' selected' : ''}>No override</option>
872
- ${mcpOptions}
873
- </select></label>
874
- <label class="sched-field" id="sched-ge-account-field" style="${showAccount ? '' : 'display:none'}"><span>OpenAI Account</span><select id="sched-ge-account">${accountOptions}</select></label>
875
- <label class="sched-field"><span>Browser</span><select id="sched-ge-browser">
876
- <option value=""${browserValue === '' ? ' selected' : ''}>No override</option>
877
- <option value="true"${browserValue === 'true' ? ' selected' : ''}>Force on</option>
878
- <option value="false"${browserValue === 'false' ? ' selected' : ''}>Force off</option>
879
- </select></label>
880
- </div>
881
- <div class="sched-group-editor-actions">
882
- <button class="sched-secondary" data-action="group-editor-cancel">Cancel</button>
883
- <button class="sched-primary" data-action="group-editor-save" data-id="${esc(group.id)}">Save Changes</button>
884
- </div>
885
- </div>`;
886
- }
1653
+ const ordered = orderedGroups();
1654
+ const position = group ? ordered.findIndex(g => g.id === group.id) : -1;
1655
+ const members = group ? _schedules.filter(s => s.groupId === group.id).length : 0;
887
1656
 
888
- function renderGroupsTab() {
889
- const content = $('sched-content');
890
- if (!content) return;
891
- const rows = _groups.map((group, idx) => {
892
- const count = _schedules.filter(s => s.groupId === group.id).length;
893
- const isEditing = _groupEditorId === group.id;
894
- const overrideSummary = renderGroupOverrideSummary(group);
895
- return `
896
- <article class="sched-group-card${isEditing ? ' editing' : ''}" data-group-id="${esc(group.id)}">
897
- <div class="sched-group-card-main">
898
- <span class="sched-group-color large" style="background:${esc(group.color || 'rgba(255,255,255,0.22)')}"></span>
899
- <div>
900
- <h4>${esc(group.name)}</h4>
901
- <span>${count} schedules${overrideSummary ? ' · ' : ''}${overrideSummary}</span>
902
- </div>
903
- </div>
904
- <div class="sched-group-card-actions">
905
- <label class="sched-mutex-toggle" data-tooltip="When enabled, only one schedule across all mutex-enabled groups can run at a time.">
906
- <input type="checkbox" data-action="group-mutex-toggle" data-id="${esc(group.id)}" ${group.mutualExclusion ? 'checked' : ''}>
907
- <span class="sched-mutex-label">${ICONS.lock} Mutex</span>
908
- </label>
909
- <button class="sched-icon-btn" data-action="group-up" data-id="${esc(group.id)}" ${idx === 0 ? 'disabled' : ''}>Up</button>
910
- <button class="sched-icon-btn" data-action="group-down" data-id="${esc(group.id)}" ${idx === _groups.length - 1 ? 'disabled' : ''}>Down</button>
911
- <button class="sched-icon-btn" data-action="group-edit" data-id="${esc(group.id)}">${ICONS.edit}</button>
912
- <button class="sched-icon-btn danger" data-action="group-delete" data-id="${esc(group.id)}">${ICONS.close}</button>
1657
+ redrawEditor(main, `
1658
+ <div class="sched-editor">
1659
+ ${editorHead({ back: 'group-editor-cancel', title: creating ? 'New group' : 'Edit group', sub: creating ? 'Groups keep related schedules together, and can share launch settings.' : (group || d._original)?.name, titleId: 'sched-view-title' })}
1660
+ <div class="sched-editor-body">
1661
+ <div class="sched-form">
1662
+ <section class="sched-form-section" aria-labelledby="sched-ge-sec-group">
1663
+ <h3 class="sched-form-title" id="sched-ge-sec-group">Group</h3>
1664
+ <div class="sched-form-group">
1665
+ ${formRow('sched-ge-name', 'Name', `<input class="sched-input" id="sched-ge-name" type="text" value="${esc(d.name || '')}" placeholder="Twitter cadence" autocomplete="off">`)}
1666
+ ${formRow('sched-ge-color', 'Colour', `<input class="sched-input" id="sched-ge-color" type="color" value="${esc(/^#[0-9a-f]{6}$/i.test(d.color || '') ? d.color : DEFAULT_GROUP_COLOR)}">`)}
1667
+ ${formRow('sched-ge-mutex', 'One at a time', toggleHTML('sched-ge-mutex', !!d.mutualExclusion, 'sched-ge-mutex-help'),
1668
+ { help: 'Schedules in every group set to one at a time take turns: while one runs, the others wait.', toggle: true })}
1669
+ </div>
1670
+ </section>
1671
+ <section class="sched-form-section" aria-labelledby="sched-ge-sec-launch">
1672
+ <h3 class="sched-form-title" id="sched-ge-sec-launch">Launch overrides</h3>
1673
+ <p class="sched-form-intro">Apply to every schedule in this group. A schedule's own setting wins.</p>
1674
+ <div class="sched-form-group">
1675
+ ${dropdownRow('sched-ge-profile', 'CLI', profileOptions)}
1676
+ ${dropdownRow('sched-ge-model', 'Model', modelOptions)}
1677
+ ${dropdownRow('sched-ge-context', 'Context', contextOptions)}
1678
+ ${dropdownRow('sched-ge-effort', 'Thinking', effortOptions, { hidden: !showEffort })}
1679
+ ${dropdownRow('sched-ge-mcp', 'MCP tools', [opt('', 'No override', !d.mcpProfile), ...mcpOptions], { hidden: !showMcp })}
1680
+ ${dropdownRow('sched-ge-account', 'OpenAI account', accountOptions, { hidden: !showAccount, rowId: 'sched-ge-account-field' })}
1681
+ ${dropdownRow('sched-ge-browser', 'Browser', [
1682
+ opt('', 'No override', browserValue === ''),
1683
+ opt('true', 'Always on', browserValue === 'true'),
1684
+ opt('false', 'Always off', browserValue === 'false'),
1685
+ ])}
1686
+ </div>
1687
+ </section>
1688
+ ${!group ? '' : `
1689
+ <section class="sched-form-section" aria-labelledby="sched-ge-sec-order">
1690
+ <h3 class="sched-form-title" id="sched-ge-sec-order">Order</h3>
1691
+ <div class="sched-form-group">
1692
+ <div class="sched-form-row">
1693
+ <span class="sched-form-label">Position ${position + 1} of ${ordered.length}</span>
1694
+ <div class="sched-form-control">
1695
+ <button type="button" class="sched-btn" data-action="group-up" data-id="${esc(group.id)}"${position <= 0 ? ' disabled' : ''}>${ICONS.up}<span>Move up</span></button>
1696
+ <button type="button" class="sched-btn" data-action="group-down" data-id="${esc(group.id)}"${position >= ordered.length - 1 ? ' disabled' : ''}>${ICONS.down}<span>Move down</span></button>
1697
+ </div>
1698
+ </div>
1699
+ </div>
1700
+ </section>
1701
+ <section class="sched-form-section" aria-labelledby="sched-ge-sec-delete">
1702
+ <h3 class="sched-form-title" id="sched-ge-sec-delete">Delete</h3>
1703
+ <div class="sched-form-group">
1704
+ <div class="sched-form-row">
1705
+ <span class="sched-form-label">${members ? `Its ${plural(members, 'schedule')} move to Ungrouped and keep running.` : 'The group is empty.'}</span>
1706
+ <div class="sched-form-control">
1707
+ <button type="button" class="sched-btn sched-btn--danger" data-action="group-delete" data-id="${esc(group.id)}">${ICONS.trash}<span>Delete group…</span></button>
1708
+ </div>
1709
+ </div>
1710
+ </div>
1711
+ </section>`}
913
1712
  </div>
914
- ${isEditing ? renderGroupEditor(group) : ''}
915
- </article>`;
916
- }).join('');
917
-
918
- content.innerHTML = `
919
- <section class="sched-tool-section">
920
- <div class="sched-section-head"><h4>Create Group</h4><span>Use groups to categorize schedules freely</span></div>
921
- <div class="sched-form-grid compact">
922
- <label class="sched-field wide"><span>Name</span><input id="sched-group-name" type="text" placeholder="e.g. Twitter cadence"></label>
923
- <label class="sched-field"><span>Color</span><input id="sched-group-color" type="color" value="#4fc3f7"></label>
924
- <label class="sched-mutex-toggle sched-mutex-create" data-tooltip="Only one schedule across all mutex-enabled groups can run at a time">
925
- <input type="checkbox" id="sched-group-mutex"><span class="sched-mutex-label">${ICONS.lock} Mutex</span>
926
- </label>
927
- <button class="sched-primary align-end" data-action="group-save-new">${ICONS.plus} Create</button>
928
1713
  </div>
929
- </section>
930
- <div class="sched-group-card-list">${rows || '<div class="sched-empty">No groups yet.</div>'}</div>
931
- `;
932
-
933
- if (_groupEditorId && _groupEditorDraft) {
934
- const profileSelect = $('sched-ge-profile');
935
- if (profileSelect) {
936
- profileSelect.addEventListener('change', () => {
937
- collectGroupEditorDraft();
938
- _groupEditorDraft.profile = profileSelect.value || null;
939
- _groupEditorDraft.model = null;
940
- _groupEditorDraft.contextMode = _groupEditorDraft.profile ? 'default' : null;
941
- _groupEditorDraft.effort = null;
942
- _groupEditorDraft.mcpProfile = null;
943
- _groupEditorDraft.codexAccountId = null;
944
- ensureModelsForProfile(_groupEditorDraft.profile || 'claude-code', false, _groupEditorDraft.codexAccountId || 'default').then(() => renderGroupsTab());
945
- renderGroupsTab();
946
- });
947
- }
948
- $('sched-ge-model')?.addEventListener('change', (e) => {
949
- _groupEditorDraft.model = e.target.value || null;
950
- _groupEditorDraft.contextMode = _groupEditorDraft.model ? 'default' : null;
951
- const select = $('sched-ge-context');
952
- if (select) select.innerHTML = renderContextOptions(_groupEditorDraft.profile || 'claude-code', _groupEditorDraft.model, _groupEditorDraft.contextMode, { accountId: _groupEditorDraft.codexAccountId || 'default', inherit: true });
953
- });
954
- $('sched-ge-context')?.addEventListener('change', (e) => { _groupEditorDraft.contextMode = e.target.value || null; });
955
- $('sched-ge-account')?.addEventListener('change', (e) => {
956
- _groupEditorDraft.codexAccountId = e.target.value || null;
957
- _groupEditorDraft.contextMode = 'default';
958
- ensureModelsForProfile('codex', false, _groupEditorDraft.codexAccountId || 'default').then(() => {
959
- const account = _groupEditorDraft.codexAccountId || 'default';
960
- const modelSelect = $('sched-ge-model');
961
- if (modelSelect) modelSelect.innerHTML = renderModelOptions('codex', _groupEditorDraft.model, { accountId: account });
962
- const contextSelect = $('sched-ge-context');
963
- if (contextSelect) contextSelect.innerHTML = renderContextOptions('codex', _groupEditorDraft.model, _groupEditorDraft.contextMode, { accountId: account, inherit: true });
964
- });
1714
+ <footer class="sched-editor-actions">
1715
+ <button type="button" class="sched-btn" data-action="group-editor-cancel">Cancel</button>
1716
+ <button type="button" class="sched-btn sched-btn--primary" data-action="group-editor-save" data-id="${esc(_groupEditorId || '')}">${creating ? 'Create group' : 'Save changes'}</button>
1717
+ </footer>
1718
+ </div>`);
1719
+ wireGroupEditor();
1720
+ }
1721
+
1722
+ function wireGroupEditor() {
1723
+ const profileSelect = $('sched-ge-profile');
1724
+ profileSelect?.addEventListener('change', () => {
1725
+ collectGroupEditorDraft();
1726
+ _groupEditorDraft.profile = profileSelect.value || null;
1727
+ _groupEditorDraft.model = null;
1728
+ _groupEditorDraft.contextMode = _groupEditorDraft.profile ? 'default' : null;
1729
+ _groupEditorDraft.effort = null;
1730
+ _groupEditorDraft.mcpProfile = null;
1731
+ _groupEditorDraft.codexAccountId = null;
1732
+ const rerender = () => { if (_panel && _groupEditorDraft) renderGroupEditor(_groupEditorId ? getGroup(_groupEditorId) : null); };
1733
+ ensureModelsForProfile(_groupEditorDraft.profile || 'claude-code', false, 'default').then(() => { collectGroupEditorDraft(); rerender(); });
1734
+ rerender();
1735
+ });
1736
+ $('sched-ge-model')?.addEventListener('change', (e) => {
1737
+ _groupEditorDraft.model = e.target.value || null;
1738
+ _groupEditorDraft.contextMode = _groupEditorDraft.model ? 'default' : null;
1739
+ const contextPopup = $('sched-ge-context');
1740
+ if (contextPopup) setDropdownItems(contextPopup, renderContextOptions(_groupEditorDraft.profile || 'claude-code', _groupEditorDraft.model, _groupEditorDraft.contextMode, { accountId: _groupEditorDraft.codexAccountId || 'default', inherit: true }));
1741
+ });
1742
+ $('sched-ge-context')?.addEventListener('change', (e) => { _groupEditorDraft.contextMode = e.target.value || null; });
1743
+ $('sched-ge-account')?.addEventListener('change', (e) => {
1744
+ _groupEditorDraft.codexAccountId = e.target.value || null;
1745
+ _groupEditorDraft.contextMode = 'default';
1746
+ ensureModelsForProfile('codex', false, _groupEditorDraft.codexAccountId || 'default').then(() => {
1747
+ if (!_groupEditorDraft) return;
1748
+ const account = _groupEditorDraft.codexAccountId || 'default';
1749
+ const modelPopup = $('sched-ge-model');
1750
+ if (modelPopup) setDropdownItems(modelPopup, renderModelOptions('codex', _groupEditorDraft.model, { accountId: account }));
1751
+ const contextPopup = $('sched-ge-context');
1752
+ if (contextPopup) setDropdownItems(contextPopup, renderContextOptions('codex', _groupEditorDraft.model, _groupEditorDraft.contextMode, { accountId: account, inherit: true }));
965
1753
  });
966
- }
1754
+ });
967
1755
  }
968
1756
 
969
- function renderActivityTab() {
970
- const content = $('sched-content');
971
- if (!content) return;
972
- const rows = [..._schedules]
973
- .sort((a, b) => new Date(b.lastRun || 0) - new Date(a.lastRun || 0))
974
- .map(s => `
975
- <div class="sched-activity-row">
976
- <div>
977
- <strong>${esc(s.name)}</strong>
978
- <span>${esc(getTemplate(s.templateId)?.name || 'Missing template')}</span>
979
- </div>
980
- ${scheduleStatusBadge(s)}
981
- <span>${esc(s.lastRunResult || 'not run')}</span>
982
- <span>${esc(formatDateTime(s.lastRun))}</span>
983
- <span>Next ${esc(formatNextRun(s.nextRun))}</span>
984
- </div>
985
- `).join('');
986
-
987
- content.innerHTML = `
988
- <div class="sched-section-head"><h4>Schedule Activity</h4><span>Last run state and upcoming runs</span></div>
989
- <div class="sched-activity-list">${rows || '<div class="sched-empty">No activity yet.</div>'}</div>
990
- `;
991
- }
1757
+ // ── Schedule editor ──
992
1758
 
993
1759
  function renderScheduleEditor() {
994
1760
  const main = $('sched-main');
995
1761
  if (!main) return;
1762
+ _frame = 'schedule-editor';
996
1763
  const s = _scheduleEditor || {};
997
1764
  const isEdit = !!s.id;
998
- const templateOptions = _templates.map(t => `<option value="${esc(t.id)}"${s.templateId === t.id ? ' selected' : ''}>${esc(t.name)} (${esc(t.category || 'custom')})</option>`).join('');
1765
+ // A saved automation or group the lists do not show (deleted elsewhere, or not in what loaded)
1766
+ // stays selected: a popup without it would show the first choice, and a save would move it.
1767
+ const templateOptions = [
1768
+ opt('', 'Choose an automation…'),
1769
+ ..._templates.map(t => opt(t.id, `${t.name} (${t.category || 'custom'})`, s.templateId === t.id)),
1770
+ ...(s.templateId && !getTemplate(s.templateId) ? [opt(s.templateId, `${s.templateId} (saved)`, true)] : []),
1771
+ ];
999
1772
  const groupOptions = [
1000
- `<option value=""${!s.groupId ? ' selected' : ''}>Ungrouped</option>`,
1001
- ..._groups.map(g => `<option value="${esc(g.id)}"${s.groupId === g.id ? ' selected' : ''}>${esc(g.name)}</option>`),
1002
- ].join('');
1773
+ opt('', 'Ungrouped', !s.groupId),
1774
+ ...orderedGroups().map(g => opt(g.id, g.name, s.groupId === g.id)),
1775
+ ...(s.groupId && !getGroup(s.groupId) ? [opt(s.groupId, `${s.groupId} (saved)`, true)] : []),
1776
+ ];
1003
1777
  const tz = s.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
1004
1778
  const profileOptions = [
1005
- `<option value=""${!s.profile ? ' selected' : ''}>Template default</option>`,
1006
- ...CLI_PROFILES.map(p => `<option value="${p.id}"${s.profile === p.id ? ' selected' : ''}>${p.label}</option>`),
1007
- ].join('');
1779
+ opt('', 'Template default', !s.profile),
1780
+ ...CLI_PROFILES.map(p => opt(p.id, p.label, s.profile === p.id)),
1781
+ ];
1008
1782
  const template = getTemplate(s.templateId);
1009
1783
  const group = s.groupId ? getGroup(s.groupId) : null;
1010
1784
  const profileForModels = s.profile || group?.profile || template?.profile || 'claude-code';
@@ -1012,7 +1786,7 @@ function renderScheduleEditor() {
1012
1786
  const effectiveModel = s.model || group?.model || template?.model || null;
1013
1787
  const modelOptions = renderModelOptions(profileForModels, s.model || '', { showService: true, accountId });
1014
1788
  const contextOptions = renderContextOptions(profileForModels, effectiveModel, s.contextMode, { accountId, inherit: true });
1015
- const effortOptions = renderEffortOptions(profileForModels, s.effort || '');
1789
+ const effortOptions = renderEffortOptions(profileForModels, s.effort || '', { inheritLabel: 'Template/default' });
1016
1790
  const mcpOptions = renderMcpOptions(s.mcpProfile || '');
1017
1791
  const accountOptions = renderAccountOptions(s.codexAccountId || '', true);
1018
1792
  const showEffort = profileSupportsEffort(profileForModels);
@@ -1020,17 +1794,28 @@ function renderScheduleEditor() {
1020
1794
  const showAccount = profileForModels === 'codex';
1021
1795
  if (showAccount) ensureCodexAccountsLoaded(() => { if (_panel && _scheduleEditor) { collectScheduleEditorDraft(); renderScheduleEditor(); } });
1022
1796
  const browserValue = s.usesBrowser === true ? 'true' : s.usesBrowser === false ? 'false' : '';
1797
+ const cron = s.cron || '0 9 * * *';
1023
1798
  const presetsHTML = CRON_PRESET_GROUPS.map(group => `
1024
- <div class="sched-preset-group">
1799
+ <div class="sched-preset-group" role="group" aria-label="${esc(group.group)} presets">
1025
1800
  <span>${esc(group.group)}</span>
1026
- <div>${group.presets.map(p => `<button class="sched-preset" data-action="cron-preset" data-cron="${esc(p.cron)}" data-tooltip="${esc(p.desc)}">${esc(p.label)}</button>`).join('')}</div>
1801
+ <div class="sched-chip-list">${group.presets.map(p => `<button type="button" class="sched-chip" data-action="cron-preset" data-cron="${esc(p.cron)}" aria-pressed="${p.cron === cron}" data-tooltip="${esc(describeCron(p.cron))}">${esc(p.label)}</button>`).join('')}</div>
1027
1802
  </div>`).join('');
1028
1803
  const dayOrder = [1, 2, 3, 4, 5, 6, 0];
1029
- const dayThemes = dayOrder.map(idx => `
1030
- <label class="sched-day-field">
1031
- <span>${DAY_NAMES[idx]}</span>
1032
- <input type="text" data-day="${idx}" value="${esc(s.dayThemes?.[String(idx)]?.contextOverride || '')}" placeholder="Optional context override">
1033
- </label>`).join('');
1804
+ const hasThemes = dayOrder.some(idx => s.dayThemes?.[String(idx)]);
1805
+ // A textarea, not an input: a theme can span lines, and an input would drop its line breaks.
1806
+ // Clearing the text keeps the day's entry (and its label: the day still runs); Remove takes the
1807
+ // whole entry away.
1808
+ const dayThemes = dayOrder.map(idx => {
1809
+ const entry = s.dayThemes?.[String(idx)];
1810
+ const help = entry?.label ? `Theme: ${esc(entry.label)}` : '';
1811
+ // A day without an entry keeps the button's room, so the fields line up.
1812
+ const remove = entry
1813
+ ? `<button type="button" class="sched-icon-btn sched-icon-btn--danger sched-day-remove" data-action="day-theme-remove" data-day="${idx}" aria-label="Remove the ${DAY_LONG[idx]} theme" data-tooltip="Remove this day's theme">${ICONS.trash}</button>`
1814
+ : '<span class="sched-icon-btn sched-day-remove sched-day-remove--room" aria-hidden="true"></span>';
1815
+ return formRow(`sched-ed-day-${idx}`, DAY_LONG[idx],
1816
+ `<textarea class="sched-input sched-textarea" id="sched-ed-day-${idx}" data-day="${idx}" rows="2" spellcheck="false" placeholder="Extra context for ${DAY_LONG[idx]} runs (optional)"${help ? ` aria-describedby="sched-ed-day-${idx}-help"` : ''}>${esc(entry?.contextOverride || '')}</textarea>${remove}`,
1817
+ { help });
1818
+ }).join('');
1034
1819
 
1035
1820
  const modelLoadKey = `${profileForModels}:${accountId}`;
1036
1821
  if (isDynamicModelProfile(profileForModels) && !_modelLoadRequests.has(modelLoadKey)) {
@@ -1043,65 +1828,71 @@ function renderScheduleEditor() {
1043
1828
  fetchMcpProfilePresets().then(() => { if (_panel && _scheduleEditor) { collectScheduleEditorDraft(); renderScheduleEditor(); } });
1044
1829
  }
1045
1830
 
1046
- main.innerHTML = `
1831
+ redrawEditor(main, `
1047
1832
  <div class="sched-editor">
1048
- <div class="sched-editor-head">
1049
- <button class="sched-icon-btn" data-action="schedule-editor-close">${ICONS.back}</button>
1050
- <div>
1051
- <h3>${isEdit ? 'Edit Schedule' : 'New Schedule'}</h3>
1052
- <span>${isEdit ? esc(s.name) : 'Create a recurring cron launch'}</span>
1053
- </div>
1054
- </div>
1833
+ ${editorHead({ back: 'schedule-editor-close', title: isEdit ? 'Edit schedule' : 'New schedule', sub: isEdit ? s.name : 'Launches one of your automations on a timetable.', titleId: 'sched-view-title' })}
1055
1834
  <div class="sched-editor-body">
1056
- <div class="sched-form-grid">
1057
- <label class="sched-field"><span>Name</span><input id="sched-ed-name" type="text" value="${esc(s.name || '')}" placeholder="Morning post"></label>
1058
- <label class="sched-field"><span>Automation</span><select id="sched-ed-template"><option value="">Select automation...</option>${templateOptions}</select></label>
1059
- <label class="sched-field"><span>Group</span><select id="sched-ed-group">${groupOptions}</select></label>
1060
- <label class="sched-field"><span>Timezone</span><input id="sched-ed-tz" type="text" value="${esc(tz)}"></label>
1835
+ <div class="sched-form">
1836
+ <section class="sched-form-section" aria-labelledby="sched-ed-sec-basics">
1837
+ <h3 class="sched-form-title" id="sched-ed-sec-basics">Schedule</h3>
1838
+ <div class="sched-form-group">
1839
+ ${formRow('sched-ed-name', 'Name', `<input class="sched-input" id="sched-ed-name" type="text" value="${esc(s.name || '')}" placeholder="Morning post" autocomplete="off">`)}
1840
+ ${dropdownRow('sched-ed-template', 'Automation', templateOptions)}
1841
+ ${dropdownRow('sched-ed-group', 'Group', groupOptions)}
1842
+ ${formRow('sched-ed-enabled', 'Active', toggleHTML('sched-ed-enabled', s.enabled !== false, 'sched-ed-enabled-help'),
1843
+ { help: 'Turn it off to pause the schedule without deleting it.', toggle: true })}
1844
+ </div>
1845
+ </section>
1846
+
1847
+ <section class="sched-form-section" aria-labelledby="sched-ed-sec-when">
1848
+ <h3 class="sched-form-title" id="sched-ed-sec-when">When it runs</h3>
1849
+ <div class="sched-form-group">
1850
+ <div class="sched-form-row sched-form-row--top">
1851
+ <label class="sched-form-label" for="sched-ed-cron">Cron expression</label>
1852
+ <div class="sched-form-stack">
1853
+ <div class="sched-form-control"><input class="sched-input sched-mono" id="sched-ed-cron" type="text" value="${esc(cron)}" placeholder="0 9 * * *" spellcheck="false" autocomplete="off" aria-describedby="sched-ed-cron-desc"></div>
1854
+ <p class="sched-help" id="sched-ed-cron-desc" aria-live="polite">${esc(describeCron(cron))}</p>
1855
+ </div>
1856
+ </div>
1857
+ <div class="sched-form-row sched-form-row--top">
1858
+ <span class="sched-form-label">Presets</span>
1859
+ <div class="sched-presets">${presetsHTML}</div>
1860
+ </div>
1861
+ ${formRow('sched-ed-tz', 'Time zone', `<input class="sched-input" id="sched-ed-tz" type="text" value="${esc(tz)}" spellcheck="false" autocomplete="off">`)}
1862
+ </div>
1863
+ </section>
1864
+
1865
+ <section class="sched-form-section" aria-labelledby="sched-ed-sec-launch">
1866
+ <h3 class="sched-form-title" id="sched-ed-sec-launch">Launch overrides</h3>
1867
+ <p class="sched-form-intro">Leave a setting on its default to use the group's, then the automation's.</p>
1868
+ <div class="sched-form-group">
1869
+ ${dropdownRow('sched-ed-profile', 'CLI', profileOptions)}
1870
+ ${dropdownRow('sched-ed-model', 'Model', modelOptions)}
1871
+ ${dropdownRow('sched-ed-context', 'Context', contextOptions)}
1872
+ ${dropdownRow('sched-ed-effort', 'Thinking', effortOptions, { hidden: !showEffort })}
1873
+ ${dropdownRow('sched-ed-mcp', 'MCP tools', [opt('', 'Template/default', !s.mcpProfile), ...mcpOptions], { hidden: !showMcp })}
1874
+ ${dropdownRow('sched-ed-account', 'OpenAI account', accountOptions, { hidden: !showAccount, rowId: 'sched-ed-account-field' })}
1875
+ ${dropdownRow('sched-ed-browser', 'Browser', [
1876
+ opt('', 'Template default', browserValue === ''),
1877
+ opt('true', 'Always on', browserValue === 'true'),
1878
+ opt('false', 'Always off', browserValue === 'false'),
1879
+ ])}
1880
+ </div>
1881
+ </section>
1882
+
1883
+ <details class="sched-form-section"${hasThemes ? ' open' : ''}>
1884
+ <summary class="sched-form-title">Day themes <small>optional</small></summary>
1885
+ <p class="sched-form-intro">Extra context added to the run on that weekday, such as a topic for Mondays. Once any day has a theme, the schedule runs only on the days that have one.</p>
1886
+ <div class="sched-form-group" id="sched-ed-daythemes">${dayThemes}</div>
1887
+ </details>
1061
1888
  </div>
1062
-
1063
- <section class="sched-editor-section">
1064
- <div class="sched-section-head"><h4>Cron</h4><span id="sched-ed-cron-desc">${esc(describeCronClient(s.cron || '0 9 * * *'))}</span></div>
1065
- <div class="sched-presets">${presetsHTML}</div>
1066
- <input class="sched-cron-input" id="sched-ed-cron" type="text" value="${esc(s.cron || '0 9 * * *')}" placeholder="0 9 * * *">
1067
- </section>
1068
-
1069
- <section class="sched-editor-section">
1070
- <div class="sched-section-head"><h4>Launch Overrides</h4><span>Leave blank to use automation defaults</span></div>
1071
- <div class="sched-form-grid">
1072
- <label class="sched-field"><span>CLI</span><select id="sched-ed-profile">${profileOptions}</select></label>
1073
- <label class="sched-field"><span>Model</span><select id="sched-ed-model">${modelOptions}</select></label>
1074
- <label class="sched-field"><span>Context</span><select id="sched-ed-context">${contextOptions}</select></label>
1075
- <label class="sched-field" style="${showEffort ? '' : 'display:none'}"><span>Think</span><select id="sched-ed-effort">
1076
- <option value=""${!s.effort ? ' selected' : ''}>Template/default</option>
1077
- ${effortOptions}
1078
- </select></label>
1079
- <label class="sched-field" style="${showMcp ? '' : 'display:none'}"><span>MCP</span><select id="sched-ed-mcp">
1080
- <option value=""${!s.mcpProfile ? ' selected' : ''}>Template/default</option>
1081
- ${mcpOptions}
1082
- </select></label>
1083
- <label class="sched-field" id="sched-ed-account-field" style="${showAccount ? '' : 'display:none'}"><span>OpenAI Account</span><select id="sched-ed-account">${accountOptions}</select></label>
1084
- <label class="sched-field"><span>Browser</span><select id="sched-ed-browser">
1085
- <option value=""${browserValue === '' ? ' selected' : ''}>Template default</option>
1086
- <option value="true"${browserValue === 'true' ? ' selected' : ''}>Force on</option>
1087
- <option value="false"${browserValue === 'false' ? ' selected' : ''}>Force off</option>
1088
- </select></label>
1089
- </div>
1090
- </section>
1091
-
1092
- <section class="sched-editor-section">
1093
- <div class="sched-section-head"><h4>Day Themes</h4><span>Optional context per weekday</span></div>
1094
- <div class="sched-day-grid" id="sched-ed-daythemes">${dayThemes}</div>
1095
- </section>
1096
-
1097
- <label class="sched-toggle"><input id="sched-ed-enabled" type="checkbox" ${s.enabled !== false ? 'checked' : ''}><span>Enabled</span></label>
1098
- </div>
1099
- <div class="sched-editor-actions">
1100
- <button class="sched-secondary" data-action="schedule-editor-close">Cancel</button>
1101
- <button class="sched-primary" data-action="schedule-editor-save">${isEdit ? 'Save Changes' : 'Create Schedule'}</button>
1102
1889
  </div>
1890
+ <footer class="sched-editor-actions">
1891
+ <button type="button" class="sched-btn" data-action="schedule-editor-close">Cancel</button>
1892
+ <button type="button" class="sched-btn sched-btn--primary" data-action="schedule-editor-save">${isEdit ? 'Save changes' : 'Create schedule'}</button>
1893
+ </footer>
1103
1894
  </div>
1104
- `;
1895
+ `);
1105
1896
 
1106
1897
  $('sched-ed-cron')?.addEventListener('input', updateCronDesc);
1107
1898
  $('sched-ed-template')?.addEventListener('change', (e) => {
@@ -1132,8 +1923,8 @@ function renderScheduleEditor() {
1132
1923
  $('sched-ed-model')?.addEventListener('change', (e) => {
1133
1924
  _scheduleEditor.model = e.target.value || null;
1134
1925
  _scheduleEditor.contextMode = _scheduleEditor.model ? 'default' : null;
1135
- const select = $('sched-ed-context');
1136
- if (select) select.innerHTML = renderContextOptions(profileForModels, _scheduleEditor.model || group?.model || template?.model, _scheduleEditor.contextMode, { accountId, inherit: true });
1926
+ const contextPopup = $('sched-ed-context');
1927
+ if (contextPopup) setDropdownItems(contextPopup, renderContextOptions(profileForModels, _scheduleEditor.model || group?.model || template?.model, _scheduleEditor.contextMode, { accountId, inherit: true }));
1137
1928
  });
1138
1929
  $('sched-ed-context')?.addEventListener('change', (e) => { _scheduleEditor.contextMode = e.target.value || null; });
1139
1930
  $('sched-ed-account')?.addEventListener('change', (e) => {
@@ -1141,10 +1932,11 @@ function renderScheduleEditor() {
1141
1932
  _scheduleEditor.contextMode = 'default';
1142
1933
  const nextAccount = _scheduleEditor.codexAccountId || 'default';
1143
1934
  ensureModelsForProfile('codex', false, nextAccount).then(() => {
1144
- const modelSelect = $('sched-ed-model');
1145
- if (modelSelect) modelSelect.innerHTML = renderModelOptions('codex', _scheduleEditor.model, { accountId: nextAccount, showService: true });
1146
- const contextSelect = $('sched-ed-context');
1147
- if (contextSelect) contextSelect.innerHTML = renderContextOptions('codex', _scheduleEditor.model || effectiveModel, _scheduleEditor.contextMode, { accountId: nextAccount, inherit: true });
1935
+ if (!_scheduleEditor) return;
1936
+ const modelPopup = $('sched-ed-model');
1937
+ if (modelPopup) setDropdownItems(modelPopup, renderModelOptions('codex', _scheduleEditor.model, { accountId: nextAccount, showService: true }));
1938
+ const contextPopup = $('sched-ed-context');
1939
+ if (contextPopup) setDropdownItems(contextPopup, renderContextOptions('codex', _scheduleEditor.model || effectiveModel, _scheduleEditor.contextMode, { accountId: nextAccount, inherit: true }));
1148
1940
  });
1149
1941
  });
1150
1942
  }
@@ -1152,16 +1944,57 @@ function renderScheduleEditor() {
1152
1944
  function updateCronDesc() {
1153
1945
  const input = $('sched-ed-cron');
1154
1946
  const desc = $('sched-ed-cron-desc');
1155
- if (input && desc) desc.textContent = describeCronClient(input.value);
1947
+ if (!input) return;
1948
+ const value = input.value.trim();
1949
+ const valid = value.split(/\s+/).length === 5;
1950
+ if (!valid && value) input.setAttribute('aria-invalid', 'true');
1951
+ else input.removeAttribute('aria-invalid');
1952
+ if (desc) desc.textContent = valid ? describeCron(value) : 'A cron expression has five fields: minute, hour, day of month, month, day of week.';
1953
+ _panel?.querySelectorAll('[data-action="cron-preset"]').forEach(btn => btn.setAttribute('aria-pressed', String(btn.dataset.cron === value)));
1954
+ }
1955
+
1956
+ // ── Saving what was saved before: only what the user changed ──
1957
+ // The server reads a launch field as: absent = keep, null or '' = clear, a value = set (and a
1958
+ // group pushes every changed launch field onto its schedules). So an edit sends a field only when
1959
+ // the form's value differs from the saved one: a popup that cannot show a saved value yet, or a
1960
+ // save of an untouched form, never clears anything.
1961
+ const LAUNCH_FIELDS = ['profile', 'model', 'contextMode', 'effort', 'mcpProfile', 'codexAccountId', 'usesBrowser'];
1962
+ // What a CLI's choice decides: a model, context, thinking level, tool list or account of one CLI
1963
+ // means nothing to another, so a change of CLI resets them (the editors' CLI popups do the same).
1964
+ const CLI_DEPENDENT = ['model', 'contextMode', 'effort', 'mcpProfile', 'codexAccountId'];
1965
+ const blankToNull = (value) => (value === undefined || value === null || value === '' ? null : value);
1966
+ const sameValue = (a, b) => blankToNull(a) === blankToNull(b);
1967
+
1968
+ function stableJson(value) {
1969
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
1970
+ if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${stableJson(value[k])}`).join(',')}}`;
1971
+ return JSON.stringify(value ?? null);
1972
+ }
1973
+
1974
+ // The day themes as the form shows them, over the draft's. An entry the user did not touch is
1975
+ // kept exactly (its label too); an edited one keeps its other fields. A day's entry is what lets a
1976
+ // themed schedule run that day (server.js skips a day without one), so an entry goes only once
1977
+ // nothing is left in it, or when its Remove button takes it out of the draft.
1978
+ function collectDayThemes(saved) {
1979
+ const out = saved && typeof saved === 'object' ? JSON.parse(JSON.stringify(saved)) : {};
1980
+ _panel?.querySelectorAll('#sched-ed-daythemes textarea[data-day]').forEach(field => {
1981
+ const day = field.dataset.day;
1982
+ const value = field.value.trim();
1983
+ const before = out[day] && typeof out[day] === 'object' ? out[day] : null;
1984
+ if (value === String(before?.contextOverride ?? '').trim()) return;
1985
+ if (value) { out[day] = { ...(before || {}), contextOverride: value }; return; }
1986
+ if (!before) return;
1987
+ const { contextOverride, ...rest } = before;
1988
+ if (Object.keys(rest).length) out[day] = rest;
1989
+ else delete out[day];
1990
+ });
1991
+ return out;
1156
1992
  }
1157
1993
 
1158
1994
  function collectScheduleEditorDraft() {
1159
- if (!_scheduleEditor) return;
1160
- const dayThemes = {};
1161
- _panel?.querySelectorAll('#sched-ed-daythemes input[data-day]').forEach(input => {
1162
- const val = input.value.trim();
1163
- if (val) dayThemes[input.dataset.day] = { contextOverride: val };
1164
- });
1995
+ // Only from a form on screen: an absent popup would read as "no value" and wipe the draft.
1996
+ if (!_scheduleEditor || !$('sched-ed-name')) return;
1997
+ const dayThemes = collectDayThemes(_scheduleEditor.dayThemes);
1165
1998
  const browserRaw = $('sched-ed-browser')?.value || '';
1166
1999
  Object.assign(_scheduleEditor, {
1167
2000
  name: $('sched-ed-name')?.value ?? _scheduleEditor.name,
@@ -1181,91 +2014,180 @@ function collectScheduleEditorDraft() {
1181
2014
  });
1182
2015
  }
1183
2016
 
2017
+ function markInvalid(id, message) {
2018
+ const field = controlOf($(id)); // a popup's trigger, not its hidden input
2019
+ field?.setAttribute('aria-invalid', 'true');
2020
+ field?.focus();
2021
+ showToast(message);
2022
+ }
2023
+
1184
2024
  async function saveScheduleFromEditor() {
2025
+ if (editorsWait()) { showToast('The schedules have not loaded yet: save once they have'); return; }
2026
+ const original = _scheduleEditor?._original || null;
1185
2027
  const name = $('sched-ed-name')?.value?.trim();
1186
2028
  const templateId = $('sched-ed-template')?.value;
1187
2029
  const cron = $('sched-ed-cron')?.value?.trim();
1188
2030
  const timezone = $('sched-ed-tz')?.value?.trim();
1189
2031
  const groupId = $('sched-ed-group')?.value || null;
1190
- const profile = $('sched-ed-profile')?.value || null;
1191
- const model = $('sched-ed-model')?.value || null;
1192
- const contextMode = $('sched-ed-context')?.value || null;
1193
- const template = getTemplate(templateId);
1194
- const group = groupId ? getGroup(groupId) : null;
1195
- const effectiveProfile = profile || group?.profile || template?.profile || 'claude-code';
1196
- const effort = normalizeEffortForProfile(effectiveProfile, $('sched-ed-effort')?.value || null);
1197
- const mcpProfile = $('sched-ed-mcp')?.value || null;
1198
- const codexAccountId = effectiveProfile === 'codex' ? ($('sched-ed-account')?.value || null) : null;
1199
- const effectiveModel = model || group?.model || template?.model || null;
1200
- if (!modelChoiceAvailable(effectiveProfile, effectiveModel, codexAccountId || group?.codexAccountId || template?.codexAccountId || 'default')) {
1201
- showToast('This model is unavailable for the selected account'); return;
1202
- }
1203
- if (!contextChoiceAvailable(effectiveProfile, effectiveModel, contextMode, codexAccountId || group?.codexAccountId || template?.codexAccountId || 'default')) {
1204
- showToast('This context window is unavailable for the selected model and account'); return;
1205
- }
1206
- const browserRaw = $('sched-ed-browser')?.value || '';
1207
2032
  const enabled = $('sched-ed-enabled')?.checked ?? true;
2033
+ const browserRaw = $('sched-ed-browser')?.value || '';
2034
+ const launch = {
2035
+ profile: $('sched-ed-profile')?.value || null,
2036
+ model: $('sched-ed-model')?.value || null,
2037
+ contextMode: $('sched-ed-context')?.value || null,
2038
+ // 'off' is a choice of its own (no thinking), never the same as "no override".
2039
+ effort: $('sched-ed-effort')?.value || null,
2040
+ mcpProfile: $('sched-ed-mcp')?.value || null,
2041
+ // Read whatever the CLI: a saved account is kept while its row is hidden, never cleared by it.
2042
+ codexAccountId: $('sched-ed-account')?.value || null,
2043
+ usesBrowser: browserRaw === '' ? null : browserRaw === 'true',
2044
+ };
1208
2045
 
1209
- if (!name) { showToast('Name is required'); return; }
1210
- if (!templateId) { showToast('Select an automation'); return; }
1211
- if (!cron || cron.split(/\s+/).length !== 5) { showToast('Valid 5-field cron expression required'); return; }
2046
+ if (!name) { markInvalid('sched-ed-name', 'Give the schedule a name'); return; }
2047
+ if (!templateId) { markInvalid('sched-ed-template', 'Choose the automation it launches'); return; }
2048
+ if (!cron || cron.split(/\s+/).length !== 5) { markInvalid('sched-ed-cron', 'Enter a cron expression with five fields'); return; }
1212
2049
 
1213
- const dayThemes = {};
1214
- _panel.querySelectorAll('#sched-ed-daythemes input[data-day]').forEach(input => {
1215
- const val = input.value.trim();
1216
- if (val) dayThemes[input.dataset.day] = { contextOverride: val };
1217
- });
2050
+ const template = getTemplate(templateId);
2051
+ const group = groupId ? getGroup(groupId) : null;
2052
+ // What the run would use is checked when the choice that decides it changed (or for a new schedule).
2053
+ const choiceChanged = !original || !sameValue(templateId, original.templateId) || !sameValue(groupId, original.groupId)
2054
+ || ['profile', 'model', 'contextMode', 'codexAccountId'].some(k => !sameValue(launch[k], original[k]));
2055
+ if (choiceChanged) {
2056
+ const effectiveProfile = launch.profile || group?.profile || template?.profile || 'claude-code';
2057
+ const effectiveModel = launch.model || group?.model || template?.model || null;
2058
+ const effectiveAccount = launch.codexAccountId || group?.codexAccountId || template?.codexAccountId || 'default';
2059
+ if (!modelChoiceAvailable(effectiveProfile, effectiveModel, effectiveAccount)) {
2060
+ showToast('This model is unavailable for the selected account'); return;
2061
+ }
2062
+ if (!contextChoiceAvailable(effectiveProfile, effectiveModel, launch.contextMode, effectiveAccount)) {
2063
+ showToast('This context window is unavailable for the selected model and account'); return;
2064
+ }
2065
+ }
1218
2066
 
1219
- const payload = {
1220
- name, templateId, cron, timezone, groupId, enabled, dayThemes,
1221
- profile, model, contextMode, effort, mcpProfile, codexAccountId,
1222
- usesBrowser: browserRaw === '' ? null : browserRaw === 'true',
1223
- };
2067
+ collectScheduleEditorDraft(); // the day themes as shown, without the ones removed
2068
+ const dayThemes = _scheduleEditor?.dayThemes || {};
2069
+ let payload;
2070
+ if (original) {
2071
+ payload = {};
2072
+ for (const [key, value] of Object.entries({ name, templateId, cron, groupId, enabled })) {
2073
+ if (!sameValue(value, original[key])) payload[key] = value;
2074
+ }
2075
+ // A schedule without a zone shows the browser's; leaving that as shown keeps it without one.
2076
+ const shownZone = original.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
2077
+ if (timezone !== shownZone) payload.timezone = timezone;
2078
+ if (stableJson(dayThemes) !== stableJson(original.dayThemes || {})) payload.dayThemes = dayThemes;
2079
+ for (const key of LAUNCH_FIELDS) if (!sameValue(launch[key], original[key])) payload[key] = launch[key];
2080
+ if (!Object.keys(payload).length) {
2081
+ _scheduleEditor = null;
2082
+ render({ frame: true });
2083
+ focusRow(original.id);
2084
+ showToast('No changes to save');
2085
+ return;
2086
+ }
2087
+ } else {
2088
+ payload = { name, templateId, cron, timezone, groupId, enabled, dayThemes };
2089
+ for (const key of LAUNCH_FIELDS) if (launch[key] !== null) payload[key] = launch[key];
2090
+ }
1224
2091
 
2092
+ // The save belongs to this editor: once answered, it closes only this one (an editor or a view
2093
+ // opened while the save was on its way stays as it is).
2094
+ const draft = _scheduleEditor;
2095
+ const panel = _panel;
1225
2096
  try {
1226
- if (_scheduleEditor?.id) {
1227
- const updated = await updateSchedule(_scheduleEditor.id, payload);
1228
- const idx = _schedules.findIndex(s => s.id === updated.id);
1229
- if (idx >= 0) _schedules[idx] = updated;
2097
+ let saved;
2098
+ if (draft?.id) {
2099
+ saved = await updateSchedule(draft.id, payload);
2100
+ const idx = _schedules.findIndex(s => s.id === saved.id);
2101
+ if (idx >= 0) _schedules[idx] = saved;
1230
2102
  showToast('Schedule updated');
1231
2103
  } else {
1232
- const created = await createSchedule(payload);
1233
- if (!_schedules.some(s => s.id === created.id)) _schedules.push(created);
2104
+ saved = await createSchedule(payload);
2105
+ if (!_schedules.some(s => s.id === saved.id)) _schedules.push(saved);
1234
2106
  showToast('Schedule created');
1235
2107
  }
2108
+ if (_panel !== panel || _scheduleEditor !== draft) { refresh(); return; }
1236
2109
  _scheduleEditor = null;
1237
- render();
2110
+ if (saved?.id) _expanded.add(saved.id);
2111
+ render({ frame: true });
2112
+ if (saved?.id) {
2113
+ const row = [..._panel.querySelectorAll('.sched-row')].find(el => el.dataset.scheduleId === saved.id);
2114
+ row?.scrollIntoView({ block: 'nearest' });
2115
+ row?.querySelector('.sched-row-toggle')?.focus({ preventScroll: true });
2116
+ }
1238
2117
  } catch (err) {
1239
2118
  showToast('Save failed: ' + err.message);
1240
2119
  }
1241
2120
  }
1242
2121
 
2122
+ // ── Events ──
2123
+
1243
2124
  async function handleClick(e) {
1244
2125
  const el = e.target.closest('[data-action]');
1245
- if (!el || !_panel?.contains(el)) return;
2126
+ if (!el || !_panel?.contains(el) || el.disabled) return;
1246
2127
  const action = el.dataset.action;
1247
2128
  const id = el.dataset.id;
1248
2129
 
1249
2130
  switch (action) {
1250
- case 'set-tab':
1251
- _tab = el.dataset.tab || 'cron';
1252
- _scheduleEditor = null;
1253
- render();
2131
+ case 'set-scope':
2132
+ _query = _scope === el.dataset.scope ? _query : '';
2133
+ setScope(el.dataset.scope || 'all');
2134
+ render({ frame: true });
2135
+ break;
2136
+ case 'schedule-expand':
2137
+ toggleExpanded(id, el);
2138
+ break;
2139
+ case 'group-collapse': {
2140
+ const key = el.dataset.key;
2141
+ if (!key) break;
2142
+ if (_collapsed.has(key)) _collapsed.delete(key); else _collapsed.add(key);
2143
+ try { storage.setItem(COLLAPSED_KEY, JSON.stringify([..._collapsed])); } catch { /* not remembered */ }
2144
+ renderList();
2145
+ break;
2146
+ }
2147
+ case 'clear-search':
2148
+ _query = '';
2149
+ { const input = $('sched-search'); if (input) input.value = ''; }
2150
+ renderMain();
2151
+ $('sched-search')?.focus();
2152
+ break;
2153
+ case 'search-all':
2154
+ setScope('all');
2155
+ render({ frame: true });
2156
+ $('sched-search')?.focus();
2157
+ break;
2158
+ case 'retry-load':
2159
+ _loading = true;
2160
+ render({ frame: true });
2161
+ await loadData(); // loadFinished() redraws, and opens an editor asked for meanwhile
1254
2162
  break;
1255
- case 'select-group':
1256
- _selectedGroup = el.dataset.groupId || 'all';
1257
- _tab = 'cron';
1258
- render();
2163
+ case 'retry-timers':
2164
+ _timersError = null;
2165
+ _timersLoaded = false;
2166
+ renderActiveTimersInto();
2167
+ await loadTimers();
2168
+ break;
2169
+ case 'dismiss-report':
2170
+ _cliReport = null;
2171
+ renderNote();
2172
+ $('sched-search')?.focus();
2173
+ break;
2174
+ case 'reveal-schedule':
2175
+ revealSchedule(id);
1259
2176
  break;
1260
2177
  case 'schedule-new':
1261
- _scheduleEditor = { groupId: _selectedGroup !== 'all' && _selectedGroup !== 'ungrouped' ? _selectedGroup : null };
1262
- render();
2178
+ applyOpenOptions({ action: 'new-schedule' });
2179
+ render({ frame: true });
2180
+ $('sched-ed-name')?.focus();
1263
2181
  break;
1264
2182
  case 'schedule-edit': {
1265
- const schedule = _schedules.find(s => s.id === id);
2183
+ const schedule = editorsWait() ? null : _schedules.find(s => s.id === id);
1266
2184
  if (schedule) {
1267
- _scheduleEditor = { ...schedule, dayThemes: { ...(schedule.dayThemes || {}) } };
1268
- render();
2185
+ _groupEditorId = null;
2186
+ _groupEditorDraft = null;
2187
+ // `_original` is the schedule as saved: a save sends only what differs from it.
2188
+ _scheduleEditor = { ...JSON.parse(JSON.stringify(schedule)), _original: JSON.parse(JSON.stringify(schedule)) };
2189
+ render({ frame: true });
2190
+ $('sched-ed-name')?.focus();
1269
2191
  }
1270
2192
  break;
1271
2193
  }
@@ -1276,12 +2198,17 @@ async function handleClick(e) {
1276
2198
  emit('automations:open', { templateId });
1277
2199
  break;
1278
2200
  }
1279
- case 'schedule-editor-close':
2201
+ case 'schedule-editor-close': {
2202
+ const editedId = _scheduleEditor?.id;
1280
2203
  _scheduleEditor = null;
1281
- render();
2204
+ render({ frame: true });
2205
+ focusRow(editedId);
1282
2206
  break;
2207
+ }
1283
2208
  case 'schedule-editor-save':
1284
- await saveScheduleFromEditor();
2209
+ // One save at a time: a second click while one is on its way would send it again.
2210
+ el.disabled = true;
2211
+ try { await saveScheduleFromEditor(); } finally { if (el.isConnected) el.disabled = false; }
1285
2212
  break;
1286
2213
  case 'cron-preset': {
1287
2214
  const cronInput = $('sched-ed-cron');
@@ -1292,19 +2219,24 @@ async function handleClick(e) {
1292
2219
  break;
1293
2220
  }
1294
2221
  case 'schedule-toggle':
1295
- await toggleSchedule(id);
2222
+ await toggleSchedule(id, el);
1296
2223
  break;
1297
2224
  case 'schedule-test':
1298
- await fireSchedule(id);
2225
+ await fireSchedule(id, el);
1299
2226
  break;
1300
2227
  case 'schedule-delete':
1301
- await removeSchedule(id);
2228
+ openConfirm({ kind: 'schedule', id, opener: el });
2229
+ break;
2230
+ case 'confirm-cancel':
2231
+ closeConfirm();
2232
+ break;
2233
+ case 'confirm-accept':
2234
+ await acceptConfirm(el);
1302
2235
  break;
1303
2236
  case 'group-new':
1304
- _tab = 'groups';
1305
- _scheduleEditor = null;
1306
- render();
1307
- setTimeout(() => $('sched-group-name')?.focus(), 0);
2237
+ applyOpenOptions({ action: 'new-group' });
2238
+ render({ frame: true });
2239
+ $('sched-ge-name')?.focus();
1308
2240
  break;
1309
2241
  case 'schedule-prompt':
1310
2242
  openPromptScheduleModal();
@@ -1315,25 +2247,39 @@ async function handleClick(e) {
1315
2247
  case 'prompt-modal-pick':
1316
2248
  handlePromptPick(el.dataset.runtime);
1317
2249
  break;
1318
- case 'group-save-new':
1319
- await saveNewGroup();
1320
- break;
1321
- case 'group-mutex-toggle':
1322
- await toggleGroupMutex(id, el.checked);
1323
- break;
1324
2250
  case 'group-edit':
1325
- await editGroup(id);
2251
+ if (editorsWait() || !getGroup(id)) break;
2252
+ openGroupEditor(id);
2253
+ render({ frame: true });
2254
+ $('sched-ge-name')?.focus();
2255
+ break;
2256
+ case 'day-theme-remove': {
2257
+ // The whole entry goes, label too: an entry, even an empty one, is what makes the day run.
2258
+ if (!_scheduleEditor) break;
2259
+ collectScheduleEditorDraft();
2260
+ const day = el.dataset.day;
2261
+ const themes = { ...(_scheduleEditor.dayThemes || {}) };
2262
+ delete themes[day];
2263
+ _scheduleEditor.dayThemes = themes;
2264
+ renderScheduleEditor();
2265
+ $(`sched-ed-day-${day}`)?.focus();
2266
+ showToast(`${DAY_LONG[Number(day)] || 'Day'} theme removed`);
1326
2267
  break;
2268
+ }
1327
2269
  case 'group-editor-save':
1328
- await saveGroupFromEditor(id);
2270
+ el.disabled = true;
2271
+ try { await saveGroupFromEditor(id || null); } finally { if (el.isConnected) el.disabled = false; }
1329
2272
  break;
1330
- case 'group-editor-cancel':
2273
+ case 'group-editor-cancel': {
2274
+ const editedId = _groupEditorId;
1331
2275
  _groupEditorId = null;
1332
2276
  _groupEditorDraft = null;
1333
- renderGroupsTab();
2277
+ render({ frame: true });
2278
+ if (editedId) _panel?.querySelector(`.sched-nav-item[data-scope="group:${CSS.escape(editedId)}"]`)?.focus();
1334
2279
  break;
2280
+ }
1335
2281
  case 'group-delete':
1336
- await removeGroup(id);
2282
+ openConfirm({ kind: 'group', id, opener: el });
1337
2283
  break;
1338
2284
  case 'group-up':
1339
2285
  await moveGroup(id, -1);
@@ -1341,10 +2287,14 @@ async function handleClick(e) {
1341
2287
  case 'group-down':
1342
2288
  await moveGroup(id, 1);
1343
2289
  break;
1344
- case 'qt-select':
1345
- _selectedQtMinutes = Number(el.dataset.minutes);
1346
- renderTimersTab();
2290
+ case 'qt-select': {
2291
+ const minutes = Number(el.dataset.minutes);
2292
+ _selectedQtMinutes = _selectedQtMinutes === minutes ? null : minutes;
2293
+ _panel.querySelectorAll('[data-action="qt-select"]').forEach(chip => chip.setAttribute('aria-pressed', String(Number(chip.dataset.minutes) === _selectedQtMinutes)));
2294
+ const custom = $('sched-qt-custom-min');
2295
+ if (custom && _selectedQtMinutes) custom.value = '';
1347
2296
  break;
2297
+ }
1348
2298
  case 'qt-go':
1349
2299
  await createQuickTimerFromForm(false);
1350
2300
  break;
@@ -1363,42 +2313,154 @@ async function handleClick(e) {
1363
2313
  }
1364
2314
  }
1365
2315
 
2316
+ function toggleExpanded(id, el) {
2317
+ if (!id) return;
2318
+ if (_expanded.has(id)) _expanded.delete(id); else _expanded.add(id);
2319
+ const row = el.closest('.sched-row');
2320
+ if (!row) { renderList(); return; }
2321
+ // Redraw only this row, so the list keeps its place and the focus stays on the row.
2322
+ const schedule = _schedules.find(s => s.id === id);
2323
+ if (!schedule) return;
2324
+ const showGroup = !!row.querySelector('.sched-row-group');
2325
+ const template = document.createElement('template');
2326
+ template.innerHTML = rowHTML(schedule, { now: Date.now(), showGroup }).trim();
2327
+ const next = template.content.firstElementChild;
2328
+ row.replaceWith(next);
2329
+ next.querySelector('.sched-row-toggle')?.focus({ preventScroll: true });
2330
+ if (_expanded.has(id)) next.querySelector('.sched-detail')?.scrollIntoView({ block: 'nearest' });
2331
+ }
2332
+
2333
+ function focusRow(id) {
2334
+ if (!id || !_panel) { $('sched-search')?.focus(); return; }
2335
+ const row = [..._panel.querySelectorAll('.sched-row')].find(el => el.dataset.scheduleId === id);
2336
+ if (row) {
2337
+ row.scrollIntoView({ block: 'nearest' });
2338
+ row.querySelector('.sched-row-toggle')?.focus({ preventScroll: true });
2339
+ } else {
2340
+ $('sched-search')?.focus();
2341
+ }
2342
+ }
2343
+
1366
2344
  async function handleChange(e) {
1367
- const select = e.target.closest('.sched-card-group-select');
1368
- if (!select) return;
1369
- const scheduleId = select.dataset.scheduleId;
1370
- try {
1371
- const updated = await updateSchedule(scheduleId, { groupId: select.value || null });
1372
- const idx = _schedules.findIndex(s => s.id === updated.id);
1373
- if (idx >= 0) _schedules[idx] = updated;
1374
- showToast(select.value ? `Moved to ${getGroup(select.value)?.name || 'group'}` : 'Moved to Ungrouped');
1375
- render();
1376
- } catch (err) {
1377
- showToast('Move failed: ' + err.message);
2345
+ const el = e.target;
2346
+ const kind = el.dataset?.change;
2347
+ if (kind === 'sort') {
2348
+ _sort = SORTS.includes(el.value) ? el.value : 'next';
2349
+ try { storage.setItem(SORT_KEY, _sort); } catch { /* not remembered */ }
2350
+ renderList();
2351
+ } else if (kind === 'scope') {
2352
+ _query = '';
2353
+ setScope(el.value || 'all');
2354
+ render({ frame: true });
2355
+ controlOf($('sched-scope-select'))?.focus();
2356
+ } else if (kind === 'move-group') {
2357
+ await moveScheduleToGroup(el.dataset.id, el.value || null);
2358
+ }
2359
+ }
2360
+
2361
+ function handleInput(e) {
2362
+ if (e.target.id === 'sched-search') {
2363
+ _query = e.target.value;
2364
+ if (_scope === 'activity') renderActivityList(); else renderList();
2365
+ } else {
2366
+ // A popup's choice arrives on its hidden input; the mark is on its trigger.
2367
+ const field = controlOf(e.target);
2368
+ if (field.getAttribute('aria-invalid') === 'true' && field.id !== 'sched-ed-cron') field.removeAttribute('aria-invalid');
1378
2369
  }
1379
2370
  }
1380
2371
 
2372
+ function handlePanelKeydown(e) {
2373
+ if (_confirm && e.key === 'Tab') {
2374
+ // Keep the focus inside the dialog.
2375
+ const layer = $('sched-dialog-layer');
2376
+ const focusable = [...(layer?.querySelectorAll('button:not(:disabled)') || [])];
2377
+ if (!focusable.length) return;
2378
+ const index = focusable.indexOf(document.activeElement);
2379
+ const next = index < 0 ? 0 : (index + (e.shiftKey ? -1 : 1) + focusable.length) % focusable.length;
2380
+ e.preventDefault();
2381
+ focusable[next].focus();
2382
+ return;
2383
+ }
2384
+ const typing = e.target.closest('input, textarea, select, [contenteditable], [role="combobox"]');
2385
+ const find = (!typing && e.key === '/') || ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'f');
2386
+ if (find && !editorOpen() && !_confirm && $('sched-search') && _scope !== 'timers') {
2387
+ e.preventDefault();
2388
+ e.stopPropagation();
2389
+ $('sched-search').focus();
2390
+ $('sched-search').select();
2391
+ }
2392
+ }
2393
+
2394
+ function handleEscape(e) {
2395
+ if (e.key !== 'Escape' || !_panel || _promptModal) return;
2396
+ if (document.querySelector('.menubar-item.open')) return; // the menubar closes its menu first
2397
+ const target = e.target;
2398
+ // Escape in another window belongs to that window.
2399
+ if (target && target !== document.body && !_panel.contains(target) && !target.closest?.('#title-bar')) return;
2400
+ e.preventDefault();
2401
+ e.stopPropagation();
2402
+ // An open popup closes first, and the focus goes back to it (this runs before its own key handler).
2403
+ if (closeDropdowns(_panel, { focus: true })) return;
2404
+ if (_confirm) { closeConfirm(); return; }
2405
+ if (_scheduleEditor) {
2406
+ const editedId = _scheduleEditor.id;
2407
+ _scheduleEditor = null;
2408
+ render({ frame: true });
2409
+ focusRow(editedId);
2410
+ return;
2411
+ }
2412
+ if (_groupEditorDraft) {
2413
+ _groupEditorId = null;
2414
+ _groupEditorDraft = null;
2415
+ render({ frame: true });
2416
+ $('sched-search')?.focus();
2417
+ return;
2418
+ }
2419
+ if (_query && target?.id === 'sched-search') {
2420
+ _query = '';
2421
+ target.value = '';
2422
+ renderMain();
2423
+ return;
2424
+ }
2425
+ closePanel();
2426
+ }
2427
+
2428
+ // ── Drag a schedule onto a group ──
2429
+
1381
2430
  function handleDragStart(e) {
1382
- const card = e.target.closest('.sched-card');
1383
- if (!card) return;
1384
- _draggingScheduleId = card.dataset.scheduleId;
1385
- card.classList.add('dragging');
2431
+ const row = e.target.closest?.('.sched-row-line[draggable="true"]')?.closest('.sched-row');
2432
+ if (!row) return;
2433
+ _draggingScheduleId = row.dataset.scheduleId;
2434
+ row.classList.add('is-dragging');
1386
2435
  try { e.dataTransfer.setData('text/plain', _draggingScheduleId); } catch {}
1387
2436
  e.dataTransfer.effectAllowed = 'move';
1388
2437
  }
1389
2438
 
2439
+ function clearDropTargets() {
2440
+ _panel?.querySelectorAll('.drop-target').forEach(el => el.classList.remove('drop-target'));
2441
+ }
2442
+
1390
2443
  function handleDragEnd() {
1391
2444
  _draggingScheduleId = null;
1392
- _panel?.querySelectorAll('.sched-card.dragging').forEach(el => el.classList.remove('dragging'));
1393
- _panel?.querySelectorAll('.sched-group-row.drop-target').forEach(el => el.classList.remove('drop-target'));
2445
+ _panel?.querySelectorAll('.sched-row.is-dragging').forEach(el => el.classList.remove('is-dragging'));
2446
+ clearDropTargets();
1394
2447
  }
1395
2448
 
1396
2449
  function handleDragOver(e) {
1397
2450
  if (!_draggingScheduleId) return;
1398
2451
  const target = e.target.closest('[data-drop-group]');
1399
- if (!target) return;
2452
+ if (!target) { clearDropTargets(); return; }
1400
2453
  e.preventDefault();
1401
- target.classList.add('drop-target');
2454
+ e.dataTransfer.dropEffect = 'move';
2455
+ if (!target.classList.contains('drop-target')) {
2456
+ clearDropTargets();
2457
+ target.classList.add('drop-target');
2458
+ }
2459
+ }
2460
+
2461
+ function handleDragLeave(e) {
2462
+ const target = e.target.closest?.('[data-drop-group]');
2463
+ if (target && !target.contains(e.relatedTarget)) target.classList.remove('drop-target');
1402
2464
  }
1403
2465
 
1404
2466
  async function handleDrop(e) {
@@ -1406,36 +2468,49 @@ async function handleDrop(e) {
1406
2468
  const target = e.target.closest('[data-drop-group]');
1407
2469
  if (!target) return;
1408
2470
  e.preventDefault();
1409
- const groupId = target.dataset.dropGroup || null;
2471
+ const id = _draggingScheduleId;
2472
+ handleDragEnd();
2473
+ await moveScheduleToGroup(id, target.dataset.dropGroup || null);
2474
+ }
2475
+
2476
+ // ── Writes ──
2477
+
2478
+ function replaceSchedule(updated) {
2479
+ const idx = _schedules.findIndex(s => s.id === updated.id);
2480
+ if (idx >= 0) _schedules[idx] = updated;
2481
+ else _schedules.push(updated);
2482
+ }
2483
+
2484
+ async function moveScheduleToGroup(id, groupId) {
2485
+ const schedule = _schedules.find(s => s.id === id);
2486
+ if (!schedule || (schedule.groupId || null) === (groupId || null)) return;
1410
2487
  try {
1411
- const updated = await updateSchedule(_draggingScheduleId, { groupId });
1412
- const idx = _schedules.findIndex(s => s.id === updated.id);
1413
- if (idx >= 0) _schedules[idx] = updated;
1414
- _selectedGroup = groupId || 'ungrouped';
2488
+ replaceSchedule(await updateSchedule(id, { groupId }));
1415
2489
  showToast(groupId ? `Moved to ${getGroup(groupId)?.name || 'group'}` : 'Moved to Ungrouped');
1416
- render();
2490
+ refresh();
1417
2491
  } catch (err) {
1418
2492
  showToast('Move failed: ' + err.message);
1419
- } finally {
1420
- handleDragEnd();
2493
+ refresh();
1421
2494
  }
1422
2495
  }
1423
2496
 
1424
- async function toggleSchedule(id) {
2497
+ async function toggleSchedule(id, el = null) {
1425
2498
  const schedule = _schedules.find(s => s.id === id);
1426
2499
  if (!schedule) return;
2500
+ el?.setAttribute('aria-busy', 'true');
1427
2501
  try {
1428
- const updated = await updateSchedule(id, { enabled: !schedule.enabled });
1429
- const idx = _schedules.findIndex(s => s.id === id);
1430
- if (idx >= 0) _schedules[idx] = updated;
1431
- showToast(updated.enabled ? 'Schedule enabled' : 'Schedule paused');
1432
- render();
2502
+ replaceSchedule(await updateSchedule(id, { enabled: !schedule.enabled }));
2503
+ const updated = _schedules.find(s => s.id === id);
2504
+ showToast(updated?.enabled ? `${schedule.name} is back on its timetable` : `${schedule.name} is paused`);
2505
+ refresh();
1433
2506
  } catch (err) {
2507
+ el?.removeAttribute('aria-busy');
1434
2508
  showToast('Toggle failed: ' + err.message);
1435
2509
  }
1436
2510
  }
1437
2511
 
1438
- async function fireSchedule(id) {
2512
+ async function fireSchedule(id, el = null) {
2513
+ if (el) el.disabled = true;
1439
2514
  try {
1440
2515
  await testSchedule(id, {
1441
2516
  sidepanelWindowId: getNativeLoopRouterWindowId(),
@@ -1444,130 +2519,276 @@ async function fireSchedule(id) {
1444
2519
  showToast('Launching schedule…');
1445
2520
  } catch (err) {
1446
2521
  showToast('Run failed: ' + err.message);
2522
+ } finally {
2523
+ if (el?.isConnected) el.disabled = false;
1447
2524
  }
1448
2525
  }
1449
2526
 
1450
- async function removeSchedule(id) {
1451
- if (!confirm('Delete this schedule?')) return;
1452
- try {
1453
- await deleteSchedule(id);
1454
- _schedules = _schedules.filter(s => s.id !== id);
1455
- render();
1456
- showToast('Schedule deleted');
1457
- } catch (err) {
1458
- showToast('Delete failed: ' + err.message);
2527
+ // ── Confirmation (destructive actions) ──
2528
+
2529
+ function openConfirm({ kind, id, opener }) {
2530
+ const layer = $('sched-dialog-layer');
2531
+ if (!layer) return;
2532
+ let title, name, text, accept;
2533
+ if (kind === 'schedule') {
2534
+ const schedule = _schedules.find(s => s.id === id);
2535
+ if (!schedule) return;
2536
+ title = 'Delete this schedule?';
2537
+ name = schedule.name;
2538
+ text = 'It stops running and cannot be restored. The automation it launches stays in Automation Studio.';
2539
+ accept = 'Delete schedule';
2540
+ } else {
2541
+ const group = getGroup(id);
2542
+ if (!group) return;
2543
+ const count = _schedules.filter(s => s.groupId === id).length;
2544
+ title = 'Delete this group?';
2545
+ name = group.name;
2546
+ text = count ? `Its ${plural(count, 'schedule')} move to Ungrouped and keep running.` : 'The group is empty.';
2547
+ accept = 'Delete group';
1459
2548
  }
2549
+ _confirm = { kind, id, opener };
2550
+ layer.innerHTML = `<div class="sched-dialog" role="alertdialog" aria-modal="true" aria-labelledby="sched-dialog-title" aria-describedby="sched-dialog-text">
2551
+ <h4 id="sched-dialog-title">${esc(title)}</h4>
2552
+ <div class="sched-dialog-body">
2553
+ <p class="sched-dialog-name">${esc(name)}</p>
2554
+ <p class="sched-help" id="sched-dialog-text">${esc(text)}</p>
2555
+ </div>
2556
+ <div class="sched-dialog-actions">
2557
+ <button type="button" class="sched-btn" data-action="confirm-cancel">Cancel</button>
2558
+ <button type="button" class="sched-btn sched-btn--danger" data-action="confirm-accept">${ICONS.trash}<span>${esc(accept)}</span></button>
2559
+ </div>
2560
+ </div>`;
2561
+ layer.hidden = false;
2562
+ for (const part of _panel.querySelectorAll(':scope > .sched-header, :scope > .sched-body')) part.inert = true;
2563
+ layer.querySelector('[data-action="confirm-cancel"]')?.focus();
1460
2564
  }
1461
2565
 
1462
- async function saveNewGroup() {
1463
- const name = $('sched-group-name')?.value?.trim();
1464
- const color = $('sched-group-color')?.value || null;
1465
- const mutualExclusion = $('sched-group-mutex')?.checked || false;
1466
- if (!name) { showToast('Group name is required'); return; }
1467
- try {
1468
- const group = await createScheduleGroup({ name, color, mutualExclusion });
1469
- if (!_groups.some(g => g.id === group.id)) _groups.push(group);
1470
- _selectedGroup = group.id;
1471
- _tab = 'cron';
1472
- render();
1473
- showToast('Group created');
1474
- } catch (err) {
1475
- showToast('Group failed: ' + err.message);
1476
- }
2566
+ function closeConfirm() {
2567
+ const layer = $('sched-dialog-layer');
2568
+ if (layer) { layer.hidden = true; layer.innerHTML = ''; }
2569
+ for (const part of _panel?.querySelectorAll(':scope > .sched-header, :scope > .sched-body') || []) part.inert = false;
2570
+ const opener = _confirm?.opener;
2571
+ _confirm = null;
2572
+ if (opener?.isConnected) opener.focus();
1477
2573
  }
1478
2574
 
1479
- async function editGroup(id) {
1480
- if (_groupEditorId === id) {
1481
- _groupEditorId = null;
1482
- _groupEditorDraft = null;
1483
- } else {
1484
- _groupEditorId = id;
1485
- const group = getGroup(id);
1486
- if (group) {
1487
- _groupEditorDraft = { name: group.name, color: group.color, profile: group.profile || null, model: group.model || null, contextMode: group.contextMode || null, effort: group.effort || null, mcpProfile: group.mcpProfile || null, codexAccountId: group.codexAccountId || null, usesBrowser: group.usesBrowser };
1488
- ensureModelsForProfile(_groupEditorDraft.profile || 'claude-code', false, _groupEditorDraft.codexAccountId || 'default').then(() => {
1489
- if (_groupEditorDraft) {
1490
- collectGroupEditorDraft();
1491
- renderGroupsTab();
1492
- }
1493
- });
2575
+ async function acceptConfirm(button) {
2576
+ if (!_confirm) return;
2577
+ const { kind, id } = _confirm;
2578
+ button.disabled = true;
2579
+ try {
2580
+ if (kind === 'schedule') {
2581
+ await deleteSchedule(id);
2582
+ _schedules = _schedules.filter(s => s.id !== id);
2583
+ _expanded.delete(id);
2584
+ showToast('Schedule deleted');
2585
+ } else {
2586
+ await deleteScheduleGroup(id);
2587
+ _groups = _groups.filter(g => g.id !== id);
2588
+ for (const schedule of _schedules) if (schedule.groupId === id) schedule.groupId = null;
2589
+ if (_scope === `group:${id}`) setScope('ungrouped', { keepEditors: true });
2590
+ if (_groupEditorId === id) { _groupEditorId = null; _groupEditorDraft = null; }
2591
+ showToast('Group deleted');
1494
2592
  }
2593
+ _confirm.opener = null;
2594
+ closeConfirm();
2595
+ render({ frame: true });
2596
+ $('sched-search')?.focus();
2597
+ } catch (err) {
2598
+ button.disabled = false;
2599
+ showToast(`${kind === 'schedule' ? 'Delete' : 'Group delete'} failed: ${err.message}`);
1495
2600
  }
1496
- renderGroupsTab();
1497
2601
  }
1498
2602
 
2603
+ // ── Groups ──
2604
+
1499
2605
  async function saveGroupFromEditor(id) {
2606
+ if (editorsWait()) { showToast('The schedules have not loaded yet: save once they have'); return; }
2607
+ collectGroupEditorDraft();
1500
2608
  const name = $('sched-ge-name')?.value?.trim();
1501
- if (!name) { showToast('Group name is required'); return; }
2609
+ if (!name) { markInvalid('sched-ge-name', 'Group name is required'); return; }
1502
2610
  const color = $('sched-ge-color')?.value || null;
1503
- const profile = $('sched-ge-profile')?.value || null;
1504
- const model = $('sched-ge-model')?.value || null;
1505
- const contextMode = $('sched-ge-context')?.value || null;
1506
- const effectiveProfile = profile || 'claude-code';
1507
- const effort = normalizeEffortForProfile(effectiveProfile, $('sched-ge-effort')?.value || null);
1508
- const mcpProfile = $('sched-ge-mcp')?.value || null;
1509
- const codexAccountId = effectiveProfile === 'codex' ? ($('sched-ge-account')?.value || null) : null;
1510
- if (!modelChoiceAvailable(effectiveProfile, model, codexAccountId || 'default')) {
1511
- showToast('This model is unavailable for the selected account'); return;
2611
+ const mutualExclusion = !!$('sched-ge-mutex')?.checked;
2612
+ const browserRaw = $('sched-ge-browser')?.value || '';
2613
+ const launch = {
2614
+ profile: $('sched-ge-profile')?.value || null,
2615
+ model: $('sched-ge-model')?.value || null,
2616
+ contextMode: $('sched-ge-context')?.value || null,
2617
+ effort: $('sched-ge-effort')?.value || null, // 'off' stays explicit
2618
+ mcpProfile: $('sched-ge-mcp')?.value || null,
2619
+ codexAccountId: $('sched-ge-account')?.value || null, // read whatever the CLI: hidden is not cleared
2620
+ usesBrowser: browserRaw === '' ? null : browserRaw === 'true',
2621
+ };
2622
+ // The group as it was when the editor opened, not as a sync update has made it since.
2623
+ const existing = id ? _groupEditorDraft?._original || getGroup(id) || {} : null;
2624
+ if (!existing || ['profile', 'model', 'contextMode', 'codexAccountId'].some(k => !sameValue(launch[k], existing[k]))) {
2625
+ const effectiveProfile = launch.profile || 'claude-code';
2626
+ if (!modelChoiceAvailable(effectiveProfile, launch.model, launch.codexAccountId || 'default')) {
2627
+ showToast('This model is unavailable for the selected account'); return;
2628
+ }
2629
+ if (!contextChoiceAvailable(effectiveProfile, launch.model, launch.contextMode, launch.codexAccountId || 'default')) {
2630
+ showToast('This context window is unavailable for the selected model and account'); return;
2631
+ }
1512
2632
  }
1513
- if (!contextChoiceAvailable(effectiveProfile, model, contextMode, codexAccountId || 'default')) {
1514
- showToast('This context window is unavailable for the selected model and account'); return;
2633
+
2634
+ // The save belongs to this editor: once it is answered, the window goes to the group only while
2635
+ // this editor is still the one open. One opened meanwhile (or a closed window) is left as it is.
2636
+ const draft = _groupEditorDraft;
2637
+ const panel = _panel;
2638
+ const stillHere = () => !!draft && _panel === panel && _groupEditorDraft === draft;
2639
+
2640
+ if (!id) {
2641
+ try {
2642
+ const payload = { name, color, mutualExclusion };
2643
+ for (const key of LAUNCH_FIELDS) if (launch[key] !== null) payload[key] = launch[key];
2644
+ const group = await createScheduleGroup(payload);
2645
+ if (!_groups.some(g => g.id === group.id)) _groups.push(group);
2646
+ showToast('Group created');
2647
+ if (!stillHere()) { refresh(); return; }
2648
+ _groupEditorId = null;
2649
+ _groupEditorDraft = null;
2650
+ _query = '';
2651
+ setScope(`group:${group.id}`);
2652
+ render({ frame: true });
2653
+ _panel?.querySelector(`.sched-nav-item[data-scope="group:${CSS.escape(group.id)}"]`)?.focus();
2654
+ } catch (err) {
2655
+ showToast('Group failed: ' + err.message);
2656
+ }
2657
+ return;
1515
2658
  }
1516
- const browserRaw = $('sched-ge-browser')?.value || '';
1517
- const usesBrowser = browserRaw === '' ? null : browserRaw === 'true';
1518
- // Send only the launch fields the user actually changed. The server pushes changed
1519
- // group launch fields onto every schedule in the group, so sending an unchanged field
1520
- // would needlessly re-propagate onto (and historically clobber) per-schedule overrides.
1521
- // name/color always go.
1522
- const group = getGroup(id) || {};
1523
- const norm = (v) => (v === '' || v == null ? null : v);
1524
- const payload = { name, color };
1525
- for (const [k, v] of [['profile', profile], ['model', model], ['contextMode', contextMode], ['effort', effort], ['mcpProfile', mcpProfile], ['codexAccountId', codexAccountId], ['usesBrowser', usesBrowser]]) {
1526
- if (norm(v) !== norm(group[k])) payload[k] = v;
2659
+
2660
+ // Send only what the user changed. The server pushes a changed group launch field onto every
2661
+ // schedule in the group, so an unchanged one sent anyway would overwrite their own choices.
2662
+ // The colour counts as changed when it differs from what showed (an unset one shows the default).
2663
+ const payload = {};
2664
+ if (name !== String(existing.name ?? '').trim()) payload.name = name;
2665
+ const shownColor = /^#[0-9a-f]{6}$/i.test(existing.color || '') ? existing.color.toLowerCase() : DEFAULT_GROUP_COLOR;
2666
+ if (color && color.toLowerCase() !== shownColor) payload.color = color;
2667
+ if (mutualExclusion !== !!existing.mutualExclusion) payload.mutualExclusion = mutualExclusion;
2668
+ for (const key of LAUNCH_FIELDS) if (!sameValue(launch[key], existing[key])) payload[key] = launch[key];
2669
+ // A new CLI: what the old one decided goes, explicitly (also what the group never set itself).
2670
+ const cliChanged = !sameValue(launch.profile, existing.profile);
2671
+ if (cliChanged) for (const key of CLI_DEPENDENT) payload[key] = launch[key];
2672
+ if (!Object.keys(payload).length) {
2673
+ _groupEditorId = null;
2674
+ _groupEditorDraft = null;
2675
+ render({ frame: true });
2676
+ _panel?.querySelector(`.sched-nav-item[data-scope="group:${CSS.escape(id)}"]`)?.focus();
2677
+ showToast('No changes to save');
2678
+ return;
1527
2679
  }
2680
+ // What each schedule of the group holds as the user saves: a reset afterwards writes only over
2681
+ // values that are still these.
2682
+ const members = cliChanged ? captureMembers(id, launch) : [];
1528
2683
  try {
1529
2684
  const updated = await updateScheduleGroup(id, payload);
1530
2685
  const idx = _groups.findIndex(g => g.id === id);
1531
2686
  if (idx >= 0) _groups[idx] = updated;
1532
- _groupEditorId = null;
1533
- _groupEditorDraft = null;
1534
- // Group launch overrides are written through to each schedule server-side;
1535
- // reload so the schedule cards immediately reflect the propagated values.
2687
+ if (stillHere()) {
2688
+ _groupEditorId = null;
2689
+ _groupEditorDraft = null;
2690
+ setScope(`group:${id}`);
2691
+ render({ frame: true });
2692
+ _panel?.querySelector(`.sched-nav-item[data-scope="group:${CSS.escape(id)}"]`)?.focus();
2693
+ }
2694
+ if (!cliChanged) {
2695
+ showToast('Group updated');
2696
+ } else {
2697
+ _cliReport = { groupId: id, profile: launch.profile, pending: true };
2698
+ renderNote();
2699
+ const result = await resetSchedulesForCli(id, launch, members);
2700
+ _cliReport = { groupId: id, profile: launch.profile, ...result };
2701
+ renderNote();
2702
+ const kept = result.changed.length + result.failed.length;
2703
+ showToast(kept
2704
+ ? `Group updated. ${plural(kept, 'schedule')} kept the old CLI's settings: the group's page names ${kept === 1 ? 'it' : 'them'}`
2705
+ : 'Group updated');
2706
+ }
2707
+ // The schedules show what the save pushed onto them. From here on nothing is navigated:
2708
+ // the user may have opened something else meanwhile, and it stays as it is.
1536
2709
  await loadData();
1537
- render();
1538
- showToast('Group updated');
1539
2710
  } catch (err) {
1540
2711
  showToast('Group update failed: ' + err.message);
1541
2712
  }
1542
2713
  }
1543
2714
 
1544
- async function toggleGroupMutex(id, checked) {
1545
- try {
1546
- const updated = await updateScheduleGroup(id, { mutualExclusion: !!checked });
1547
- const idx = _groups.findIndex(g => g.id === id);
1548
- if (idx >= 0) _groups[idx] = updated;
1549
- render();
1550
- showToast(checked ? 'Mutual exclusion enabled' : 'Mutual exclusion disabled');
1551
- } catch (err) {
1552
- showToast('Mutex toggle failed: ' + err.message);
2715
+ const RESET_KEYS = ['profile', ...CLI_DEPENDENT];
2716
+
2717
+ /** The schedules of a group that hold a value of their own the group's new launch values replace. */
2718
+ function captureMembers(groupId, launch) {
2719
+ return _schedules.filter(s => s.groupId === groupId)
2720
+ .map(s => ({ id: s.id, name: s.name || s.id, values: Object.fromEntries(RESET_KEYS.map(k => [k, blankToNull(s[k])])) }))
2721
+ .filter(m => RESET_KEYS.some(k => m.values[k] !== null && m.values[k] !== blankToNull(launch[k])));
2722
+ }
2723
+
2724
+ // server.js pushes a group field onto the group's schedules only when the group's own value
2725
+ // changed. A schedule's own model, context, thinking, tools or account under a group that had
2726
+ // none of its own would survive a change of CLI and launch the new CLI with the old one's model.
2727
+ // So after the group is saved, each schedule that was in it when the user saved is read again
2728
+ // right before its write, and gets the group's new values only while it is still in the group and
2729
+ // each of those values is still what it was at the save (or what the save itself pushed onto it).
2730
+ // One that moved or took a newer value meanwhile is left alone; one that cannot be read is not
2731
+ // written, and when a read gets no answer at all the rest are not tried. Never from a cached list.
2732
+ async function resetSchedulesForCli(groupId, launch, members) {
2733
+ const report = { reset: [], changed: [], failed: [] };
2734
+ const target = (key) => blankToNull(launch[key]);
2735
+ for (let i = 0; i < members.length; i++) {
2736
+ const member = members[i];
2737
+ let fresh;
2738
+ try {
2739
+ fresh = await withDeadline(fetchSchedule(member.id));
2740
+ } catch (err) {
2741
+ if (err?.timeout) { report.failed.push(...members.slice(i).map(m => m.name)); break; }
2742
+ report.failed.push(member.name);
2743
+ continue;
2744
+ }
2745
+ const name = fresh?.name || member.name;
2746
+ const moved = !fresh || fresh.groupId !== groupId;
2747
+ const changed = moved || RESET_KEYS.some(key => {
2748
+ const now = blankToNull(fresh[key]);
2749
+ return now !== member.values[key] && now !== target(key);
2750
+ });
2751
+ if (changed) { report.changed.push(name); continue; }
2752
+ const reset = {};
2753
+ for (const key of RESET_KEYS) if (blankToNull(fresh[key]) !== null && blankToNull(fresh[key]) !== target(key)) reset[key] = launch[key];
2754
+ if (!Object.keys(reset).length) continue; // the save's own push already did it
2755
+ try {
2756
+ const saved = await updateSchedule(member.id, reset);
2757
+ const idx = _schedules.findIndex(s => s.id === saved?.id);
2758
+ if (idx >= 0) _schedules[idx] = saved;
2759
+ report.reset.push(name);
2760
+ } catch {
2761
+ report.failed.push(name);
2762
+ }
1553
2763
  }
2764
+ return report;
1554
2765
  }
1555
2766
 
1556
- async function removeGroup(id) {
1557
- const count = _schedules.filter(s => s.groupId === id).length;
1558
- if (!confirm(`Delete this group? ${count} schedules will move to Ungrouped.`)) return;
2767
+ async function moveGroup(id, direction) {
2768
+ const ordered = orderedGroups();
2769
+ const idx = ordered.findIndex(g => g.id === id);
2770
+ const next = idx + direction;
2771
+ if (idx < 0 || next < 0 || next >= ordered.length) return;
2772
+ const copy = [...ordered];
2773
+ const [moved] = copy.splice(idx, 1);
2774
+ copy.splice(next, 0, moved);
1559
2775
  try {
1560
- await deleteScheduleGroup(id);
1561
- _groups = _groups.filter(g => g.id !== id);
1562
- for (const schedule of _schedules) if (schedule.groupId === id) schedule.groupId = null;
1563
- if (_selectedGroup === id) _selectedGroup = 'ungrouped';
1564
- render();
1565
- showToast('Group deleted');
2776
+ collectGroupEditorDraft();
2777
+ await reorderScheduleGroups(copy.map(g => g.id));
2778
+ _groups = copy.map((g, i) => ({ ...g, order: i }));
2779
+ renderRail();
2780
+ if (_groupEditorDraft) renderGroupEditor(getGroup(id));
2781
+ else renderMain(true);
2782
+ // Keep the focus on the button that was pressed, or on the other one once it reached an end.
2783
+ const pressed = _panel?.querySelector(`[data-action="${direction < 0 ? 'group-up' : 'group-down'}"]`);
2784
+ (pressed && !pressed.disabled ? pressed : _panel?.querySelector(`[data-action="${direction < 0 ? 'group-down' : 'group-up'}"]`))?.focus();
1566
2785
  } catch (err) {
1567
- showToast('Group delete failed: ' + err.message);
2786
+ showToast('Reorder failed: ' + err.message);
1568
2787
  }
1569
2788
  }
1570
2789
 
2790
+ // ── Prompt a Schedule ──
2791
+
1571
2792
  function openPromptScheduleModal() {
1572
2793
  if (_promptModal) return;
1573
2794
 
@@ -1581,12 +2802,15 @@ function openPromptScheduleModal() {
1581
2802
  const codexMeta = getProviderMeta('codex');
1582
2803
  _promptModal = document.createElement('div');
1583
2804
  _promptModal.className = 'sched-prompt-modal glass';
2805
+ _promptModal.setAttribute('role', 'dialog');
2806
+ _promptModal.setAttribute('aria-modal', 'true');
2807
+ _promptModal.setAttribute('aria-labelledby', 'sched-prompt-title');
1584
2808
  _promptModal.innerHTML = `
1585
2809
  <header class="sched-prompt-head">
1586
2810
  <div class="sched-prompt-head-text">
1587
2811
  <span class="sched-prompt-icon">${ICONS.sparkle}</span>
1588
2812
  <div>
1589
- <h3>Prompt a Schedule</h3>
2813
+ <h3 id="sched-prompt-title">Prompt a Schedule</h3>
1590
2814
  <p>Start the schedule draft in a sidepanel.</p>
1591
2815
  </div>
1592
2816
  </div>
@@ -1622,7 +2846,7 @@ function openPromptScheduleModal() {
1622
2846
  _promptModal.addEventListener('click', handlePromptModalClick);
1623
2847
  document.body.appendChild(_promptModal);
1624
2848
 
1625
- _promptKeyHandler = (e) => { if (e.key === 'Escape') closePromptScheduleModal(); };
2849
+ _promptKeyHandler = (e) => { if (e.key === 'Escape') { e.stopPropagation(); closePromptScheduleModal(); } };
1626
2850
  document.addEventListener('keydown', _promptKeyHandler);
1627
2851
 
1628
2852
  requestAnimationFrame(() => {
@@ -1648,10 +2872,12 @@ function closePromptScheduleModal() {
1648
2872
  document.removeEventListener('keydown', _promptKeyHandler);
1649
2873
  _promptKeyHandler = null;
1650
2874
  }
2875
+ const hadModal = !!_promptModal;
1651
2876
  _promptBackdrop?.remove();
1652
2877
  _promptModal?.remove();
1653
2878
  _promptBackdrop = null;
1654
2879
  _promptModal = null;
2880
+ if (hadModal && _panel) _panel.querySelector('[data-action="schedule-prompt"]')?.focus();
1655
2881
  }
1656
2882
 
1657
2883
  async function handlePromptPick(runtime, triggerEl = null) {
@@ -1672,28 +2898,14 @@ async function handlePromptPick(runtime, triggerEl = null) {
1672
2898
  }
1673
2899
  }
1674
2900
 
1675
- async function moveGroup(id, direction) {
1676
- const idx = _groups.findIndex(g => g.id === id);
1677
- const next = idx + direction;
1678
- if (idx < 0 || next < 0 || next >= _groups.length) return;
1679
- const copy = [..._groups];
1680
- const [moved] = copy.splice(idx, 1);
1681
- copy.splice(next, 0, moved);
1682
- try {
1683
- await reorderScheduleGroups(copy.map(g => g.id));
1684
- _groups = copy.map((g, i) => ({ ...g, order: i }));
1685
- render();
1686
- } catch (err) {
1687
- showToast('Reorder failed: ' + err.message);
1688
- }
1689
- }
2901
+ // ── Timers: writes ──
1690
2902
 
1691
2903
  async function createQuickTimerFromForm(runNow) {
1692
2904
  const templateId = $('sched-qt-template')?.value;
1693
- if (!templateId) { showToast('Select an automation first'); return; }
2905
+ if (!templateId) { markInvalid('sched-qt-template', 'Choose an automation first'); return; }
1694
2906
  const customVal = Number($('sched-qt-custom-min')?.value);
1695
2907
  const minutes = customVal >= 1 ? customVal : _selectedQtMinutes;
1696
- if (!runNow && !minutes) { showToast('Select a time or enter minutes'); return; }
2908
+ if (!runNow && !minutes) { showToast('Choose a delay or enter minutes'); return; }
1697
2909
  const template = getTemplate(templateId);
1698
2910
  const profile = $('sched-qt-profile')?.value || 'claude-code';
1699
2911
  const model = $('sched-qt-model')?.value || null;
@@ -1723,9 +2935,11 @@ async function createQuickTimerFromForm(runNow) {
1723
2935
  _quickTimers.push({ id: result.timerId, templateId, templateName: result.templateName, firesAt: result.firesAt, minutes: result.minutes, profile: result.profile, model: result.model, contextMode: result.contextMode, effort: result.effort, mcpProfile: result.mcpProfile, usesBrowser: result.usesBrowser });
1724
2936
  }
1725
2937
  _selectedQtMinutes = null;
2938
+ _panel?.querySelectorAll('[data-action="qt-select"]').forEach(chip => chip.setAttribute('aria-pressed', 'false'));
1726
2939
  showToast(`Timer set: ${result.templateName}`);
1727
2940
  }
1728
- renderTimersTab();
2941
+ renderActiveTimersInto();
2942
+ renderRail();
1729
2943
  } catch (err) {
1730
2944
  showToast((runNow ? 'Run now' : 'Timer') + ' failed: ' + err.message);
1731
2945
  }
@@ -1735,7 +2949,8 @@ async function cancelQuickTimerById(id) {
1735
2949
  try {
1736
2950
  await cancelQuickTimer(id);
1737
2951
  _quickTimers = _quickTimers.filter(t => t.id !== id);
1738
- renderTimersTab();
2952
+ renderActiveTimersInto();
2953
+ renderRail();
1739
2954
  showToast('Timer cancelled');
1740
2955
  } catch (err) {
1741
2956
  showToast('Cancel failed: ' + err.message);
@@ -1745,12 +2960,13 @@ async function cancelQuickTimerById(id) {
1745
2960
  async function createExistingScheduleTimer() {
1746
2961
  const scheduleId = $('sched-existing-timer-schedule')?.value;
1747
2962
  const minutes = Number($('sched-existing-timer-min')?.value);
1748
- if (!scheduleId) { showToast('Select a schedule'); return; }
1749
- if (!minutes || minutes < 1) { showToast('Enter minutes'); return; }
2963
+ if (!scheduleId) { markInvalid('sched-existing-timer-schedule', 'Choose a schedule'); return; }
2964
+ if (!minutes || minutes < 1) { markInvalid('sched-existing-timer-min', 'Enter the minutes from now'); return; }
1750
2965
  try {
1751
2966
  const result = await startScheduleTimer(scheduleId, minutes);
1752
2967
  _scheduleTimerData[scheduleId] = { firesAt: result.firesAt, minutes: result.minutes };
1753
- renderTimersTab();
2968
+ renderActiveTimersInto();
2969
+ renderRail();
1754
2970
  showToast('Schedule timer set');
1755
2971
  } catch (err) {
1756
2972
  showToast('Timer failed: ' + err.message);
@@ -1761,28 +2977,41 @@ async function cancelExistingScheduleTimer(id) {
1761
2977
  try {
1762
2978
  await cancelScheduleTimer(id);
1763
2979
  delete _scheduleTimerData[id];
1764
- renderTimersTab();
2980
+ renderActiveTimersInto();
2981
+ renderRail();
1765
2982
  showToast('Timer cancelled');
1766
2983
  } catch (err) {
1767
2984
  showToast('Cancel failed: ' + err.message);
1768
2985
  }
1769
2986
  }
1770
2987
 
2988
+ // ── Every second: countdowns ──
2989
+
1771
2990
  function updateTimerCountdowns() {
1772
- _panel?.querySelectorAll('.sched-active-timer').forEach(row => {
1773
- const button = row.querySelector('[data-action="qt-cancel"], [data-action="schedule-timer-cancel"]');
1774
- const id = button?.dataset.id;
1775
- let timer = _quickTimers.find(t => t.id === id);
1776
- if (!timer) timer = _scheduleTimerData[id];
1777
- const strong = row.querySelector('strong');
1778
- if (timer && strong) strong.textContent = formatTimerCountdown(timer.firesAt);
2991
+ if (!_panel) return;
2992
+ const now = Date.now();
2993
+ _panel.querySelectorAll('.sched-timer-count[data-fires-at]').forEach(el => {
2994
+ el.textContent = formatTimerCountdown(el.dataset.firesAt);
1779
2995
  });
1780
- _panel?.querySelectorAll('[data-countdown]').forEach(el => {
2996
+ let stale = false;
2997
+ _panel.querySelectorAll('[data-countdown]').forEach(el => {
1781
2998
  const iso = el.dataset.countdown;
1782
- el.textContent = formatNextRun(iso || null);
2999
+ if (!iso) return;
3000
+ const countdown = formatCountdown(iso, now);
3001
+ const rel = el.querySelector('.sched-next-rel') || el;
3002
+ if (rel.textContent !== countdown.text) rel.textContent = countdown.text;
3003
+ if (el.classList.contains('sched-next')) el.dataset.state = countdown.state;
3004
+ if (Date.parse(iso) < now - 90_000) stale = true;
1783
3005
  });
3006
+ // A run's time has passed and no update came: ask the server, at most once a minute.
3007
+ if (stale && now - _lastAutoRefresh > 60_000 && !editorOpen()) {
3008
+ _lastAutoRefresh = now;
3009
+ scheduleRefresh(0);
3010
+ }
1784
3011
  }
1785
3012
 
3013
+ // ── Live updates over the sync socket ──
3014
+
1786
3015
  function setupSync() {
1787
3016
  if (_syncReady) return;
1788
3017
  _syncReady = true;
@@ -1790,25 +3019,25 @@ function setupSync() {
1790
3019
  on('sync:schedule:created', (data) => {
1791
3020
  if (!data?.schedule) return;
1792
3021
  if (!_schedules.some(s => s.id === data.schedule.id)) _schedules.push(data.schedule);
1793
- if (_panel) render();
3022
+ refresh();
1794
3023
  });
1795
3024
  on('sync:schedule:updated', (data) => {
1796
3025
  if (!data?.schedule) return;
1797
- const idx = _schedules.findIndex(s => s.id === data.schedule.id);
1798
- if (idx >= 0) _schedules[idx] = data.schedule;
1799
- else _schedules.push(data.schedule);
1800
- if (_panel) render();
3026
+ replaceSchedule(data.schedule);
3027
+ refresh();
1801
3028
  });
1802
3029
  on('sync:schedule:deleted', (data) => {
1803
3030
  if (!data?.scheduleId) return;
1804
3031
  _schedules = _schedules.filter(s => s.id !== data.scheduleId);
1805
- if (_panel) render();
3032
+ _expanded.delete(data.scheduleId);
3033
+ refresh();
1806
3034
  });
1807
3035
  on('sync:schedule:fired', (data) => {
1808
3036
  if (data?.scheduleName) showToast(`Schedule fired: ${data.scheduleName}`);
3037
+ if (_panel) scheduleRefresh();
1809
3038
  });
1810
3039
  on('sync:schedule:completed', (data) => {
1811
- loadData().then(() => { if (_panel) render(); });
3040
+ if (_panel) scheduleRefresh();
1812
3041
  if (data?.terminalSessionId && data.surface !== 'sidepanel') {
1813
3042
  emit('terminal:attach-floating', {
1814
3043
  terminalSessionId: data.terminalSessionId,
@@ -1819,64 +3048,65 @@ function setupSync() {
1819
3048
  });
1820
3049
  on('sync:schedule:failed', (data) => {
1821
3050
  if (data?.reason) showToast(`Schedule failed: ${data.reason}`);
1822
- loadData().then(() => { if (_panel) render(); });
3051
+ if (_panel) scheduleRefresh();
1823
3052
  });
1824
3053
  on('sync:schedule:timer-set', (data) => {
1825
3054
  if (data?.scheduleId && data?.firesAt) {
1826
3055
  _scheduleTimerData[data.scheduleId] = { firesAt: data.firesAt, minutes: data.minutes };
1827
- if (_panel && _tab === 'timers') renderTimersTab();
3056
+ if (_panel) { renderRail(); renderActiveTimersInto(); }
1828
3057
  }
1829
3058
  });
1830
3059
  on('sync:schedule:timer-fired', (data) => {
1831
3060
  if (data?.scheduleName) showToast(`Timer fired: ${data.scheduleName}`);
1832
3061
  if (data?.scheduleId) delete _scheduleTimerData[data.scheduleId];
1833
- if (_panel) render();
3062
+ if (_panel) { renderRail(); renderActiveTimersInto(); }
1834
3063
  });
1835
3064
  on('sync:schedule:timer-cancelled', (data) => {
1836
3065
  if (data?.scheduleId) delete _scheduleTimerData[data.scheduleId];
1837
- if (_panel && _tab === 'timers') renderTimersTab();
3066
+ if (_panel) { renderRail(); renderActiveTimersInto(); }
1838
3067
  });
1839
3068
  on('sync:schedule-group:created', (data) => {
1840
3069
  if (data?.group && !_groups.some(g => g.id === data.group.id)) _groups.push(data.group);
1841
- if (_panel) render();
3070
+ refresh();
1842
3071
  });
1843
3072
  on('sync:schedule-group:updated', (data) => {
1844
3073
  if (!data?.group) return;
1845
3074
  const idx = _groups.findIndex(g => g.id === data.group.id);
1846
3075
  if (idx >= 0) _groups[idx] = data.group;
1847
3076
  else _groups.push(data.group);
1848
- if (_panel) render();
3077
+ refresh();
1849
3078
  });
1850
3079
  on('sync:schedule-group:deleted', (data) => {
1851
3080
  if (!data?.groupId) return;
1852
3081
  _groups = _groups.filter(g => g.id !== data.groupId);
1853
3082
  for (const schedule of _schedules) if (schedule.groupId === data.groupId) schedule.groupId = null;
1854
- if (_selectedGroup === data.groupId) _selectedGroup = 'ungrouped';
1855
- if (_panel) render();
3083
+ if (_scope === `group:${data.groupId}`) setScope('ungrouped', { keepEditors: true });
3084
+ refresh();
1856
3085
  });
1857
3086
  on('sync:schedule-group:reordered', (data) => {
1858
3087
  if (Array.isArray(data?.groups)) _groups = data.groups;
1859
- if (_panel) render();
3088
+ refresh();
1860
3089
  });
1861
3090
  on('sync:quick-timer:set', (data) => {
1862
3091
  if (data?.timerId && !_quickTimers.some(t => t.id === data.timerId)) {
1863
3092
  _quickTimers.push({ id: data.timerId, templateName: data.templateName, firesAt: data.firesAt, minutes: data.minutes, profile: data.profile, model: data.model, contextMode: data.contextMode, effort: data.effort, mcpProfile: data.mcpProfile, usesBrowser: data.usesBrowser });
1864
- if (_panel && _tab === 'timers') renderTimersTab();
3093
+ if (_panel) { renderRail(); renderActiveTimersInto(); }
1865
3094
  }
1866
3095
  });
1867
3096
  on('sync:quick-timer:fired', (data) => {
1868
3097
  if (data?.templateName) showToast(`Timer fired: ${data.templateName}`);
1869
3098
  if (data?.timerId) _quickTimers = _quickTimers.filter(t => t.id !== data.timerId);
1870
- if (_panel && _tab === 'timers') renderTimersTab();
3099
+ if (_panel) { renderRail(); renderActiveTimersInto(); }
1871
3100
  });
1872
3101
  on('sync:quick-timer:fired-now', (data) => {
1873
3102
  if (data?.templateName) showToast(`Running now: ${data.templateName}`);
1874
3103
  });
1875
3104
  on('sync:quick-timer:cancelled', (data) => {
1876
3105
  if (data?.timerId) _quickTimers = _quickTimers.filter(t => t.id !== data.timerId);
1877
- if (_panel && _tab === 'timers') renderTimersTab();
3106
+ if (_panel) { renderRail(); renderActiveTimersInto(); }
1878
3107
  });
1879
3108
  on('sync:quick-timer:failed', (data) => {
1880
3109
  if (data?.reason) showToast(`Timer failed: ${data.reason}`);
1881
3110
  });
1882
3111
  }
3112
+