Improve scheduler prompt editing and support optional YAML
Some checks failed
Build And Publish Docker Images / plan (push) Has been cancelled
Build And Publish Docker Images / build (push) Has been cancelled

Give task prompts full-width, resizable fields and an expanded view in
the shared modal stack. Preserve drafts on failed saves and keep actions
in the pinned footer.

Honor the selected initial task state and inherit only the active chat's
project. Support existing tasks.yaml files through the existing helpers,
with JSON remaining the default and taking precedence. Do not migrate
existing task files or change scheduler execution logic.

Validation:
- 28 focused scheduler, timezone, and tool-contract checks passed.
- 8 checks passed in the Docker framework runtime.
- Live desktop/mobile creation, editing, deletion, and storage checks.
This commit is contained in:
Alessandro 2026-09-24 00:59:39 +02:00
parent 50db781a4d
commit 0efb0d4f8f
9 changed files with 409 additions and 98 deletions

View file

@ -1,7 +1,7 @@
from helpers.api import ApiHandler, Input, Output, Request
from helpers.task_scheduler import (
TaskScheduler, ScheduledTask, AdHocTask, PlannedTask, TaskSchedule,
serialize_task, parse_task_schedule, parse_task_plan, TaskType
serialize_task, parse_task_schedule, parse_task_plan, TaskType, TaskState
)
from helpers.projects import load_basic_project_data
from helpers.localization import Localization
@ -27,6 +27,7 @@ class SchedulerTaskCreate(ApiHandler):
name = input.get("name")
system_prompt = input.get("system_prompt", "")
prompt = input.get("prompt")
state = TaskState(input.get("state", TaskState.IDLE))
attachments = input.get("attachments", [])
requested_project_slug = input.get("project_name")
@ -138,6 +139,7 @@ class SchedulerTaskCreate(ApiHandler):
printer.print(f"AdHocTask created with token: '{task.token}'")
# Add the task to the scheduler
task.state = state
await scheduler.add_task(task)
# Verify the task was added correctly - retrieve by UUID to check persistence

View file

@ -20,6 +20,7 @@
- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change.
- `SchedulerTaskCreate` is an `ApiHandler`.
- `SchedulerTaskCreate` defines `process(...)`.
- An optional `state` is validated as `TaskState` and applied before persistence; it defaults to `idle`. Creating a disabled task must not briefly expose it as runnable.
- Observed side-effect areas: filesystem writes, secret handling, scheduler state.
- Imported dependency areas include: `helpers.api`, `helpers.localization`, `helpers.print_style`, `helpers.projects`, `helpers.task_scheduler`, `random`.
@ -37,7 +38,7 @@
## Verification
- Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists.
- No direct test reference was found by name search; choose the nearest behavioral test or perform a focused smoke check.
- Run `tests/test_scheduler_editor.py` for initial-state persistence.
## Child DOX Index

View file

