Scheduled: the server's real /api/v2 routes and shapes, edit, settings, results, models
ci / check (push) Failing after 8s
ci / start (push) Failing after 5s

Re-wired to api/v2/schedules.py and its contract fixtures: description and
next_run(s), pause/resume verbs, runs paged with `before`, a run's result
(GET runs/{id}, Markdown) unfolded under its row, run.model with the switch from
a busy one, waiting and its retry, timing (late, caught up, by hand). The form
serves new and edit: words -> compile -> title, instruction and next runs; the
rule set by hand (days and a time, read back by preview) when the words give
none; notify policy, fresh or continue, model from the scheduling models,
effort and team size. Models on the list: the person's narrowing (PUT
/schedules/models), each free or busy. Live from the `schedules` event.
i18n: 39 new sentences x 8 languages (312 in all); CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-09 13:37:21 +00:00
1 parent 5ce5a62dff
commit 8590792b50
6 files changed
+1190 -188

No files matched your search

+18 -7
View File
@@ -47,13 +47,24 @@ All notable changes to LLeMbas Desktop. The format follows
### Added
- **Scheduled tasks** (with a server that has them): the list with each task's next run, how its
last run went and a switch to pause it; a task's page with what it does, when, how it tells you,
the next runs, its history (each run's status, time, model and its result to open) and **Run
now**; and a new task said in your own words -- the server reads them into a rule and an
instruction and shows the next five runs before you save. While the app runs, a scheduled task's
result the server decides to tell of comes as a system notification (never a run with nothing
to report). A server without scheduled tasks says so.
- **Scheduled tasks** (with a server that has them, LLeMbas 2.0.0-beta.6 or later): the list
with each task's next run, how its last run went and a switch to pause it, kept up to date as
the server runs them; a task's page with what it does, when (and in which time zone), its
model, effort, team and how it tells you, the next runs, and its history -- each run's status,
time, whether it was late, caught up or run by hand, the model it ran on (and that it switched
from a busy one), and its result to unfold in place, with its chat to open; earlier runs on
request. **Run now**, **Edit**, pause and resume, and delete. A new task is said in your own
words: the server reads them into a rule, a title and an instruction and shows the next five
runs before you save; the title and instruction can be changed, and where the words give no
time the days and the time can be set by hand. Each task's settings: when to tell you
(*when it matters*, *always*, *never*), a new chat for each run or one that grows, its model
(or the first free one of the scheduling models), its effort and its team. **Models** on the
list chooses which models scheduled tasks may run on, each shown free or busy.
- **A scheduled task's result comes as a system notification** while the app runs, by the
task's own setting: a result, never a run with nothing to report unless the task says
*always*, nothing for *never*; a failure the first time, and on the third in a row the
question whether to pause it. The app follows the server's event stream for this rather than
asking every few seconds.
## [1.0.0-beta.3] — 2026-11-09
+312 -9
View File
@@ -312,13 +312,52 @@ KEYS = (
"It has not run yet.",
"late",
"caught up",
"Open the result",
"Delete this scheduled task?",
"“%(title)s” stops, and its history goes. The chats it wrote stay.",
"Every weekday at 8:00, summarise the news on my reading list",
"What should happen, and when",
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.",
"These words give no run in the future.",
"%(model)s was busy, so it ran on this one.",
"%(n)s missed",
"A busy model is waited for, then the next free one is used.",
"A result, but not a run with nothing to report.",
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.",
"A team of up to %(n)s",
"A team works on it together, its own agent included.",
"Choose a model to choose its effort.",
"Due %(when)s",
"Each run sees the ones before it.",
"Each run starts with nothing but the instruction.",
"Earlier runs",
"Edit %(title)s",
"Every result, even with nothing to report.",
"Models for scheduled tasks",
"No team",
"Only in its history.",
"Open the chat",
"Result",
"The first free scheduling model",
"The instruction each run is given, on its own: say everything it needs.",
"The last %(n)s runs failed: %(why)s",
"The result is no longer there.",
"Times in %(zone)s",
"Title",
"Written to %(path)s",
"busy",
"free",
"switched",
"tried again %(when)s",
"Set the time yourself",
"Days",
"Time",
"No day ticked: every day.",
"A scheduled task failed 3 times in a row. Pause it?",
"Edit",
"Models",
"Open its chat",
"The model's own",
"run by hand",
)
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
@@ -482,13 +521,46 @@ OWN: dict[str, dict[str, str]] = {
"It has not run yet.": "Ještě neběžela.",
"late": "pozdě",
"caught up": "dohnáno",
"Open the result": "Otevřít výsledek",
"Delete this scheduled task?": "Odstranit tuto naplánovanou úlohu?",
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "„%(title)s“ se zastaví a její historie zmizí. Konverzace, které napsala, zůstanou.",
"Every weekday at 8:00, summarise the news on my reading list": "Každý pracovní den v 8:00 shrň zprávy z mého seznamu ke čtení",
"What should happen, and when": "Co se má stát a kdy",
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Vlastními slovy. Server z nich vytvoří pravidlo a pokyn a před uložením vám ukáže další spuštění.",
"These words give no run in the future.": "Z těchto slov nevychází žádné budoucí spuštění.",
"%(model)s was busy, so it ran on this one.": "%(model)s byl zaneprázdněný, proto běžela na tomto.",
"%(n)s missed": "zmeškáno: %(n)s",
"A busy model is waited for, then the next free one is used.": "Na zaneprázdněný model se čeká, pak se použije další volný.",
"A result, but not a run with nothing to report.": "Výsledek, ale ne spuštění, které nemá co hlásit.",
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Úloha bez vlastního modelu běží na prvním volném z těchto, v tomto pořadí. Žádný nezaškrtnutý: všechny.",
"A team of up to %(n)s": "Tým: nejvýše %(n)s",
"A team works on it together, its own agent included.": "Pracuje na ní tým společně, včetně jejího vlastního agenta.",
"Choose a model to choose its effort.": "Vyberte model, abyste mohli zvolit jeho náročnost.",
"Due %(when)s": "Naplánováno na %(when)s",
"Each run sees the ones before it.": "Každé spuštění vidí ta předchozí.",
"Each run starts with nothing but the instruction.": "Každé spuštění začíná jen s instrukcí.",
"Earlier runs": "Starší spuštění",
"Edit %(title)s": "Upravit %(title)s",
"Every result, even with nothing to report.": "Každý výsledek, i když není co hlásit.",
"Models for scheduled tasks": "Modely pro naplánované úlohy",
"No team": "Bez týmu",
"Only in its history.": "Jen v její historii.",
"Open the chat": "Otevřít konverzaci",
"Result": "Výsledek",
"The first free scheduling model": "První volný model pro plánování",
"The instruction each run is given, on its own: say everything it needs.": "Instrukce, kterou dostane každé spuštění samostatně: uveďte vše, co potřebuje.",
"The last %(n)s runs failed: %(why)s": "Posledních %(n)s spuštění selhalo: %(why)s",
"The result is no longer there.": "Výsledek už tu není.",
"Times in %(zone)s": "Časové pásmo: %(zone)s",
"Title": "Název",
"Written to %(path)s": "Zapsáno do %(path)s",
"busy": "zaneprázdněný",
"free": "volný",
"switched": "přepnuto",
"tried again %(when)s": "znovu %(when)s",
"Set the time yourself": "Nastavit čas sami",
"Days": "Dny",
"Time": "Čas",
"No day ticked: every day.": "Žádný den nezaškrtnutý: každý den.",
},
"de": {
"Menu": "Menü",
@@ -647,13 +719,46 @@ OWN: dict[str, dict[str, str]] = {
"It has not run yet.": "Sie ist noch nicht gelaufen.",
"late": "verspätet",
"caught up": "nachgeholt",
"Open the result": "Ergebnis öffnen",
"Delete this scheduled task?": "Diese geplante Aufgabe löschen?",
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "„%(title)s“ hört auf, und ihr Verlauf wird gelöscht. Die Chats, die sie geschrieben hat, bleiben.",
"Every weekday at 8:00, summarise the news on my reading list": "Jeden Werktag um 8:00 die Nachrichten auf meiner Leseliste zusammenfassen",
"What should happen, and when": "Was soll wann geschehen?",
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "In Ihren eigenen Worten. Der Server macht daraus eine Regel und eine Anweisung und zeigt Ihnen die nächsten Läufe, bevor etwas gespeichert wird.",
"These words give no run in the future.": "Diese Worte ergeben keinen künftigen Lauf.",
"%(model)s was busy, so it ran on this one.": "%(model)s war belegt, daher lief sie auf diesem.",
"%(n)s missed": "%(n)s verpasst",
"A busy model is waited for, then the next free one is used.": "Auf ein belegtes Modell wird gewartet, dann wird das nächste freie genommen.",
"A result, but not a run with nothing to report.": "Ein Ergebnis, aber kein Lauf ohne Neuigkeiten.",
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Eine Aufgabe ohne eigenes Modell läuft auf dem ersten freien davon, in dieser Reihenfolge. Keines angehakt: alle.",
"A team of up to %(n)s": "Ein Team mit bis zu %(n)s",
"A team works on it together, its own agent included.": "Ein Team arbeitet gemeinsam daran, ihr eigener Agent eingeschlossen.",
"Choose a model to choose its effort.": "Wählen Sie ein Modell, um seinen Aufwand zu wählen.",
"Due %(when)s": "Fällig %(when)s",
"Each run sees the ones before it.": "Jeder Lauf sieht die vorherigen.",
"Each run starts with nothing but the instruction.": "Jeder Lauf beginnt nur mit der Anweisung.",
"Earlier runs": "Frühere Läufe",
"Edit %(title)s": "%(title)s bearbeiten",
"Every result, even with nothing to report.": "Jedes Ergebnis, auch ohne Neuigkeiten.",
"Models for scheduled tasks": "Modelle für geplante Aufgaben",
"No team": "Kein Team",
"Only in its history.": "Nur in ihrem Verlauf.",
"Open the chat": "Chat öffnen",
"Result": "Ergebnis",
"The first free scheduling model": "Das erste freie Planungsmodell",
"The instruction each run is given, on its own: say everything it needs.": "Die Anweisung, die jeder Lauf für sich erhält: Sagen Sie alles, was er braucht.",
"The last %(n)s runs failed: %(why)s": "Die letzten %(n)s Läufe sind fehlgeschlagen: %(why)s",
"The result is no longer there.": "Das Ergebnis ist nicht mehr da.",
"Times in %(zone)s": "Zeitzone: %(zone)s",
"Title": "Titel",
"Written to %(path)s": "Geschrieben nach %(path)s",
"busy": "belegt",
"free": "frei",
"switched": "gewechselt",
"tried again %(when)s": "erneut %(when)s",
"Set the time yourself": "Zeit selbst festlegen",
"Days": "Tage",
"Time": "Uhrzeit",
"No day ticked: every day.": "Kein Tag angehakt: jeden Tag.",
},
"es": {
"Menu": "Menú",
@@ -812,13 +917,46 @@ OWN: dict[str, dict[str, str]] = {
"It has not run yet.": "Aún no se ha ejecutado.",
"late": "con retraso",
"caught up": "recuperada",
"Open the result": "Abrir el resultado",
"Delete this scheduled task?": "¿Eliminar esta tarea programada?",
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "«%(title)s» se detiene y su historial se borra. Los chats que escribió se conservan.",
"Every weekday at 8:00, summarise the news on my reading list": "Cada día laborable a las 8:00, resume las noticias de mi lista de lectura",
"What should happen, and when": "Qué debe ocurrir, y cuándo",
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Con sus propias palabras. El servidor las convierte en una regla y una instrucción, y le muestra las próximas ejecuciones antes de guardar nada.",
"These words give no run in the future.": "Estas palabras no dan ninguna ejecución futura.",
"%(model)s was busy, so it ran on this one.": "%(model)s estaba ocupado, así que se ejecutó en este.",
"%(n)s missed": "%(n)s perdidas",
"A busy model is waited for, then the next free one is used.": "Se espera a un modelo ocupado y luego se usa el siguiente libre.",
"A result, but not a run with nothing to report.": "Un resultado, pero no una ejecución sin nada que informar.",
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Una tarea sin modelo propio se ejecuta en el primero libre de estos, en este orden. Ninguno marcado: todos.",
"A team of up to %(n)s": "Un equipo de hasta %(n)s",
"A team works on it together, its own agent included.": "Un equipo trabaja en ella, incluido su propio agente.",
"Choose a model to choose its effort.": "Elija un modelo para elegir su esfuerzo.",
"Due %(when)s": "Prevista %(when)s",
"Each run sees the ones before it.": "Cada ejecución ve las anteriores.",
"Each run starts with nothing but the instruction.": "Cada ejecución empieza solo con la instrucción.",
"Earlier runs": "Ejecuciones anteriores",
"Edit %(title)s": "Editar %(title)s",
"Every result, even with nothing to report.": "Cada resultado, incluso sin nada que informar.",
"Models for scheduled tasks": "Modelos para tareas programadas",
"No team": "Sin equipo",
"Only in its history.": "Solo en su historial.",
"Open the chat": "Abrir el chat",
"Result": "Resultado",
"The first free scheduling model": "El primer modelo de programación libre",
"The instruction each run is given, on its own: say everything it needs.": "La instrucción que recibe cada ejecución por sí sola: diga todo lo que necesita.",
"The last %(n)s runs failed: %(why)s": "Las últimas %(n)s ejecuciones fallaron: %(why)s",
"The result is no longer there.": "El resultado ya no está.",
"Times in %(zone)s": "Zona horaria: %(zone)s",
"Title": "Título",
"Written to %(path)s": "Escrito en %(path)s",
"busy": "ocupado",
"free": "libre",
"switched": "cambiado",
"tried again %(when)s": "de nuevo %(when)s",
"Set the time yourself": "Fijar la hora usted mismo",
"Days": "Días",
"Time": "Hora",
"No day ticked: every day.": "Ningún día marcado: todos los días.",
},
"fr": {
"Menu": "Menu",
@@ -977,13 +1115,46 @@ OWN: dict[str, dict[str, str]] = {
"It has not run yet.": "Elle n’a pas encore été exécutée.",
"late": "en retard",
"caught up": "rattrapée",
"Open the result": "Ouvrir le résultat",
"Delete this scheduled task?": "Supprimer cette tâche planifiée ?",
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "« %(title)s » s’arrête et son historique disparaît. Les conversations qu’elle a écrites restent.",
"Every weekday at 8:00, summarise the news on my reading list": "Chaque jour ouvré à 8 h, résume les nouvelles de ma liste de lecture",
"What should happen, and when": "Ce qui doit se passer, et quand",
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Avec vos propres mots. Le serveur en fait une règle et une instruction, et vous montre les prochaines exécutions avant que rien ne soit enregistré.",
"These words give no run in the future.": "Ces mots ne donnent aucune exécution à venir.",
"%(model)s was busy, so it ran on this one.": "%(model)s était occupé, elle a donc tourné sur celui-ci.",
"%(n)s missed": "%(n)s manquées",
"A busy model is waited for, then the next free one is used.": "Un modèle occupé est attendu, puis le suivant libre est utilisé.",
"A result, but not a run with nothing to report.": "Un résultat, mais pas une exécution sans rien à signaler.",
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Une tâche sans modèle à elle tourne sur le premier libre de ceux-ci, dans cet ordre. Aucun coché : tous.",
"A team of up to %(n)s": "Une équipe jusqu'à %(n)s",
"A team works on it together, its own agent included.": "Une équipe y travaille ensemble, son propre agent compris.",
"Choose a model to choose its effort.": "Choisissez un modèle pour choisir son effort.",
"Due %(when)s": "Prévue %(when)s",
"Each run sees the ones before it.": "Chaque exécution voit les précédentes.",
"Each run starts with nothing but the instruction.": "Chaque exécution part de la seule instruction.",
"Earlier runs": "Exécutions précédentes",
"Edit %(title)s": "Modifier %(title)s",
"Every result, even with nothing to report.": "Chaque résultat, même sans rien à signaler.",
"Models for scheduled tasks": "Modèles pour les tâches planifiées",
"No team": "Pas d'équipe",
"Only in its history.": "Seulement dans son historique.",
"Open the chat": "Ouvrir la conversation",
"Result": "Résultat",
"The first free scheduling model": "Le premier modèle de planification libre",
"The instruction each run is given, on its own: say everything it needs.": "L'instruction donnée à chaque exécution, seule : dites tout ce dont elle a besoin.",
"The last %(n)s runs failed: %(why)s": "Les %(n)s dernières exécutions ont échoué : %(why)s",
"The result is no longer there.": "Le résultat n'est plus là.",
"Times in %(zone)s": "Fuseau horaire : %(zone)s",
"Title": "Titre",
"Written to %(path)s": "Écrit dans %(path)s",
"busy": "occupé",
"free": "libre",
"switched": "changé",
"tried again %(when)s": "de nouveau %(when)s",
"Set the time yourself": "Régler l'heure vous-même",
"Days": "Jours",
"Time": "Heure",
"No day ticked: every day.": "Aucun jour coché : tous les jours.",
},
"hu": {
"Menu": "Menü",
@@ -1142,13 +1313,46 @@ OWN: dict[str, dict[str, str]] = {
"It has not run yet.": "Még nem futott.",
"late": "késve",
"caught up": "pótolva",
"Open the result": "Az eredmény megnyitása",
"Delete this scheduled task?": "Törli ezt az ütemezett feladatot?",
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "„%(title)s” leáll, és az előzményei törlődnek. Az általa írt beszélgetések megmaradnak.",
"Every weekday at 8:00, summarise the news on my reading list": "Minden hétköznap 8:00-kor foglald össze az olvasólistám híreit",
"What should happen, and when": "Mi történjen, és mikor",
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "A saját szavaival. A szerver szabályt és utasítást készít belőlük, és mentés előtt megmutatja a következő futásokat.",
"These words give no run in the future.": "Ezekből a szavakból nem következik jövőbeli futás.",
"%(model)s was busy, so it ran on this one.": "%(model)s foglalt volt, ezért ezen futott.",
"%(n)s missed": "%(n)s kimaradt",
"A busy model is waited for, then the next free one is used.": "A foglalt modellre vár, majd a következő szabadot használja.",
"A result, but not a run with nothing to report.": "Eredmény, de nem olyan futás, amelynek nincs mit jelentenie.",
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "A saját modell nélküli feladat ezek közül az első szabadon fut, ebben a sorrendben. Ha egy sincs bejelölve: mindegyiken.",
"A team of up to %(n)s": "Legfeljebb %(n)s fős csapat",
"A team works on it together, its own agent included.": "Egy csapat dolgozik rajta együtt, a saját ügynökét is beleértve.",
"Choose a model to choose its effort.": "Válasszon modellt az erőfeszítés megadásához.",
"Due %(when)s": "Esedékes: %(when)s",
"Each run sees the ones before it.": "Minden futás látja az előzőeket.",
"Each run starts with nothing but the instruction.": "Minden futás csak az utasítással indul.",
"Earlier runs": "Korábbi futások",
"Edit %(title)s": "%(title)s szerkesztése",
"Every result, even with nothing to report.": "Minden eredmény, akkor is, ha nincs mit jelenteni.",
"Models for scheduled tasks": "Modellek az ütemezett feladatokhoz",
"No team": "Nincs csapat",
"Only in its history.": "Csak az előzményeiben.",
"Open the chat": "A csevegés megnyitása",
"Result": "Eredmény",
"The first free scheduling model": "Az első szabad ütemezési modell",
"The instruction each run is given, on its own: say everything it needs.": "Az utasítás, amelyet minden futás önmagában kap: mondjon el mindent, amire szüksége van.",
"The last %(n)s runs failed: %(why)s": "Az utolsó %(n)s futás sikertelen: %(why)s",
"The result is no longer there.": "Az eredmény már nincs meg.",
"Times in %(zone)s": "Időzóna: %(zone)s",
"Title": "Cím",
"Written to %(path)s": "Kiírva ide: %(path)s",
"busy": "foglalt",
"free": "szabad",
"switched": "váltva",
"tried again %(when)s": "újra %(when)s",
"Set the time yourself": "Az időpont megadása kézzel",
"Days": "Napok",
"Time": "Időpont",
"No day ticked: every day.": "Ha egy nap sincs bejelölve: minden nap.",
},
"it": {
"Menu": "Menu",
@@ -1307,13 +1511,46 @@ OWN: dict[str, dict[str, str]] = {
"It has not run yet.": "Non è ancora stata eseguita.",
"late": "in ritardo",
"caught up": "recuperata",
"Open the result": "Apri il risultato",
"Delete this scheduled task?": "Eliminare questa attività pianificata?",
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "«%(title)s» si ferma e la sua cronologia viene eliminata. Le chat che ha scritto restano.",
"Every weekday at 8:00, summarise the news on my reading list": "Ogni giorno feriale alle 8:00, riassumi le notizie della mia lista di lettura",
"What should happen, and when": "Cosa deve succedere, e quando",
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Con parole tue. Il server ne ricava una regola e un’istruzione e ti mostra le prossime esecuzioni prima di salvare.",
"These words give no run in the future.": "Queste parole non danno nessuna esecuzione futura.",
"%(model)s was busy, so it ran on this one.": "%(model)s era occupato, quindi è stata eseguita su questo.",
"%(n)s missed": "%(n)s perse",
"A busy model is waited for, then the next free one is used.": "Si attende un modello occupato, poi si usa il successivo libero.",
"A result, but not a run with nothing to report.": "Un risultato, ma non un’esecuzione senza nulla da segnalare.",
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Un’attività senza un modello proprio gira sul primo libero di questi, in quest’ordine. Nessuno spuntato: tutti.",
"A team of up to %(n)s": "Un team fino a %(n)s",
"A team works on it together, its own agent included.": "Un team ci lavora insieme, incluso il suo agente.",
"Choose a model to choose its effort.": "Scegli un modello per sceglierne l’impegno.",
"Due %(when)s": "Prevista %(when)s",
"Each run sees the ones before it.": "Ogni esecuzione vede quelle precedenti.",
"Each run starts with nothing but the instruction.": "Ogni esecuzione parte solo dall’istruzione.",
"Earlier runs": "Esecuzioni precedenti",
"Edit %(title)s": "Modifica %(title)s",
"Every result, even with nothing to report.": "Ogni risultato, anche senza nulla da segnalare.",
"Models for scheduled tasks": "Modelli per le attività pianificate",
"No team": "Nessun team",
"Only in its history.": "Solo nella sua cronologia.",
"Open the chat": "Apri la chat",
"Result": "Risultato",
"The first free scheduling model": "Il primo modello di pianificazione libero",
"The instruction each run is given, on its own: say everything it needs.": "L’istruzione che ogni esecuzione riceve da sola: di’ tutto ciò che le serve.",
"The last %(n)s runs failed: %(why)s": "Le ultime %(n)s esecuzioni non sono riuscite: %(why)s",
"The result is no longer there.": "Il risultato non c’è più.",
"Times in %(zone)s": "Fuso orario: %(zone)s",
"Title": "Titolo",
"Written to %(path)s": "Scritto in %(path)s",
"busy": "occupato",
"free": "libero",
"switched": "cambiato",
"tried again %(when)s": "di nuovo %(when)s",
"Set the time yourself": "Imposta tu l’orario",
"Days": "Giorni",
"Time": "Ora",
"No day ticked: every day.": "Nessun giorno spuntato: ogni giorno.",
},
"pl": {
"Menu": "Menu",
@@ -1472,13 +1709,46 @@ OWN: dict[str, dict[str, str]] = {
"It has not run yet.": "Jeszcze nie było uruchomione.",
"late": "z opóźnieniem",
"caught up": "nadrobione",
"Open the result": "Otwórz wynik",
"Delete this scheduled task?": "Usunąć to zaplanowane zadanie?",
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "„%(title)s” zatrzyma się, a jego historia zniknie. Czaty, które napisało, zostaną.",
"Every weekday at 8:00, summarise the news on my reading list": "W każdy dzień roboczy o 8:00 streść wiadomości z mojej listy do czytania",
"What should happen, and when": "Co ma się stać i kiedy",
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Własnymi słowami. Serwer zamieni je w regułę i polecenie i pokaże następne uruchomienia, zanim cokolwiek zostanie zapisane.",
"These words give no run in the future.": "Z tych słów nie wynika żadne przyszłe uruchomienie.",
"%(model)s was busy, so it ran on this one.": "%(model)s był zajęty, więc uruchomiono je na tym.",
"%(n)s missed": "pominięte: %(n)s",
"A busy model is waited for, then the next free one is used.": "Na zajęty model się czeka, potem używany jest następny wolny.",
"A result, but not a run with nothing to report.": "Wynik, ale nie uruchomienie bez niczego do zgłoszenia.",
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Zadanie bez własnego modelu działa na pierwszym wolnym z tych, w tej kolejności. Żaden niezaznaczony: wszystkie.",
"A team of up to %(n)s": "Zespół do %(n)s",
"A team works on it together, its own agent included.": "Pracuje nad nim zespół, wraz z jego własnym agentem.",
"Choose a model to choose its effort.": "Wybierz model, aby wybrać jego wysiłek.",
"Due %(when)s": "Zaplanowane na %(when)s",
"Each run sees the ones before it.": "Każde uruchomienie widzi poprzednie.",
"Each run starts with nothing but the instruction.": "Każde uruchomienie zaczyna tylko od instrukcji.",
"Earlier runs": "Wcześniejsze uruchomienia",
"Edit %(title)s": "Edytuj %(title)s",
"Every result, even with nothing to report.": "Każdy wynik, nawet bez niczego do zgłoszenia.",
"Models for scheduled tasks": "Modele dla zaplanowanych zadań",
"No team": "Bez zespołu",
"Only in its history.": "Tylko w jego historii.",
"Open the chat": "Otwórz czat",
"Result": "Wynik",
"The first free scheduling model": "Pierwszy wolny model do planowania",
"The instruction each run is given, on its own: say everything it needs.": "Instrukcja, którą każde uruchomienie dostaje samodzielnie: podaj wszystko, czego potrzebuje.",
"The last %(n)s runs failed: %(why)s": "Ostatnie uruchomienia nie powiodły się (%(n)s): %(why)s",
"The result is no longer there.": "Wyniku już nie ma.",
"Times in %(zone)s": "Strefa czasowa: %(zone)s",
"Title": "Tytuł",
"Written to %(path)s": "Zapisano do %(path)s",
"busy": "zajęty",
"free": "wolny",
"switched": "zmieniony",
"tried again %(when)s": "ponownie %(when)s",
"Set the time yourself": "Ustaw czas samodzielnie",
"Days": "Dni",
"Time": "Godzina",
"No day ticked: every day.": "Żaden dzień niezaznaczony: codziennie.",
},
"sk": {
"Menu": "Ponuka",
@@ -1637,13 +1907,46 @@ OWN: dict[str, dict[str, str]] = {
"It has not run yet.": "Ešte nebežala.",
"late": "neskoro",
"caught up": "dobehnuté",
"Open the result": "Otvoriť výsledok",
"Delete this scheduled task?": "Odstrániť túto naplánovanú úlohu?",
"“%(title)s” stops, and its history goes. The chats it wrote stay.": "„%(title)s“ sa zastaví a jej história zmizne. Konverzácie, ktoré napísala, zostanú.",
"Every weekday at 8:00, summarise the news on my reading list": "Každý pracovný deň o 8:00 zhrň správy z môjho zoznamu na čítanie",
"What should happen, and when": "Čo sa má stať a kedy",
"In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Vlastnými slovami. Server z nich vytvorí pravidlo a pokyn a pred uložením vám ukáže ďalšie spustenia.",
"These words give no run in the future.": "Z týchto slov nevychádza žiadne budúce spustenie.",
"%(model)s was busy, so it ran on this one.": "%(model)s bol zaneprázdnený, preto bežala na tomto.",
"%(n)s missed": "zmeškané: %(n)s",
"A busy model is waited for, then the next free one is used.": "Na zaneprázdnený model sa čaká, potom sa použije ďalší voľný.",
"A result, but not a run with nothing to report.": "Výsledok, ale nie spustenie, ktoré nemá čo hlásiť.",
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Úloha bez vlastného modelu beží na prvom voľnom z týchto, v tomto poradí. Žiadny nezaškrtnutý: všetky.",
"A team of up to %(n)s": "Tím: najviac %(n)s",
"A team works on it together, its own agent included.": "Pracuje na nej tím spoločne, vrátane jej vlastného agenta.",
"Choose a model to choose its effort.": "Vyberte model, aby ste mohli zvoliť jeho náročnosť.",
"Due %(when)s": "Naplánované na %(when)s",
"Each run sees the ones before it.": "Každé spustenie vidí tie predchádzajúce.",
"Each run starts with nothing but the instruction.": "Každé spustenie začína len s inštrukciou.",
"Earlier runs": "Staršie spustenia",
"Edit %(title)s": "Upraviť %(title)s",
"Every result, even with nothing to report.": "Každý výsledok, aj keď nie je čo hlásiť.",
"Models for scheduled tasks": "Modely pre naplánované úlohy",
"No team": "Bez tímu",
"Only in its history.": "Len v jej histórii.",
"Open the chat": "Otvoriť konverzáciu",
"Result": "Výsledok",
"The first free scheduling model": "Prvý voľný model na plánovanie",
"The instruction each run is given, on its own: say everything it needs.": "Inštrukcia, ktorú dostane každé spustenie samostatne: uveďte všetko, čo potrebuje.",
"The last %(n)s runs failed: %(why)s": "Posledné spustenia zlyhali (%(n)s): %(why)s",
"The result is no longer there.": "Výsledok už tu nie je.",
"Times in %(zone)s": "Časové pásmo: %(zone)s",
"Title": "Názov",
"Written to %(path)s": "Zapísané do %(path)s",
"busy": "zaneprázdnený",
"free": "voľný",
"switched": "prepnuté",
"tried again %(when)s": "znova %(when)s",
"Set the time yourself": "Nastaviť čas sami",
"Days": "Dni",
"Time": "Čas",
"No day ticked: every day.": "Žiadny deň nezaškrtnutý: každý deň.",
},
}
+35 -4
View File
@@ -108,7 +108,7 @@ bdi { unicode-bidi: isolate; }
.tool:hover, .pane__btn:hover { color: var(--ink); background: var(--surface-hover); }
.tool:disabled { opacity: var(--opacity-dim); cursor: default; }
input[type="text"], input[type="search"], input[type="number"], select, textarea {
input[type="text"], input[type="search"], input[type="number"], input[type="time"], select, textarea {
min-width: 0;
font: var(--text-sm) var(--font-body);
color: var(--ink);
@@ -116,7 +116,7 @@ input[type="text"], input[type="search"], input[type="number"], select, textarea
border: var(--border-w) solid var(--border-strong);
border-radius: var(--radius-lg);
}
input[type="text"], input[type="search"], input[type="number"], select { height: var(--control-h); padding: 0 var(--control-px-sm); }
input[type="text"], input[type="search"], input[type="number"], input[type="time"], select { height: var(--control-h); padding: 0 var(--control-px-sm); }
textarea { padding: var(--sp-2) var(--control-px-sm); resize: none; line-height: var(--leading-normal); }
:focus-visible { outline: var(--outline-w) solid var(--accent); outline-offset: var(--outline-offset); }
@@ -1380,16 +1380,47 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
font-size: var(--text-sm);
border-bottom: var(--border-w) solid var(--border);
}
.run__when { display: flex; gap: var(--sp-2); align-items: center; min-width: 0; }
.run__when { display: flex; gap: var(--sp-2); align-items: center; min-width: 0; white-space: nowrap; overflow: hidden; }
.run__late { font: var(--text-xs) var(--font-ui); color: var(--warning); }
.run__model { display: inline-grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--sp-2); align-items: center; min-width: 0; color: var(--ink-muted); }
.run__model > span:last-child { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.run__error { max-width: var(--w-field); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--warning); font-size: var(--text-xs); }
.run__error { max-width: var(--w-field-sm); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--warning); font-size: var(--text-xs); }
.sched--new { gap: var(--sp-3); }
.sched__words { width: 100%; font-size: var(--text-base); border-radius: var(--radius-soft); padding: var(--sp-3) var(--sp-4); }
.sched__preview { margin-top: var(--sp-3); }
.sched__card { display: grid; gap: var(--sp-4); padding: var(--sp-4); background: var(--surface); border: var(--border-w) solid var(--border); border-radius: var(--radius-soft); }
.sched__title { font: 600 var(--text-md) / var(--leading-tight) var(--font-body); }
/* A run's row opens its result under it, across every track. */
.run__open { justify-self: end; }
.run__switched { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
.run__detail { grid-column: 1 / -1; display: grid; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); margin-bottom: var(--sp-2); background: var(--surface); border: var(--border-w) solid var(--border); border-radius: var(--radius-soft); }
.run__detail[hidden] { display: none; }
.run__report { font: var(--text-xs) var(--font-ui); color: var(--ink-muted); overflow-wrap: anywhere; }
.sched__more { justify-self: start; }
.sched + .sched__history { margin-top: var(--sp-6); }
.sched__trouble { padding: var(--sp-3) var(--sp-4); color: var(--warning); background: var(--warning-soft); border-radius: var(--radius-soft); overflow-wrap: anywhere; }
.fact__hint { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
/* The form: the When field's words on the control's track, one line, the time zone as its hint. */
.sched__desc { align-self: center; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--text-sm); }
.sched__desc.is-error { color: var(--warning); }
/* The rule by hand: seven day toggles on equal tracks, and a time. */
.sched__manual[hidden] { display: none; }
.sdays { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: var(--sp-1); }
.sday { position: relative; display: grid; place-items: center; min-width: 0; height: var(--control-h); border: var(--border-w) solid var(--border); border-radius: var(--radius-lg); cursor: pointer; font: var(--text-xs) var(--font-ui); color: var(--ink-muted); overflow: hidden; }
.sday input { position: absolute; opacity: 0; pointer-events: none; }
.sday:has(input:checked) { color: var(--accent); background: var(--accent-soft); border-color: var(--accent); }
.sday:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 1px; }
.sched__manual-open { justify-self: start; }
.sched__manual-open[hidden] { display: none; }
.sched__settings { padding-top: var(--sp-3); border-top: var(--border-w) solid var(--border); }
/* The scheduling models: a tick, the avatar, the name, free or busy -- on fixed tracks. */
.smodels { width: 100%; }
.smodels__hint { font-size: var(--text-xs); color: var(--ink-muted); }
.smodels__list { list-style: none; margin: 0; padding: 0; display: grid; }
.smodel { display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto; gap: var(--sp-2); align-items: center; min-height: var(--control-h); color: var(--ink); cursor: pointer; }
.smodel__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--text-sm); }
.smodel__state { font: var(--text-xs) var(--font-ui); color: var(--warning); }
.smodel__state.is-free { color: var(--success); }
/* A page's way back, before its title: "Scheduled ›". */
.crumb { display: inline-flex; gap: var(--sp-1); align-items: center; padding: 0; font: inherit; color: var(--ink-muted); background: none; border: 0; cursor: pointer; }
.crumb:hover { color: var(--ink); }
+347 -35
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -332,7 +332,7 @@ async function boot() {
}
})
invoke("set_words", { words: { reply: t("The reply is ready."), agent: t("The agent has finished."), scheduled: t("A scheduled task has a result."), scheduled_failed: t("A scheduled task failed.") } })
invoke("set_words", { words: { reply: t("The reply is ready."), agent: t("The agent has finished."), scheduled: t("A scheduled task has a result."), scheduled_failed: t("A scheduled task failed."), scheduled_pause: t("A scheduled task failed 3 times in a row. Pause it?") } })
listen("status", (e) => {
const before = status
status = e.payload
+477 -132
View File
@@ -1,32 +1,47 @@
// Scheduled tasks: standing instructions the server runs on its own -- "every weekday at 8:00,
// summarise …". The list (each task's next run, how its last run went, a switch to pause it), a
// task's page (what it does, when, how it tells, its history, Run now), and a new one said in
// words: the server turns them into a rule and a standalone instruction, the next five runs are
// shown, and only then is it saved. The server runs them; this page only reads and asks.
// task's page (what it does, when, its settings, the next runs, its history with each run's
// result, Run now, edit, pause, delete), and a task said in words: the server reads them into a
// rule and an instruction (compile), the next runs are shown, the settings chosen, and only then
// is it saved. The server runs them; this page only reads and asks.
//
// Its routes are the server's /api/v2 for scheduling:
// The server's /api/v2 for scheduling (LLeMbas `api/v2/schedules.py`; the shapes are its contract
// fixtures `schedules.json`, `schedule*.json`, `event_schedules.json`):
//
// GET /schedules { schedules: [Schedule] }
// GET /schedules/{id} { schedule, runs: [Run], more }
// POST /schedules/compile { text } -> { title, instruction, summary, rule, next: [stamp] }
// POST /schedules { request, title, instruction, rule, model?, notify?, mode? } -> { schedule }
// PATCH /schedules/{id} { enabled?, notify?, mode?, model? } -> { schedule }
// POST /schedules/{id}/run -> { run }
// DELETE /schedules/{id}
// GET /schedules { schedules: [schedule] }, newest first
// POST /schedules { title, instruction, request, rule, model, notify,
// mode, effort, agents } -> 201 { schedule }
// GET /schedules/{id} { schedule }
// PATCH /schedules/{id} any of the create fields -> { schedule }
// DELETE /schedules/{id}?keep_chat=true 204
// POST /schedules/{id}/pause | /resume { schedule }
// POST /schedules/{id}/run 202 { run }: now, not counted against the rule
// GET /schedules/{id}/runs?before=&limit= { runs: [run], more }, newest first
// GET /schedules/{id}/runs/{run} { run, text }: the result, Markdown (untrusted)
// POST /schedules/compile { text } -> { ok, reason, title, instruction,
// target, rule, description, next_runs, timezone }
// POST /schedules/preview { rule } -> { rule, description, next_runs, timezone }
// GET /schedules/models { models: [model + free, busy, chosen], instance, mine }
// PUT /schedules/models { models: [id] }: the person's narrowing
// GET /events `schedules` frames, { version, schedules }: followed
// by the app (Rust, watch.rs), handed here as the
// `schedules` event
//
// A server without them answers 404: the page says this server has no scheduled tasks yet.
// Everything a task says (its title, instruction, a run's error) is the person's or a model's
// words, set as text.
// Everything a task says (its title, instruction, a run's result or error) is the person's or a
// model's words, set as text; a result's Markdown goes through markdown.js.
import { $, h, clear, icon, ICONS, when } from "./dom.js"
import { get, post, patch, del, why } from "./bridge.js"
import { ask, menu } from "./popup.js"
import { avatar } from "./models.js"
import { api, get, post, patch, del, why, listen } from "./bridge.js"
import { ask, menu, popover } from "./popup.js"
import { avatar, levelsOf, levelWord } from "./models.js"
import { markdown } from "./markdown.js"
let ctx = null // { status(), models(), me(), openChat(id), show() }
let view = { name: "list" } // { name: "list" } | { name: "task", id } | { name: "new" }
let view = { name: "list" } // { name: "list" } | { name: "task", id } | { name: "form", task? }
let tasks = []
let missing = false // the server has no scheduling routes
let shown = null // the task whose page is open: { id, last, running }
const STATUS = {
ok: () => t("Done"),
@@ -39,36 +54,56 @@ const STATUS = {
}
const NOTIFY = {
always: () => t("Always"),
matters: () => t("When it matters"),
always: () => t("Always"),
never: () => t("Never"),
}
const NOTIFY_HINT = {
matters: () => t("A result, but not a run with nothing to report."),
always: () => t("Every result, even with nothing to report."),
never: () => t("Only in its history."),
}
const MODE = {
fresh: () => t("A new chat for each run"),
continue: () => t("One chat that grows"),
}
const enc = encodeURIComponent
const TIMING = {
late: () => t("late"),
catch_up: () => t("caught up"),
manual: () => t("run by hand"),
}
function statusChip(run) {
const MAX_AGENTS = 8 // services/schedules.py MAX_AGENTS
const enc = encodeURIComponent
const RUNS_PAGE = 20
function statusChip(run, running) {
if (running) return h("span", { class: "schip is-running", text: STATUS.running() })
if (!run) return h("span", { class: "schip", text: t("Not run yet") })
const word = STATUS[run.status]?.() || run.status
const n = run.status === "missed" && run.missed > 1 ? ` ×${run.missed}` : ""
return h("span", { class: `schip is-${run.status}`, text: `${word}${n}` })
// A missed run counts the slots that passed unrun; any other run says how many it followed.
const n = run.status === "missed" ? (run.missed > 1 ? ` ×${run.missed}` : "") : run.missed > 0 ? ` · ${t("%(n)s missed", { n: run.missed })}` : ""
return h("span", { class: `schip is-${run.status}`, title: `${word}${n}`, text: `${word}${n}` })
}
/** A stamp as a date and time in the person's language: "Mon 10 Nov, 08:00". */
function stamp(value) {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return ""
if (!value || Number.isNaN(d.getTime())) return ""
return d.toLocaleString(document.documentElement.lang || undefined, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })
}
function nextWords(task) {
if (!task.enabled) return t("Paused")
if (!task.next_run_at) return t("No more runs")
return `${when(task.next_run_at)} · ${stamp(task.next_run_at)}`
if (!task.next_run) return t("No more runs")
return `${when(task.next_run)} · ${stamp(task.next_run)}`
}
function onPage() {
return !$("page-scheduled").hidden
}
// --- Reading ------------------------------------------------------------------------------------
@@ -84,6 +119,15 @@ async function load() {
}
}
/** The scheduling models, in the order they are tried; null when the server has none to say. */
async function schedulingModels() {
try {
return await get("/schedules/models")
} catch {
return null
}
}
/** Show the page: the list, or one task's page (`id`). */
export async function showScheduled(id) {
view = id ? { name: "task", id } : { name: "list" }
@@ -94,10 +138,11 @@ async function draw() {
const body = clear($("scheduled-body"))
clear($("scheduled-tools"))
clear($("scheduled-place"))
shown = null
body.append(h("p", { class: "muted page__loading", text: t("Loading…") }))
try {
if (view.name === "task") await drawTask(view.id)
else if (view.name === "new") drawNew()
else if (view.name === "form") await drawForm(view.task)
else {
await load()
drawList()
@@ -114,6 +159,11 @@ function header(title, back) {
else h1.textContent = title
}
function go(next) {
view = next
draw()
}
// --- The list -------------------------------------------------------------------------------------
function drawList() {
@@ -124,11 +174,14 @@ function drawList() {
body.append(empty(t("This server has no scheduled tasks yet."), t("Scheduled tasks need a newer LLeMbas server. Ask its administrator to update it.")))
return
}
tools.append(h("button", { type: "button", class: "btn btn--sm btn--primary", onclick: () => go({ name: "new" }) }, icon(ICONS.plus), h("span", { text: t("New scheduled task") })))
tools.append(
h("button", { type: "button", class: "btn btn--sm", id: "sched-models", "aria-haspopup": "dialog", onclick: (e) => modelsPanel(e.currentTarget) }, h("span", { text: t("Models") })),
h("button", { type: "button", class: "btn btn--sm btn--primary", id: "sched-new", onclick: () => go({ name: "form" }) }, icon(ICONS.plus), h("span", { text: t("New scheduled task") })),
)
if (!tasks.length) {
body.append(
empty(t("Nothing is scheduled."), t("Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.")),
h("div", { class: "empty__actions" }, h("button", { type: "button", class: "btn btn--primary", onclick: () => go({ name: "new" }) }, icon(ICONS.plus), h("span", { text: t("New scheduled task") }))),
h("div", { class: "empty__actions" }, h("button", { type: "button", class: "btn btn--primary", onclick: () => go({ name: "form" }) }, icon(ICONS.plus), h("span", { text: t("New scheduled task") }))),
)
return
}
@@ -152,30 +205,30 @@ function taskRow(task) {
onclick: (e) => e.stopPropagation(),
onchange: (e) => setEnabled(task, e.target.checked),
})
const row = h(
return h(
"li",
{ class: `task${task.enabled ? "" : " is-paused"}` },
{ class: `task${task.enabled ? "" : " is-paused"}`, dataset: { id: task.id } },
h(
"button",
{ type: "button", class: "task__open", onclick: () => go({ name: "task", id: task.id }) },
h("span", { class: "task__icon" }, icon(ICONS.clock)),
h("span", { class: "task__what" }, h("span", { class: "task__title", text: task.title || task.request || "" }), h("span", { class: "task__rule", text: task.summary || "" })),
h("span", { class: "task__what" }, h("span", { class: "task__title", text: task.title || task.request || "" }), h("span", { class: "task__rule", text: task.description || "" })),
h(
"span",
{ class: "task__next", title: task.enabled && task.next_run_at ? stamp(task.next_run_at) : "" },
{ class: "task__next", title: task.enabled && task.next_run ? stamp(task.next_run) : "" },
h("span", { class: "task__label", text: t("Next run") }),
h("span", { class: "task__value", text: task.enabled && task.next_run_at ? when(task.next_run_at) : nextWords(task) }),
h("span", { class: "task__value", text: task.enabled && task.next_run ? when(task.next_run) : nextWords(task) }),
),
h("span", { class: "task__last" }, statusChip(task.last_run)),
h("span", { class: "task__last" }, statusChip(task.last_run, task.running)),
),
h("label", { class: "toggle task__toggle" }, toggle),
)
return row
}
/** Pause or resume: the server's own verbs, which answer the schedule as it now is. */
async function setEnabled(task, on) {
try {
const r = await patch(`/schedules/${enc(task.id)}`, { enabled: on })
const r = await post(`/schedules/${enc(task.id)}/${on ? "resume" : "pause"}`)
Object.assign(task, r.schedule || { enabled: on })
} catch (err) {
await ask({ title: t("Scheduled"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
@@ -184,60 +237,129 @@ async function setEnabled(task, on) {
else draw()
}
function go(next) {
view = next
draw()
/**
* The scheduling models: the ones scheduled work may run on, in the order they are tried, each
* free now or busy (and why). A task without a model of its own runs on the first free one of
* those ticked; ticking none means all of them.
*/
async function modelsPanel(anchor) {
const data = await schedulingModels()
if (!data) return
let mine = new Set(data.mine || [])
const said = h("p", { class: "status is-error", hidden: true })
const save = async () => {
try {
const r = await api("PUT", "/schedules/models", { models: data.models.filter((m) => mine.has(m.id)).map((m) => m.id) })
Object.assign(data, r)
mine = new Set(r.mine || [])
said.hidden = true
} catch (err) {
said.textContent = why(err)
said.hidden = false
}
pop.redraw()
}
const content = () =>
h(
"div",
{ class: "pop__body smodels" },
h("p", { class: "pop__title", text: t("Models for scheduled tasks") }),
h("p", { class: "smodels__hint", text: t("A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.") }),
h(
"ul",
{ class: "smodels__list", role: "list" },
data.models.map((m) =>
h(
"li",
null,
h(
"label",
{ class: "smodel" },
h("input", {
type: "checkbox",
checked: mine.has(m.id),
onchange: (e) => {
if (e.target.checked) mine.add(m.id)
else mine.delete(m.id)
save()
},
}),
avatar(m),
h("span", { class: "smodel__name", text: m.name }),
h("span", { class: `smodel__state${m.free ? " is-free" : ""}`, title: m.busy || "", text: m.free ? t("free") : t("busy") }),
),
),
),
),
data.models.length ? null : h("p", { class: "muted", text: t("No model is available to this account.") }),
said,
)
const pop = popover(anchor, content, { align: "end", label: t("Models for scheduled tasks") })
}
// --- One task -----------------------------------------------------------------------------------
async function drawTask(id) {
const r = await get(`/schedules/${enc(id)}`)
const [r, page] = await Promise.all([get(`/schedules/${enc(id)}`), get(`/schedules/${enc(id)}/runs?limit=${RUNS_PAGE}`)])
const task = r.schedule
const runs = r.runs || []
shown = { id: task.id, last: task.last_run?.id || null, status: task.last_run?.status || null, running: task.running }
header(task.title || t("Scheduled"), () => go({ name: "list" }))
const tools = clear($("scheduled-tools"))
tools.append(
h("button", { type: "button", class: "btn btn--sm btn--primary", onclick: (e) => runNow(task, e.currentTarget) }, icon(ICONS.play), h("span", { text: t("Run now") })),
h("button", {
type: "button",
class: "tool",
"aria-label": t("More"),
title: t("More"),
onclick: (e) =>
menu(e.currentTarget, [
{ label: task.enabled ? t("Pause") : t("Resume"), run: () => setEnabled(task, !task.enabled) },
null,
{ label: t("Delete"), danger: true, run: () => remove(task) },
]),
}, icon(ICONS.more)),
h("button", { type: "button", class: "btn btn--sm btn--primary", id: "sched-run", disabled: !!task.running, onclick: (e) => runNow(task, e.currentTarget) }, icon(ICONS.play), h("span", { text: t("Run now") })),
h("button", { type: "button", class: "btn btn--sm", id: "sched-edit", onclick: () => go({ name: "form", task }) }, icon(ICONS.edit), h("span", { text: t("Edit") })),
h(
"button",
{
type: "button",
class: "tool",
"aria-label": t("More"),
title: t("More"),
onclick: (e) =>
menu(e.currentTarget, [
{ label: task.enabled ? t("Pause") : t("Resume"), run: () => setEnabled(task, !task.enabled) },
...(task.chat_id ? [{ label: t("Open its chat"), run: () => ctx.openChat(task.chat_id) }] : []),
null,
{ label: t("Delete"), danger: true, run: () => remove(task) },
]),
},
icon(ICONS.more),
),
)
const models = ctx.models()
const model = models.find((m) => m.id === task.model)
const models = (await schedulingModels())?.models || ctx.models()
const body = clear($("scheduled-body"))
body.append(
h(
"section",
{ class: "sched" },
task.failure_streak > 0 && task.last_error ? h("p", { class: "status is-error sched__trouble", text: t("The last %(n)s runs failed: %(why)s", { n: task.failure_streak, why: task.last_error }) }) : null,
h(
"div",
{ class: "sched__facts" },
fact(t("When"), h("span", { text: task.summary || "" })),
fact(t("When"), h("span", { text: task.description || "" }), task.timezone ? t("Times in %(zone)s", { zone: task.timezone }) : ""),
fact(t("Next run"), h("span", { text: nextWords(task) })),
fact(t("Last run"), statusChip(task.last_run)),
fact(t("Model"), model ? h("span", { class: "sched__model" }, avatar(model), h("span", { text: model.name })) : h("span", { text: task.model || t("default") })),
fact(t("Last run"), statusChip(task.last_run, task.running)),
fact(t("Model"), modelWords(models, task.model)),
fact(t("Reasoning effort"), h("span", { text: task.effort ? levelWord(task.effort) : t("The model's own") })),
fact(t("Agents"), h("span", { text: task.agents ? t("A team of up to %(n)s", { n: task.agents }) : t("No team") })),
fact(t("Tell me"), h("span", { text: NOTIFY[task.notify]?.() || NOTIFY.matters() })),
fact(t("Runs"), h("span", { text: MODE[task.mode]?.() || MODE.fresh() })),
),
h("div", { class: "sched__what" }, h("h2", { class: "sched__h", text: t("What it does") }), h("p", { class: "sched__instruction", text: task.instruction || task.request || "" }), task.request && task.request !== task.instruction ? h("p", { class: "sched__asked", text: t("You asked: “%(text)s”", { text: task.request }) }) : null),
upcoming(task.next || []),
upcoming(task.next_runs || []),
),
history(runs),
history(task, page.runs || [], page.more),
)
}
function fact(label, value) {
return h("div", { class: "fact" }, h("span", { class: "fact__label", text: label }), h("span", { class: "fact__value" }, value))
function fact(label, value, hint) {
return h("div", { class: "fact" }, h("span", { class: "fact__label", text: label }), h("span", { class: "fact__value" }, value), hint ? h("span", { class: "fact__hint", text: hint }) : null)
}
function modelWords(models, id) {
if (!id) return h("span", { text: t("The first free scheduling model") })
const m = models.find((x) => x.id === id)
return m ? h("span", { class: "sched__model" }, avatar(m), h("span", { text: m.name })) : h("span", { text: id })
}
function upcoming(next) {
@@ -245,34 +367,93 @@ function upcoming(next) {
return h("div", { class: "sched__next" }, h("h2", { class: "sched__h", text: t("The next runs") }), h("ol", { class: "runs-next" }, next.slice(0, 5).map((s) => h("li", null, h("span", { text: stamp(s) }), h("span", { class: "muted", text: when(s) })))))
}
/** The history: one row per run on fixed tracks -- status, when, model, and its result. */
function history(runs) {
/** The history: one row per run on fixed tracks -- status, when, model, and a way to its result. */
function history(task, runs, more) {
const box = h("section", { class: "sched__history" }, h("h2", { class: "sched__h", text: t("History") }))
if (!runs.length) {
box.append(h("p", { class: "muted", text: t("It has not run yet.") }))
return box
}
const models = ctx.models()
const list = h("ul", { class: "runs" })
for (const run of runs) {
const m = models.find((x) => x.id === run.model)
list.append(
h(
"li",
{ class: "run" },
h("span", { class: "run__status" }, statusChip(run)),
h("span", { class: "run__when", title: run.started_at || run.due_at || "" }, h("span", { text: stamp(run.started_at || run.due_at) }), run.timing === "late" || run.timing === "catch_up" ? h("span", { class: "run__late", text: run.timing === "late" ? t("late") : t("caught up") }) : null),
h("span", { class: "run__model", title: run.model_reason || "" }, m ? avatar(m) : null, h("span", { text: m?.name || run.model || "" })),
run.chat_id
? h("button", { type: "button", class: "btn btn--sm", text: t("Open the result"), onclick: () => ctx.openChat(run.chat_id) })
: h("span", { class: "run__error", title: run.error || "", text: run.error || "" }),
),
)
const add = (page) => {
for (const run of page) list.append(runRow(task, run))
}
add(runs)
box.append(list)
if (more) {
const earlier = h("button", { type: "button", class: "btn btn--sm sched__more", text: t("Earlier runs") })
earlier.addEventListener("click", async () => {
earlier.disabled = true
try {
const last = list.lastElementChild?.dataset.id || ""
const r = await get(`/schedules/${enc(task.id)}/runs?limit=${RUNS_PAGE}&before=${enc(last)}`)
add(r.runs || [])
if (!r.more) earlier.remove()
else earlier.disabled = false
} catch (err) {
earlier.disabled = false
await ask({ title: t("History"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
}
})
box.append(earlier)
}
return box
}
/** What a run that did not finish well says: waiting and until when, or its error. */
function runNote(run) {
if (run.status === "waiting") {
const again = run.retry_at ? t("tried again %(when)s", { when: when(run.retry_at) }) : ""
return [run.error?.text || STATUS.waiting(), again].filter(Boolean).join(" · ")
}
return run.error?.text || ""
}
function runRow(task, run) {
const model = run.model || {}
const switched = model.reason === "switched" && model.switched_from ? t("%(model)s was busy, so it ran on this one.", { model: model.switched_from }) : ""
const hasResult = run.message_id || run.report_path || run.preview
const detail = h("div", { class: "run__detail", hidden: true })
const open = hasResult
? h("button", { type: "button", class: "btn btn--sm run__open", "aria-expanded": "false", text: t("Result") })
: null
open?.addEventListener("click", async () => {
const opening = detail.hidden
open.setAttribute("aria-expanded", String(opening))
if (!opening) {
detail.hidden = true
return
}
detail.hidden = false
detail.replaceChildren(h("p", { class: "muted", text: t("Loading…") }))
try {
const r = await get(`/schedules/${enc(task.id)}/runs/${enc(run.id)}`)
detail.replaceChildren(
r.text ? h("div", { class: "md run__text" }, markdown(r.text)) : h("p", { class: "muted", text: run.preview || t("The result is no longer there.") }),
r.run?.report_path ? h("p", { class: "run__report", text: t("Written to %(path)s", { path: r.run.report_path }) }) : null,
r.run?.chat_id ? h("div", { class: "set__buttons" }, h("button", { type: "button", class: "btn btn--sm", text: t("Open the chat"), onclick: () => ctx.openChat(r.run.chat_id) })) : null,
)
} catch (err) {
detail.replaceChildren(h("p", { class: "status is-error", text: why(err) }))
}
})
const note = runNote(run)
return h(
"li",
{ class: "run", dataset: { id: run.id } },
h("span", { class: "run__status" }, statusChip(run)),
h(
"span",
{ class: "run__when", title: run.due_at ? t("Due %(when)s", { when: stamp(run.due_at) }) : "" },
h("span", { text: stamp(run.started_at || run.due_at) }),
TIMING[run.timing] ? h("span", { class: "run__late", text: TIMING[run.timing]() }) : null,
),
h("span", { class: "run__model", title: switched }, h("span", { text: model.name || "" }), switched ? h("span", { class: "run__switched", text: t("switched") }) : null),
open || h("span", { class: "run__error", title: note, text: note }),
detail,
)
}
async function runNow(task, btn) {
btn.disabled = true
try {
@@ -288,95 +469,258 @@ async function remove(task) {
const yes = await ask({ title: t("Delete this scheduled task?"), text: t("“%(title)s” stops, and its history goes. The chats it wrote stay."), ok: t("Delete"), cancel: t("Cancel"), danger: true })
if (!yes) return
try {
await del(`/schedules/${enc(task.id)}`)
await del(`/schedules/${enc(task.id)}?keep_chat=true`)
go({ name: "list" })
} catch (err) {
await ask({ title: t("Delete"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
}
}
// --- A new task, said in words ------------------------------------------------------------------
// --- A task said in words: new, or an existing one edited ---------------------------------------
let compiled = null // the server's reading of the words: { title, instruction, summary, rule, next }
function drawNew() {
header(t("New scheduled task"), () => go({ name: "list" }))
/**
* The form. The words first: Read them asks the server (compile) for the rule, its title and
* instruction, and the next runs; the title and instruction can then be changed. Below, the
* settings the server keeps per task. An existing task starts from what it is: its words, title,
* instruction, and its rule read back (preview) -- a paused task's next runs too.
*/
async function drawForm(task) {
const editing = !!task
header(editing ? t("Edit %(title)s", { title: task.title }) : t("New scheduled task"), () => go(editing ? { name: "task", id: task.id } : { name: "list" }))
clear($("scheduled-tools"))
compiled = null
const words = h("textarea", { id: "sched-words", class: "sched__words", rows: "4", placeholder: t("Every weekday at 8:00, summarise the news on my reading list"), "aria-label": t("What should happen, and when") })
const smodels = (await schedulingModels())?.models || []
// What will be saved: the rule only from the server's reading of words (or the task's own).
const draft = {
request: task?.request || "",
title: task?.title || "",
instruction: task?.instruction || "",
rule: task?.rule || null,
target: task?.target || "chat",
model: task?.model || "",
effort: task?.effort || "",
agents: task?.agents || null,
notify: task?.notify || "matters",
mode: task?.mode || "fresh",
}
let reading = editing ? await post("/schedules/preview", { rule: task.rule }).catch(() => null) : null
const words = h("textarea", { id: "sched-words", class: "sched__words", rows: "3", placeholder: t("Every weekday at 8:00, summarise the news on my reading list"), "aria-label": t("What should happen, and when"), value: draft.request })
const readBtn = h("button", { type: "submit", class: "btn", text: editing ? t("Read again") : t("Preview") })
const said = h("p", { class: "status is-error", hidden: true })
const preview = h("div", { class: "sched__preview", hidden: true })
const read = h("button", { type: "submit", class: "btn btn--primary", text: t("Preview") })
const when_ = h("span", { class: "sched__desc", id: "sched-when" })
const zone = h("p", { class: "hint" })
const title = h("input", { type: "text", id: "sched-title", value: draft.title, "aria-label": t("Title") })
const instruction = h("textarea", { id: "sched-instruction", class: "sched__words", rows: "3", "aria-label": t("What it does"), value: draft.instruction })
const settings = h("div", { class: "fields sched__settings" })
const saveBtn = h("button", { type: "button", class: "btn btn--primary", id: "sched-save", text: t("Save") })
const saveSaid = h("p", { class: "status is-error", hidden: true })
const nextBox = h("div", { class: "sched__nextbox" })
const manual = manualRule((rule) => {
post("/schedules/preview", { rule })
.then((r) => {
reading = r
draft.rule = r.rule || {}
drawWhen()
})
.catch((err) => {
saveSaid.textContent = why(err)
saveSaid.hidden = false
})
})
const manualBtn = h("button", { type: "button", class: "btn btn--sm sched__manual-open", text: t("Set the time yourself"), onclick: () => ((manual.hidden = false), (manualBtn.hidden = true), manual.querySelector("input")?.focus()) })
const rest = h(
"div",
{ class: "sched__card", hidden: !editing },
h("div", { class: "fields" }, field(t("Title"), title, "", "sched-title"), h("div", { class: "field" }, h("span", { class: "label", text: t("When") }), when_, zone)),
manualBtn,
manual,
nextBox,
h("label", { class: "sched__h", for: "sched-instruction", text: t("What it does") }),
instruction,
h("p", { class: "hint", text: t("The instruction each run is given, on its own: say everything it needs.") }),
settings,
saveSaid,
h("div", { class: "set__buttons" }, saveBtn),
)
const drawWhen = () => {
const r = reading
const next = r?.next_runs || []
const runs = !!(draft.rule && Object.keys(draft.rule).length)
when_.textContent = (runs && r?.description) || t("These words give no run in the future.")
when_.title = when_.textContent
when_.classList.toggle("is-error", !runs)
zone.textContent = runs && r?.timezone ? t("Times in %(zone)s", { zone: r.timezone }) : ""
nextBox.replaceChildren(upcoming(next) || "")
saveBtn.disabled = !(draft.rule && Object.keys(draft.rule).length)
}
const drawSettings = () => {
const model = smodels.find((m) => m.id === draft.model) || ctx.models().find((m) => m.id === draft.model)
const select = (id, table, key, onChange) =>
h(
"select",
{ id, onchange: (e) => ((draft[key] = e.target.value), onChange?.()) },
Object.entries(table).map(([v, word]) => h("option", { value: v, text: word(), selected: draft[key] === v })),
)
const modelSelect = h(
"select",
{ id: "sched-model", onchange: (e) => ((draft.model = e.target.value), (draft.effort = ""), drawSettings()) },
h("option", { value: "", text: t("The first free scheduling model"), selected: !draft.model }),
smodels.map((m) => h("option", { value: m.id, text: m.free ? m.name : `${m.name} · ${t("busy")}`, selected: draft.model === m.id })),
draft.model && !smodels.some((m) => m.id === draft.model) ? h("option", { value: draft.model, text: draft.model, selected: true }) : null,
)
const levels = model ? levelsOf(model).levels.filter((l) => l !== "default") : []
const effort = h(
"select",
{ id: "sched-effort", disabled: !levels.length && !draft.effort, onchange: (e) => (draft.effort = e.target.value) },
h("option", { value: "", text: t("The model's own"), selected: !draft.effort }),
levels.map((l) => h("option", { value: l, text: levelWord(l), selected: draft.effort === l })),
// A level the task has that its model no longer offers stays, as it is, until changed.
draft.effort && !levels.includes(draft.effort) ? h("option", { value: draft.effort, text: levelWord(draft.effort), selected: true }) : null,
)
const agents = h(
"select",
{ id: "sched-agents", onchange: (e) => (draft.agents = Number(e.target.value) || null) },
h("option", { value: "", text: t("No team"), selected: !draft.agents }),
Array.from({ length: MAX_AGENTS - 1 }, (_, i) => i + 2).map((n) => h("option", { value: String(n), text: t("A team of up to %(n)s", { n }), selected: draft.agents === n })),
)
settings.replaceChildren(
field(t("Tell me"), select("sched-notify", NOTIFY, "notify", () => (settings.querySelector("#sched-notify-hint").textContent = NOTIFY_HINT[draft.notify]())), NOTIFY_HINT[draft.notify]?.() || "", "sched-notify", "sched-notify-hint"),
field(t("Runs"), select("sched-mode", MODE, "mode"), draft.mode === "continue" ? t("Each run sees the ones before it.") : t("Each run starts with nothing but the instruction."), "sched-mode"),
field(t("Model"), modelSelect, t("A busy model is waited for, then the next free one is used."), "sched-model"),
field(t("Reasoning effort"), effort, levels.length ? "" : t("Choose a model to choose its effort."), "sched-effort"),
field(t("Agents"), agents, t("A team works on it together, its own agent included."), "sched-agents"),
)
settings.querySelector("#sched-mode").addEventListener("change", drawSettings)
}
const form = h(
"form",
{ class: "sched sched--new" },
h("label", { class: "sched__h", for: "sched-words", text: t("What should happen, and when") }),
words,
h("p", { class: "hint", text: t("In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.") }),
h("div", { class: "set__buttons" }, read),
h("div", { class: "set__buttons" }, readBtn),
said,
preview,
rest,
)
form.addEventListener("submit", async (e) => {
e.preventDefault()
const text = words.value.trim()
if (!text) return words.focus()
read.disabled = true
readBtn.disabled = true
said.hidden = true
try {
compiled = await post("/schedules/compile", { text })
drawPreview(preview, text)
const c = await post("/schedules/compile", { text })
draft.request = text
draft.rule = c.rule || {}
if (c.title || !editing) title.value = c.title || ""
if (c.instruction || !editing) instruction.value = c.instruction || text
if (c.target) draft.target = c.target
reading = c
if (!c.ok && c.reason) {
said.textContent = c.reason
said.hidden = false
}
// The words gave no rule: the form for one, rather than a task that cannot run.
if (!(c.rule && Object.keys(c.rule).length)) {
manual.hidden = false
manualBtn.hidden = true
}
rest.hidden = false
drawWhen()
;(c.ok ? saveBtn : instruction).focus()
} catch (err) {
said.textContent = why(err)
said.hidden = false
} finally {
read.disabled = false
readBtn.disabled = false
}
})
words.addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit()
})
saveBtn.addEventListener("click", async () => {
saveBtn.disabled = true
saveSaid.hidden = true
const body = {
request: draft.request || words.value.trim(),
title: title.value.trim(),
instruction: instruction.value.trim(),
rule: draft.rule,
target: draft.target,
model: draft.model,
effort: draft.effort,
agents: draft.agents,
notify: draft.notify,
mode: draft.mode,
}
try {
const r = editing ? await patch(`/schedules/${enc(task.id)}`, body) : await post("/schedules", body)
go({ name: "task", id: r.schedule.id })
} catch (err) {
saveSaid.textContent = why(err)
saveSaid.hidden = false
saveBtn.disabled = false
}
})
clear($("scheduled-body")).append(form)
drawSettings()
drawWhen()
words.focus()
}
/** What the server made of the words: the rule in words, the instruction, the next five runs. */
function drawPreview(box, text) {
const c = compiled
box.hidden = false
const choice = { notify: "matters", mode: "fresh" }
const select = (key, table) =>
h(
"select",
{ "aria-label": key === "notify" ? t("Tell me") : t("Runs"), onchange: (e) => (choice[key] = e.target.value) },
Object.entries(table).map(([v, word]) => h("option", { value: v, text: word(), selected: choice[key] === v })),
)
const save = h("button", { type: "button", class: "btn btn--primary", text: t("Save") })
const said = h("p", { class: "status is-error", hidden: true })
save.addEventListener("click", async () => {
save.disabled = true
try {
const r = await post("/schedules", { request: text, title: c.title, instruction: c.instruction, rule: c.rule, notify: choice.notify, mode: choice.mode })
go({ name: "task", id: r.schedule.id })
} catch (err) {
said.textContent = why(err)
said.hidden = false
save.disabled = false
}
/**
* The rule set by hand, for words the server could not read into one: the days of the week (none
* ticked: every day) and a time, as the rule's calendar (`{ at: { weekdays, times } }`, Monday 0).
* `onRule(rule)` on every change; the server reads it back (preview).
*/
function manualRule(onRule) {
const lang = document.documentElement.lang || undefined
const days = Array.from({ length: 7 }, (_, i) => {
const d = new Date(2024, 0, 1 + i) // 1 January 2024 was a Monday
return h("label", { class: "sday", title: d.toLocaleDateString(lang, { weekday: "long" }) }, h("input", { type: "checkbox", value: String(i) }), h("span", { text: d.toLocaleDateString(lang, { weekday: "short" }) }))
})
box.replaceChildren(
h(
"div",
{ class: "sched__card" },
h("p", { class: "sched__title", text: c.title || "" }),
h("div", { class: "sched__facts" }, fact(t("When"), h("span", { text: c.summary || "" })), fact(t("What it does"), h("span", { text: c.instruction || "" }))),
upcoming(c.next || []) || h("p", { class: "status is-error", text: t("These words give no run in the future.") }),
h("div", { class: "sched__facts" }, fact(t("Tell me"), select("notify", NOTIFY)), fact(t("Runs"), select("mode", MODE))),
said,
h("div", { class: "set__buttons" }, save),
),
const time = h("input", { type: "time", id: "sched-time", value: "08:00", required: true })
const box = h(
"div",
{ class: "sched__manual", hidden: true },
h("div", { class: "fields" }, h("div", { class: "field" }, h("span", { class: "label", text: t("Days") }), h("div", { class: "sdays", role: "group", "aria-label": t("Days") }, days), h("p", { class: "hint", text: t("No day ticked: every day.") })), field(t("Time"), time, "", "sched-time")),
)
save.focus()
const changed = () => {
if (!time.value) return
const weekdays = days.map((d) => d.querySelector("input")).filter((i) => i.checked).map((i) => Number(i.value))
onRule({ at: { ...(weekdays.length ? { weekdays } : {}), times: [time.value] } })
}
box.addEventListener("change", changed)
return box
}
/** A field on the form's shared tracks: label, control, hint -- always all three. */
function field(label, control, hint, forId, hintId) {
return h("div", { class: "field" }, h("label", { for: forId || null, text: label }), control, h("p", { class: "hint", id: hintId || null, text: hint || "" }))
}
// --- Live: the list as the server's event stream gives it ---------------------------------------
/** A `schedules` frame (`{ version, schedules }`), handed on by the app: redraw what it changes. */
function onSchedules(list) {
if (!Array.isArray(list?.schedules)) return
tasks = list.schedules
missing = false
if (!onPage() || popoverOpenHere()) return
if (view.name === "list") drawList()
else if (view.name === "task" && shown) {
const now = tasks.find((x) => x.id === shown.id)
if (!now) return go({ name: "list" })
const last = now.last_run || {}
if ((last.id || null) !== shown.last || (last.status || null) !== shown.status || now.running !== shown.running) draw()
}
}
function popoverOpenHere() {
return !!document.querySelector(".pop .smodels")
}
/** The palette's commands for scheduled tasks. */
@@ -386,7 +730,7 @@ export function scheduledCommands() {
label: t("New scheduled task"),
run: () => {
ctx.show()
go({ name: "new" })
go({ name: "form" })
},
},
]
@@ -394,4 +738,5 @@ export function scheduledCommands() {
export function initScheduled(context) {
ctx = context
listen("schedules", (e) => onSchedules(e.payload))
}