synabun 2.0.9 → 2.1.0

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