@ -22,7 +22,7 @@ from helpers.print_style import PrintStyle
from helpers.defer import DeferredTask
from helpers.files import get_abs_path, make_dirs, read_file, write_file
from helpers.localization import Localization
from helpers import projects, guids
from helpers import projects, guids, yaml
import pytz
from typing import Annotated
@ -509,6 +509,12 @@ class PlannedTask(BaseTask):
await super().on_error(error)
def _task_file_path() -> str:
json_path = get_abs_path(SCHEDULER_FOLDER, "tasks.json")
yaml_path = get_abs_path(SCHEDULER_FOLDER, "tasks.yaml")
return yaml_path if not exists(json_path) and exists(yaml_path) else json_path
class SchedulerTaskList(BaseModel):
tasks: list[Annotated[Union[ScheduledTask, AdHocTask, PlannedTask], Field(discriminator="type")]] = Field(default_factory=list)
# Singleton instance
@ -518,26 +524,33 @@ class SchedulerTaskList(BaseModel):
@classmethod
def get(cls) -> "SchedulerTaskList":
path = get_abs_path(SCHEDULER_FOLDER, "tasks.json")
path = _task_file_path()
if cls.__instance is None:
if not exists(path):
make_dirs(path)
cls.__instance = asyncio.run(cls(tasks=[]).save())
else:
cls.__instance = cls.model_validate_json(read_file(path))
cls.__instance = cls._load(path)
else:
asyncio.run(cls.__instance.reload())
return cls.__instance
@classmethod
def _load(cls, path: str) -> "SchedulerTaskList":
content = read_file(path)
if path.endswith(".yaml"):
return cls.model_validate(yaml.loads(content))
return cls.model_validate_json(content)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._lock = threading.RLock()
async def reload(self) -> "SchedulerTaskList":
path = get_abs_path(SCHEDULER_FOLDER, "tasks.json")
path = _task_file_path()
if exists(path):
with self._lock:
data = self.__class__.model_validate_json(read_file(path))
data = self.__class__._load(path)
self.tasks.clear()
self.tasks.extend(data.tasks)
return self
@ -563,7 +576,7 @@ class SchedulerTaskList(BaseModel):
f"Fixed: Generated new token '{task.token}' for task {task.name}"
)
path = get_abs_path(SCHEDULER_FOLDER, "tasks.json")
path = _task_file_path()
if not exists(path):
make_dirs(path)
@ -576,7 +589,7 @@ class SchedulerTaskList(BaseModel):
"ERROR: Found null token in JSON output for an adhoc task"
)
write_file(path, json_data)
write_file(path, yaml.from_json(json_data) if path.endswith(".yaml") else json_data)
# Debug: Verify after saving
if exists(path):

View file

@ -83,6 +83,8 @@
## Runtime Contracts
- `SchedulerTaskList` keeps `usr/scheduler/tasks.json` as its default and prefers it when both formats exist. An existing `tasks.yaml` is supported when no JSON file exists; saves use the same selection and format. Loading never migrates, renames, or creates a second file.
- JSON retains its existing Pydantic parser and `helpers.files` persistence path. Optional YAML uses `helpers.yaml` with the same task models. Invalid data raises before replacing the in-memory task list; YAML comments and formatting are regenerated on save.
- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, network calls, settings/state persistence, secret handling, scheduler state.
@ -104,6 +106,7 @@
- Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers.
- Related tests observed by source search:
- `tests/test_task_scheduler_timezone.py`
- `tests/test_scheduler_editor.py`
- `tests/test_timezone_regressions.py`
- `tests/test_tool_action_contracts.py`

View file

