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