@ -0,0 +1,241 @@
"""Scheduler prompt editing and human-editable task persistence."""
import asyncio
from datetime import datetime, timezone, timedelta
import json
from pathlib import Path
import re
import subprocess
import sys
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import yaml
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from helpers import task_scheduler as scheduler
from api.scheduler_task_create import SchedulerTaskCreate
def test_scheduler_json_default_and_optional_yaml():
common = dict(
name="Prompt editing", system_prompt="First line\n Keep indentation: # sì\n",
prompt="Check these values:\ntrue\n123\n*\n\n", state="disabled",
created_at=datetime(2026, 9, 24, tzinfo=timezone.utc),
updated_at=datetime(2026, 9, 24, tzinfo=timezone.utc),
attachments=["/tmp/report.md"], last_result="A result\nwith two lines",
)
original = scheduler.SchedulerTaskList(tasks=[
scheduler.AdHocTask(**common, token="000123"),
scheduler.ScheduledTask(**common, schedule=scheduler.TaskSchedule(
minute="30", hour="9", day="*", month="*", weekday="*", timezone="Europe/Rome",
)),
scheduler.PlannedTask(**common, plan=scheduler.TaskPlan(
todo=[datetime(2026, 10, 1, 9, tzinfo=timezone.utc)],
)),
])
expected = original.model_dump(mode="json")
for extension in ("json", "yaml"):
with TemporaryDirectory() as folder, patch.object(scheduler, "SCHEDULER_FOLDER", folder), patch.object(
scheduler.SchedulerTaskList, "_SchedulerTaskList__instance", None,
):
path = Path(folder, f"tasks.{extension}")
text = original.model_dump_json() if extension == "json" else scheduler.yaml.dumps(expected)
path.write_text(text, encoding="utf-8")
tasks = scheduler.SchedulerTaskList.get()
assert tasks.model_dump(mode="json") == expected
assert path.read_text(encoding="utf-8") == text, "loading must not rewrite the file"
asyncio.run(tasks.save())
assert list(Path(folder).iterdir()) == [path], "saving must not migrate formats"
assert yaml.safe_load(path.read_text(encoding="utf-8")) == expected
path.write_text(path.read_text(encoding="utf-8").replace("First line", "Edited externally"), encoding="utf-8")
asyncio.run(tasks.reload())
assert all(task.system_prompt.startswith("Edited externally\n") for task in tasks.tasks)
before = tasks.model_dump(mode="json")
for invalid in ("tasks: [", "", '{"tasks":{}}', '{"tasks":[{"type":"unknown"}]}', "!!python/object:builtins.object {}"):
path.write_text(invalid, encoding="utf-8")
try:
asyncio.run(tasks.reload())
except (ValueError, yaml.YAMLError):
pass
else:
raise AssertionError("Invalid task data was accepted")
assert tasks.model_dump(mode="json") == before
assert path.read_text(encoding="utf-8") == invalid
path.write_text(text, encoding="utf-8")
json_path, yaml_path = Path(folder, "tasks.json"), Path(folder, "tasks.yaml")
json_path.write_text(original.model_dump_json(), encoding="utf-8")
yaml_path.write_text("tasks: []\n", encoding="utf-8")
with patch.object(scheduler.yaml, "loads", side_effect=AssertionError("JSON must not use YAML parsing")):
asyncio.run(tasks.reload())
assert tasks.model_dump(mode="json") == expected
asyncio.run(tasks.remove_task_by_uuid(tasks.tasks[0].uuid))
assert len(json.loads(json_path.read_text())["tasks"]) == 2
assert yaml_path.read_text() == "tasks: []\n", "JSON takes precedence without changing YAML"
with TemporaryDirectory() as folder, patch.object(scheduler, "SCHEDULER_FOLDER", folder), patch.object(
scheduler.SchedulerTaskList, "_SchedulerTaskList__instance", None,
):
assert scheduler.SchedulerTaskList.get().tasks == []
assert json.loads(Path(folder, "tasks.json").read_text()) == {"tasks": []}
assert not Path(folder, "tasks.yaml").exists()
def test_scheduler_due_tasks_and_execution_guards_in_both_formats():
now = datetime(2026, 9, 24, 8, 30, 30, tzinfo=timezone.utc)
common = dict(name="Execution check", system_prompt="", prompt="Check scheduler state")
templates = [
scheduler.AdHocTask(**common),
scheduler.ScheduledTask(**common, schedule=scheduler.TaskSchedule(
minute="30", hour="10", day="*", month="*", weekday="*", timezone="Europe/Rome",
)),
scheduler.PlannedTask(**common, plan=scheduler.TaskPlan(todo=[now - timedelta(minutes=1)])),
]
data = scheduler.SchedulerTaskList(tasks=[
task.__class__.model_validate({**task.model_dump(), "uuid": f"{task.type.value}-{state.value}", "state": state})
for task in templates for state in scheduler.TaskState
])
async def check(engine):
due = await engine._tasks.get_due_tasks()
assert {task.uuid for task in due} == {"scheduled-idle", "planned-idle"}
for task in list(engine.get_tasks()):
if task.state in (scheduler.TaskState.DISABLED, scheduler.TaskState.RUNNING):
try:
await engine.run_task_by_uuid(task.uuid)
except ValueError:
pass
else:
raise AssertionError("A disabled or running task was started")
else:
await engine.run_task_by_uuid(task.uuid)
assert engine._run_task.call_args.args[0].state == scheduler.TaskState.IDLE
assert engine._run_task.await_count == 6
claim = await engine.update_task_checked(
"planned-idle", lambda task: task.state == scheduler.TaskState.IDLE, state=scheduler.TaskState.RUNNING,
)
assert claim is not None
assert await engine.update_task_checked(
"planned-idle", lambda task: task.state == scheduler.TaskState.IDLE, state=scheduler.TaskState.RUNNING,
) is None
await claim.on_run()
await claim.on_success("Result\nwith preserved line breaks")
await claim.on_finish()
await engine.reload()
finished = engine.get_task_by_uuid("planned-idle")
assert finished.state == scheduler.TaskState.IDLE
assert finished.last_run == now
assert finished.last_result == "Result\nwith preserved line breaks"
assert finished.plan.todo == [] and finished.plan.in_progress is None
assert finished.plan.done == [now - timedelta(minutes=1)]
assert "planned-idle" not in {task.uuid for task in await engine._tasks.get_due_tasks()}
failed = engine.get_task_by_uuid("adhoc-idle")
await failed.on_error("Expected failure")
await engine.reload()
assert engine.get_task_by_uuid(failed.uuid).state == scheduler.TaskState.ERROR
assert engine.get_task_by_uuid(failed.uuid).last_result == "ERROR: Expected failure"
for extension in ("json", "yaml"):
with TemporaryDirectory() as folder, patch.object(scheduler, "SCHEDULER_FOLDER", folder), patch.object(
scheduler, "_now", return_value=now,
), patch("helpers.state_monitor_integration.mark_dirty_all"):
content = data.model_dump_json() if extension == "json" else scheduler.yaml.from_json(data.model_dump_json())
Path(folder, f"tasks.{extension}").write_text(content, encoding="utf-8")
engine = object.__new__(scheduler.TaskScheduler)
engine._tasks = asyncio.run(scheduler.SchedulerTaskList().reload())
engine._run_task = AsyncMock()
with patch.object(scheduler.TaskScheduler, "get", return_value=engine):
asyncio.run(check(engine))
def test_scheduler_creation_applies_state_before_persistence():
states = []
fake = SimpleNamespace(
reload=AsyncMock(), add_task=AsyncMock(side_effect=lambda task: states.append(task.state)),
get_task_by_uuid=lambda _uuid: fake.add_task.call_args.args[0],
)
with patch.object(scheduler.TaskScheduler, "get", return_value=fake):
for config in ({}, {"schedule": "0 9 * * *"}, {"plan": {"todo": ["2026-10-01T09:00:00+00:00"]}}):
for state in ({}, {"state": "disabled"}):
result = asyncio.run(SchedulerTaskCreate(None, None).process({
"name": "Initial state", "prompt": "Keep disabled until enabled", **config, **state,
}, None))
assert result["ok"]
assert states[-1] == state.get("state", "idle") == result["task"]["state"]
def test_scheduler_editor_preserves_drafts_until_save_succeeds():
source = (PROJECT_ROOT / "webui/components/modals/scheduler/scheduler-store.js").read_text()
source = re.sub(r"^import\b[\s\S]*?;\n", "", source, flags=re.M)
source = source.replace("export { store };", "")
script = r'''
import assert from 'node:assert/strict';
const window = { localStorage: { getItem: () => null }, alert: assert.fail };
const createStore = (_name, model) => model;
const getUserTimezone = () => 'Europe/Rome';
let response, sent, destroyed = 0;
const errors = [], opened = [], closed = [];
const openModal = async path => opened.push(path);
const closeModal = async path => closed.push(path);
const notificationsStore = { frontendError: message => errors.push(message), frontendSuccess() {} };
const fetchApi = async (_endpoint, options) => {
sent = JSON.parse(options.body);
if (response instanceof Error) throw response;
return response;
};
''' + source + r'''
store.destroyFlatpickr = () => destroyed++;
for (const isCreating of [true, false]) {
store.isCreating = isCreating;
store.isEditing = !isCreating;
store.editingTask = defaultEditingTask({
uuid: 'task-id', type: 'adhoc', name: 'Task', token: '123',
system_prompt: 'Keep\nall instructions', prompt: 'A long prompt\nwith edits\n',
});
const draft = store.editingTask;
for (const failure of [
{ ok: true, json: async () => ({ error: 'Invalid task' }) },
{ ok: false, json: async () => ({ error: 'Save failed' }) },
new Error('Network unavailable'),
]) {
response = failure;
await store.saveTask();
assert.equal(store.editingTask, draft);
assert.equal(store.isCreating, isCreating);
assert.equal(store.isEditing, !isCreating);
assert.equal(sent.prompt, draft.prompt);
}
for (const field of ['system_prompt', 'prompt']) {
await store.openPromptEditor(field);
assert.equal(store.promptField, field);
store.editingTask[store.promptField] += '\nExpanded edit';
await store.closePromptEditor();
assert.ok(draft[field].endsWith('\nExpanded edit'));
assert.equal(opened.at(-1), closed.at(-1));
}
response = { ok: true, json: async () => ({ ok: true, task: { ...draft } }) };
await store.saveTask();
assert.equal(store.isCreating, false);
assert.equal(store.isEditing, false);
assert.equal(store.editingTask.prompt, '');
assert.equal(store.tasks.find(task => task.uuid === draft.uuid).prompt, draft.prompt);
}
assert.equal(errors.length, 6);
assert.equal(destroyed, 2, 'failed saves must leave the editor and date picker intact');
await store.openPromptEditor('name');
assert.equal(opened.length, 4, 'only prompt fields can be expanded');
store.refreshProjectOptions = async () => {};
store.projectOptions = [{ name: 'unrelated-project' }];
store.deriveActiveProject = () => null;
await store.startCreateTask();
assert.equal(store.selectedProjectSlug, '');
assert.equal(store.editingTask.project, null, 'no active project must not select an unrelated project');
'''
subprocess.run(["node", "--input-type=module"], input=script, text=True, check=True)

View file

@ -10,6 +10,8 @@
- Each direct child folder owns one modal workflow and its store.
- Modal HTML files own body content, titles, scoped styles, and `data-modal-footer` content.
- Modal store files own modal-local state and cleanup.
- `scheduler/` owns task editing: both prompt fields use full-width resizable textareas, with a shared expanded prompt modal bound to the same draft. Closing the expanded view retains draft edits; only a successful task save clears the draft. API error bodies must be treated as failures even with HTTP 200.
- Scheduler actions live in the pinned footer. New tasks offer idle/disabled initial states and inherit only the active chat's project, otherwise defaulting to no project.
## Local Contracts
@ -25,6 +27,7 @@
## Verification
- Smoke-test open, close, Escape, click-outside, scrolling, stacked modals, and pinned footers after modal changes.
- For scheduler editing, run `pytest tests/test_scheduler_editor.py` and check prompt expansion, draft retention, and save/reopen on desktop and mobile.
## Child DOX Index

View file

@ -0,0 +1,37 @@
<html>
<head>
<title>Edit Task Prompt</title>
<script type="module">
import { store } from "/components/modals/scheduler/scheduler-store.js";
</script>
</head>
<body>
<div x-data>
<template x-if="$store.schedulerStore">
<div class="scheduler-expanded-prompt">
<label for="scheduler-expanded-prompt" class="scheduler-form-label"
x-text="$store.schedulerStore.promptField === 'system_prompt' ? 'System Prompt' : 'User Prompt'"></label>
<p id="scheduler-prompt-hint" class="scheduler-form-help">Changes stay in your draft. Save the task when you're done.</p>
<textarea id="scheduler-expanded-prompt" rows="24"
aria-describedby="scheduler-prompt-hint"
x-model="$store.schedulerStore.editingTask[$store.schedulerStore.promptField]"
x-init="$nextTick(() => $el.focus())"></textarea>
</div>
</template>
<div class="modal-footer" data-modal-footer>
<button type="button" class="btn btn-ok" @click="$store.schedulerStore.closePromptEditor()">Done</button>
</div>
</div>
<style>
.scheduler-expanded-prompt textarea {
box-sizing: border-box;
width: 100%;
height: 58dvh;
min-height: 14rem;
resize: vertical;
line-height: 1.6;
font-family: var(--font-family-main);
}
</style>
</body>
</html>

View file

@ -1,5 +1,6 @@
import { createStore } from "/js/AlpineStore.js";
import { fetchApi } from "/js/api.js";
import { openModal, closeModal } from "/js/modals.js";
import {
formatDateTime,
getUserDateTimeParts,
@ -282,7 +283,7 @@ async function callSchedulerEndpoint(endpoint, payload = {}, defaultError) {
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
if (!response.ok || data?.error) {
return { ok: false, error: data?.error || defaultError || "Task request failed" };
}
return { ok: true, data };
@ -479,6 +480,7 @@ const schedulerStoreModel = {
isCreating: false,
isEditing: false,
editingTask: defaultEditingTask(),
promptField: "prompt",
selectedProjectSlug: "",
projectOptions: [],
@ -679,12 +681,8 @@ const schedulerStoreModel = {
} catch (error) {
this.notifyError(`Failed to save task: ${error.message}`);
return;
} finally {
this.destroyFlatpickr("all");
this.resetEditingTask();
this.isCreating = false;
this.isEditing = false;
}
this.cancelEdit();
},
async runTask(taskId) {
@ -756,8 +754,19 @@ const schedulerStoreModel = {
},
// Domain helpers -----------------------------------------------------------
openPromptEditor(field) {
if (!["system_prompt", "prompt"].includes(field)) return;
this.promptField = field;
return openModal("modals/scheduler/scheduler-prompt-editor.html");
},
closePromptEditor() {
return closeModal("modals/scheduler/scheduler-prompt-editor.html");
},
resetEditingTask() {
this.editingTask = defaultEditingTask();
this.promptField = "prompt";
this.selectedProjectSlug = "";
},
@ -1022,10 +1031,7 @@ const schedulerStoreModel = {
this.isEditing = false;
await this.refreshProjectOptions();
let initialProject = this.deriveActiveProject();
if (!initialProject && this.projectOptions.length > 0) {
initialProject = { ...this.projectOptions[0] };
}
const initialProject = this.deriveActiveProject();
this.editingTask = defaultEditingTask({
token: this.generateRandomToken(),

View file

@ -13,15 +13,6 @@
<div class="scheduler-form-header">
<div class="scheduler-form-title">Create New Task</div>
</div>
<div class="buttons-container scheduler-editor-actions scheduler-editor-actions-top">
<div class="buttons-left"></div>
<div class="buttons-right">
<button type="button" class="button confirm"
@click="$store.schedulerStore.saveTask()">Save</button>
<button type="button" class="button cancel"
@click="$store.schedulerStore.cancelEdit()">Cancel</button>
</div>
</div>
<div class="scheduler-form-grid">
<div class="scheduler-form-field">
@ -64,31 +55,13 @@
<div class="scheduler-form-field" x-show="$store.schedulerStore.isCreating">
<div class="label-help-wrapper">
<label class="scheduler-form-label">State</label>
<label class="scheduler-form-label" for="scheduler-initial-state">State</label>
<div class="scheduler-form-help">Select the initial state of the task</div>
</div>
<div>
<div class="scheduler-state-selector">
<span class="scheduler-status-badge scheduler-status-idle"
:class="{'scheduler-status-selected': $store.schedulerStore.editingTask.state === 'idle'}"
@click="$store.schedulerStore.editingTask.state = 'idle'">Idle</span>
<span class="scheduler-status-badge scheduler-status-running"
:class="{'scheduler-status-selected': $store.schedulerStore.editingTask.state === 'running'}"
@click="$store.schedulerStore.editingTask.state = 'running'">Running</span>
<span class="scheduler-status-badge scheduler-status-disabled"
:class="{'scheduler-status-selected': $store.schedulerStore.editingTask.state === 'disabled'}"
@click="$store.schedulerStore.editingTask.state = 'disabled'">Disabled</span>
<span class="scheduler-status-badge scheduler-status-error"
:class="{'scheduler-status-selected': $store.schedulerStore.editingTask.state === 'error'}"
@click="$store.schedulerStore.editingTask.state = 'error'">Error</span>
</div>
<div class="scheduler-state-explanation">
<span x-show="$store.schedulerStore.editingTask.state === 'idle'"><strong>Idle</strong>: Ready to run</span>
<span x-show="$store.schedulerStore.editingTask.state === 'running'"><strong>Running</strong>: Currently executing</span>
<span x-show="$store.schedulerStore.editingTask.state === 'disabled'"><strong>Disabled</strong>: Won't execute automatically</span>
<span x-show="$store.schedulerStore.editingTask.state === 'error'"><strong>Error</strong>: Task encountered an error</span>
</div>
</div>
<select id="scheduler-initial-state" x-model="$store.schedulerStore.editingTask.state">
<option value="idle">Idle (ready to run)</option>
<option value="disabled">Disabled</option>
</select>
</div>
<div class="scheduler-form-field full-width"
@ -186,22 +159,36 @@
</div>
</div>
<div class="scheduler-form-field full-width">
<div class="label-help-wrapper">
<label class="scheduler-form-label">System Prompt</label>
<div class="scheduler-form-help">System-level instructions for the assistant</div>
<div class="scheduler-form-field full-width scheduler-prompt-field">
<div class="scheduler-prompt-heading">
<div class="label-help-wrapper">
<label class="scheduler-form-label">System Prompt</label>
<div class="scheduler-form-help">System-level instructions for the assistant</div>
</div>
<button type="button" class="text-button"
aria-label="Expand system prompt"
@click="$store.schedulerStore.openPromptEditor('system_prompt')">
<x-icon name="open_in_full"></x-icon> Expand
</button>
</div>
<textarea x-model="$store.schedulerStore.editingTask.system_prompt"
placeholder="System instructions for the AI"></textarea>
<textarea x-model="$store.schedulerStore.editingTask.system_prompt" rows="12"
aria-label="System Prompt" placeholder="System instructions for the AI"></textarea>
</div>
<div class="scheduler-form-field full-width">
<div class="label-help-wrapper">
<label class="scheduler-form-label">User Prompt</label>
<div class="scheduler-form-help">The main task prompt that will be executed</div>
<div class="scheduler-form-field full-width scheduler-prompt-field">
<div class="scheduler-prompt-heading">
<div class="label-help-wrapper">
<label class="scheduler-form-label">User Prompt</label>
<div class="scheduler-form-help">The main task prompt that will be executed</div>
</div>
<button type="button" class="text-button"
aria-label="Expand user prompt"
@click="$store.schedulerStore.openPromptEditor('prompt')">
<x-icon name="open_in_full"></x-icon> Expand
</button>
</div>
<textarea x-model="$store.schedulerStore.editingTask.prompt"
placeholder="User message for the AI"></textarea>
<textarea x-model="$store.schedulerStore.editingTask.prompt" rows="12"
aria-label="User Prompt" placeholder="User message for the AI"></textarea>
</div>
<div class="scheduler-form-field full-width">
@ -220,18 +207,6 @@
<div class="scheduler-form-header">
<div class="scheduler-form-title">Edit Task</div>
</div>
<div class="buttons-container scheduler-editor-actions scheduler-editor-actions-top">
<div class="buttons-left">
<button type="button" class="button cancel"
@click="$confirmClick($event, () => $store.schedulerStore.deleteTask($store.schedulerStore.editingTask.uuid))">Delete</button>
</div>
<div class="buttons-right">
<button type="button" class="button confirm"
@click="$store.schedulerStore.saveTask()">Save</button>
<button type="button" class="button cancel"
@click="$store.schedulerStore.cancelEdit()">Cancel</button>
</div>
</div>
<div class="scheduler-form-grid">
<div class="scheduler-form-field">
@ -391,22 +366,36 @@
</div>
</div>
<div class="scheduler-form-field full-width">
<div class="label-help-wrapper">
<label class="scheduler-form-label">System Prompt</label>
<div class="scheduler-form-help">System-level instructions for the assistant</div>
<div class="scheduler-form-field full-width scheduler-prompt-field">
<div class="scheduler-prompt-heading">
<div class="label-help-wrapper">
<label class="scheduler-form-label">System Prompt</label>
<div class="scheduler-form-help">System-level instructions for the assistant</div>
</div>
<button type="button" class="text-button"
aria-label="Expand system prompt"
@click="$store.schedulerStore.openPromptEditor('system_prompt')">
<x-icon name="open_in_full"></x-icon> Expand
</button>
</div>
<textarea x-model="$store.schedulerStore.editingTask.system_prompt"
placeholder="System instructions for the AI"></textarea>
<textarea x-model="$store.schedulerStore.editingTask.system_prompt" rows="12"
aria-label="System Prompt" placeholder="System instructions for the AI"></textarea>
</div>
<div class="scheduler-form-field full-width">
<div class="label-help-wrapper">
<label class="scheduler-form-label">User Prompt</label>
<div class="scheduler-form-help">The main task prompt that will be executed</div>
<div class="scheduler-form-field full-width scheduler-prompt-field">
<div class="scheduler-prompt-heading">
<div class="label-help-wrapper">
<label class="scheduler-form-label">User Prompt</label>
<div class="scheduler-form-help">The main task prompt that will be executed</div>
</div>
<button type="button" class="text-button"
aria-label="Expand user prompt"
@click="$store.schedulerStore.openPromptEditor('prompt')">
<x-icon name="open_in_full"></x-icon> Expand
</button>
</div>
<textarea x-model="$store.schedulerStore.editingTask.prompt"
placeholder="User message for the AI"></textarea>
<textarea x-model="$store.schedulerStore.editingTask.prompt" rows="12"
aria-label="User Prompt" placeholder="User message for the AI"></textarea>
</div>
<div class="scheduler-form-field full-width">
@ -424,7 +413,33 @@
</div>
<style>
/* Form header with Save/Cancel buttons - aligns with Projects pattern */
.scheduler-prompt-field {
grid-template-columns: minmax(0, 1fr);
gap: 0.5rem;
}
.scheduler-prompt-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.scheduler-prompt-heading .text-button {
display: inline-flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.scheduler-prompt-field textarea {
box-sizing: border-box;
width: 100%;
min-height: 16rem;
resize: vertical;
line-height: 1.6;
font-family: var(--font-family-main);
}
.scheduler-form-title {
font-size: 1.2rem;
@ -432,15 +447,6 @@
color: var(--color-primary);
}
.scheduler-form-actions {
display: flex;
gap: 0.5rem;
}
.scheduler-editor-actions {
margin: 0 0 1rem 0;
}
/* Generate token button - extends .btn-action from buttons.css */
.btn-action.generate-token {
padding: 0.4rem 0.75rem;
@ -503,4 +509,3 @@
</style>
</body>
</html>