feat: add scheduled web agents (#1422)

* feat: add scheduled web agents

Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com>

* fix: secure scheduled agent repositories

Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com>

* feat: rebuild scheduled agents as Automations tab

Scrap the inline ScheduledAgentsPanel and replace it with a dedicated
Automations tab: sidebar nav entry, list view with stat cards + empty
state, and a full editor (name, Active toggle, repo, scheduled trigger
picker, agent instructions + model).

* fix: clear collapsed-sidebar button on mobile in Automations

* fix: allow clearing automation repo on update

---------

Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-06-04 19:20:24 -07:00 • committed by GitHub
parent 78ca5af7f4
commit 6895ddcedc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 2288 additions and 327 deletions

View file

@ -0,0 +1,65 @@
"""GitHub repository access checks for dashboard actions."""
from __future__ import annotations
import httpx
from fastapi import HTTPException
from .profiles import get_valid_access_token
from .review_styles import normalize_repo_full_name
def _raise_for_github_repo_status(status_code: int) -> None:
if status_code == 401:
raise HTTPException(401, "github token expired, re-login required")
if status_code == 404:
raise HTTPException(404, "repository not found")
if status_code == 403:
raise HTTPException(403, "no access to this private repository")
if status_code != 200:
raise HTTPException(502, f"github API error ({status_code})")
async def assert_repo_access(full_name: str, token: str) -> str:
full_name = normalize_repo_full_name(full_name)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
owner, name = full_name.split("/", 1)
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.github.com/repos/{owner}/{name}",
headers=headers,
)
_raise_for_github_repo_status(response.status_code)
return full_name
async def require_repo_access_for_user(login: str, full_name: str) -> str:
token = await get_valid_access_token(login)
if not token:
raise HTTPException(401, "github token unavailable, re-login required")
try:
await assert_repo_access(full_name, token)
except HTTPException as exc:
if exc.status_code != 401:
raise
token = await get_valid_access_token(login, force_refresh=True)
if not token:
raise HTTPException(401, "github token expired, re-login required") from exc
await assert_repo_access(full_name, token)
return token
async def repo_config_for_user(login: str, full_name: str | None) -> dict[str, str] | None:
if not isinstance(full_name, str) or not full_name.strip():
return None
try:
normalized = normalize_repo_full_name(full_name)
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
await require_repo_access_for_user(login, normalized)
owner, name = normalized.split("/", 1)
return {"owner": owner, "name": name}

View file

@ -43,6 +43,7 @@ from .profiles import (
upsert_access_token_from_github_response,
upsert_profile,
)
from .repo_access import require_repo_access_for_user
from .review_style_jobs import (
cancel_review_style_analysis,
start_bootstrap_analysis,
@ -58,6 +59,14 @@ from .review_styles import (
normalize_repo_full_name,
set_custom_prompt,
)
from .schedules import (
ScheduleCreateBody,
ScheduleUpdateBody,
create_agent_schedule,
delete_agent_schedule,
list_agent_schedules,
update_agent_schedule,
)
from .slack_oauth import (
SLACK_STATE_COOKIE_NAME,
build_authorize_url,
@ -592,59 +601,6 @@ async def list_repos(
}
def _raise_for_github_repo_status(status_code: int) -> None:
if status_code == 401:
raise HTTPException(401, "github token expired, re-login required")
if status_code == 404:
raise HTTPException(404, "repository not found")
if status_code == 403:
raise HTTPException(403, "no access to this private repository")
if status_code != 200:
raise HTTPException(502, f"github API error ({status_code})")
async def _assert_repo_available_for_style_analysis(full_name: str, token: str) -> None:
"""Ensure the repo exists and is readable for style learning.
Public repositories are allowed without the GitHub App installed on them.
Private repositories require the authenticated user to have read access.
"""
full_name = normalize_repo_full_name(full_name)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
owner, name = full_name.split("/", 1)
async with httpx.AsyncClient() as client:
r = await client.get(
f"https://api.github.com/repos/{owner}/{name}",
headers=headers,
)
_raise_for_github_repo_status(r.status_code)
body = r.json()
if body.get("private") is not True:
return
# Private repo: 200 from GitHub implies the user's token can read it.
async def _require_repo_access_for_user(login: str, full_name: str) -> str:
"""Verify the user can read ``full_name`` on GitHub; return a valid access token."""
token = await get_valid_access_token(login)
if not token:
raise HTTPException(401, "github token unavailable, re-login required")
try:
await _assert_repo_available_for_style_analysis(full_name, token)
except HTTPException as exc:
if exc.status_code != 401:
raise
token = await get_valid_access_token(login, force_refresh=True)
if not token:
raise HTTPException(401, "github token expired, re-login required") from exc
await _assert_repo_available_for_style_analysis(full_name, token)
return token
@router.get("/review-styles")
async def api_list_review_styles(
session: dict[str, Any] = _SESSION_DEP,
@ -665,7 +621,7 @@ async def api_create_review_style(
body: ReviewStyleCreate,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
await _require_repo_access_for_user(session["sub"], body.full_name)
await require_repo_access_for_user(session["sub"], body.full_name)
return await create_review_style(body.full_name, session["sub"])
@ -693,7 +649,7 @@ async def api_update_review_style_prompt(
record = await get_review_style(full_name)
if not record:
raise HTTPException(404, "review style not found")
await _require_repo_access_for_user(session["sub"], full_name)
await require_repo_access_for_user(session["sub"], full_name)
return await set_custom_prompt(full_name, body.custom_prompt)
@ -703,7 +659,7 @@ async def api_analyze_review_style(
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
full_name = normalize_repo_full_name(full_name)
token = await _require_repo_access_for_user(session["sub"], full_name)
token = await require_repo_access_for_user(session["sub"], full_name)
record = await get_review_style(full_name)
if not record:
record = await create_review_style(full_name, session["sub"])
@ -762,6 +718,41 @@ async def api_agent_usage_leaderboard(
)
@router.get("/schedules")
async def api_list_schedules(
session: dict[str, Any] = _SESSION_DEP,
) -> list[dict[str, Any]]:
return await list_agent_schedules(session["sub"], email=session.get("email"))
@router.post("/schedules")
async def api_create_schedule(
body: ScheduleCreateBody,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
return await create_agent_schedule(session["sub"], body, email=session.get("email"))
@router.patch("/schedules/{schedule_id}")
async def api_update_schedule(
schedule_id: str,
body: ScheduleUpdateBody,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
return await update_agent_schedule(
schedule_id, session["sub"], body, email=session.get("email")
)
@router.delete("/schedules/{schedule_id}")
async def api_delete_schedule(
schedule_id: str,
session: dict[str, Any] = _SESSION_DEP,
) -> Response:
await delete_agent_schedule(schedule_id, session["sub"], email=session.get("email"))
return Response(status_code=204)
@router.get("/threads")
async def api_list_threads(
session: dict[str, Any] = _SESSION_DEP,

View file

@ -0,0 +1,467 @@
"""Dashboard-managed recurring agent schedules."""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from typing import Any
from fastapi import HTTPException
from pydantic import BaseModel, Field, field_validator
from ..utils.thread_ops import langgraph_client
from .options import SUPPORTED_MODEL_IDS, model_supports_effort
from .profiles import get_profile, get_valid_access_token
from .repo_access import repo_config_for_user, require_repo_access_for_user
from .thread_api import _agent_version_metadata, _now_ms, _resolve_run_email
logger = logging.getLogger(__name__)
SCHEDULES_NAMESPACE: list[str] = ["agent_schedules"]
_AGENT_ASSISTANT_ID = "agent"
_SCHEDULER_ASSISTANT_ID = "scheduler"
_CRON_FIELD_RANGES = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 7))
class ScheduleCreateBody(BaseModel):
prompt: str = Field(min_length=1, max_length=20_000)
schedule: str = Field(min_length=1, max_length=120)
name: str | None = Field(default=None, max_length=120)
repo: str | None = None
model_id: str | None = None
effort: str | None = None
@field_validator("schedule")
@classmethod
def _valid_schedule(cls, value: str) -> str:
return normalize_cron_schedule(value)
class ScheduleUpdateBody(BaseModel):
prompt: str | None = Field(default=None, min_length=1, max_length=20_000)
schedule: str | None = Field(default=None, min_length=1, max_length=120)
name: str | None = Field(default=None, max_length=120)
repo: str | None = None
model_id: str | None = None
effort: str | None = None
enabled: bool | None = None
@field_validator("schedule")
@classmethod
def _valid_schedule(cls, value: str | None) -> str | None:
return normalize_cron_schedule(value) if value is not None else None
def _client():
return langgraph_client()
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def _normalize_model_choice(
model_id: str | None, effort: str | None
) -> tuple[str | None, str | None]:
if not isinstance(model_id, str) or model_id not in SUPPORTED_MODEL_IDS:
return None, None
if not isinstance(effort, str) or not model_supports_effort(model_id, effort):
return None, None
return model_id, effort
def _validate_cron_value(value: str, low: int, high: int) -> None:
try:
n = int(value)
except ValueError as exc:
raise ValueError("cron fields must use numbers, *, ranges, steps, or lists") from exc
if n < low or n > high:
raise ValueError(f"cron value {n} outside allowed range {low}-{high}")
def _validate_cron_field(field: str, low: int, high: int) -> None:
for segment in field.split(","):
if not segment:
raise ValueError("cron fields cannot contain empty list segments")
base, sep, step = segment.partition("/")
if sep:
_validate_cron_value(step, 1, high)
if base == "*":
continue
start, dash, end = base.partition("-")
if dash:
_validate_cron_value(start, low, high)
_validate_cron_value(end, low, high)
if int(start) > int(end):
raise ValueError("cron ranges must be ascending")
else:
_validate_cron_value(base, low, high)
def normalize_cron_schedule(raw: str) -> str:
value = " ".join(raw.strip().split())
parts = value.split(" ")
if len(parts) != 5:
raise ValueError("schedule must be a five-field cron expression")
for part, (low, high) in zip(parts, _CRON_FIELD_RANGES, strict=True):
_validate_cron_field(part, low, high)
return value
def _derive_name(prompt: str) -> str:
return prompt.strip().splitlines()[0][:80] or "Scheduled agent"
def _repo_full_name(repo: dict[str, str] | None) -> str | None:
if not repo:
return None
owner = repo.get("owner")
name = repo.get("name")
return f"{owner}/{name}" if owner and name else None
def _schedule_summary(record: dict[str, Any]) -> dict[str, Any]:
repo = record.get("repo") if isinstance(record.get("repo"), dict) else None
return {
"id": record.get("id"),
"name": record.get("name"),
"prompt": record.get("prompt"),
"schedule": record.get("schedule"),
"repo": _repo_full_name(repo),
"model": record.get("model"),
"effort": record.get("effort"),
"enabled": bool(record.get("enabled")),
"cronId": record.get("cron_id"),
"lastThreadId": record.get("last_thread_id"),
"lastRunId": record.get("last_run_id"),
"lastTriggeredAt": record.get("last_triggered_at"),
"lastError": record.get("last_error"),
"lastErrorAt": record.get("last_error_at"),
"createdAt": record.get("created_at"),
"updatedAt": record.get("updated_at"),
}
async def _get_value(schedule_id: str) -> dict[str, Any] | None:
item = await _client().store.get_item(SCHEDULES_NAMESPACE, schedule_id)
if item is None:
return None
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
return value if isinstance(value, dict) else None
async def _put_value(record: dict[str, Any]) -> dict[str, Any]:
record = {**record, "updated_at": _now_iso()}
await _client().store.put_item(SCHEDULES_NAMESPACE, record["id"], record)
return record
async def get_agent_schedule(schedule_id: str) -> dict[str, Any] | None:
return await _get_value(schedule_id)
def _user_owns_schedule(record: dict[str, Any], login: str, email: str | None = None) -> bool:
if record.get("created_by") == login:
return True
record_email = record.get("user_email")
return bool(email and isinstance(record_email, str) and record_email == email.strip().lower())
def _assert_schedule_owner(
record: dict[str, Any] | None, login: str, email: str | None = None
) -> None:
if not record or not _user_owns_schedule(record, login, email):
raise HTTPException(404, "schedule not found")
async def _search_schedule_values(filter: dict[str, Any]) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
limit = 100
offset = 0
while True:
result = await _client().store.search_items(
SCHEDULES_NAMESPACE,
filter=filter,
limit=limit,
offset=offset,
)
items = result.get("items") if isinstance(result, dict) else getattr(result, "items", [])
if not items:
break
for item in items:
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
if isinstance(value, dict):
records.append(value)
if len(items) < limit:
break
offset += len(items)
return records
async def list_agent_schedules(login: str, *, email: str | None = None) -> list[dict[str, Any]]:
searches: list[dict[str, Any]] = [{"created_by": login}]
if email and email.strip():
searches.append({"user_email": email.strip().lower()})
seen: dict[str, dict[str, Any]] = {}
for filter in searches:
for record in await _search_schedule_values(filter):
schedule_id = record.get("id")
if isinstance(schedule_id, str) and _user_owns_schedule(record, login, email):
seen[schedule_id] = record
records = list(seen.values())
records.sort(key=lambda item: item.get("updated_at", ""), reverse=True)
return [_schedule_summary(record) for record in records]
async def _ensure_dashboard_github_token(login: str) -> None:
token = await get_valid_access_token(login)
if not token:
raise HTTPException(401, "github token unavailable, re-login required")
def _build_cron_config(record: dict[str, Any]) -> dict[str, Any]:
return {
"configurable": {
"schedule_id": record["id"],
},
"metadata": _agent_version_metadata(),
}
async def _create_cron(record: dict[str, Any]) -> str:
cron = await _client().crons.create(
_SCHEDULER_ASSISTANT_ID,
schedule=record["schedule"],
input={"schedule_id": record["id"]},
config=_build_cron_config(record),
metadata={
"kind": "agent_schedule",
"schedule_id": record["id"],
"github_login": record.get("created_by"),
},
)
cron_id = cron.get("cron_id") if isinstance(cron, dict) else getattr(cron, "cron_id", None)
if not isinstance(cron_id, str) or not cron_id:
raise RuntimeError("cron creation did not return a cron_id")
return cron_id
async def _delete_cron(cron_id: str | None) -> None:
if not cron_id:
return
try:
await _client().crons.delete(cron_id)
except Exception:
logger.debug("Could not delete schedule cron %s", cron_id, exc_info=True)
async def create_agent_schedule(
login: str, body: ScheduleCreateBody, *, email: str | None = None
) -> dict[str, Any]:
await _ensure_dashboard_github_token(login)
profile = await get_profile(login) or {}
chosen_model, chosen_effort = _normalize_model_choice(body.model_id, body.effort)
repo = await repo_config_for_user(login, body.repo)
schedule_id = str(uuid.uuid4())
now = _now_iso()
record: dict[str, Any] = {
"id": schedule_id,
"name": (body.name or _derive_name(body.prompt)).strip(),
"prompt": body.prompt.strip(),
"schedule": body.schedule,
"repo": repo,
"model": chosen_model or profile.get("default_model") or "Default",
"effort": chosen_effort or profile.get("reasoning_effort"),
"base_branch": profile.get("base_branch") or "main",
"branch_prefix": profile.get("branch_prefix"),
"enabled": True,
"cron_id": None,
"last_thread_id": None,
"last_run_id": None,
"last_triggered_at": None,
"last_error": None,
"last_error_at": None,
"created_by": login,
"user_email": (await _resolve_run_email(login, profile) or email or "").strip().lower(),
"created_at": now,
"updated_at": now,
}
await _put_value(record)
try:
cron_id = await _create_cron(record)
except Exception as exc:
await _client().store.delete_item(SCHEDULES_NAMESPACE, schedule_id)
logger.exception("Failed to create schedule cron for %s", schedule_id)
raise HTTPException(502, "failed to create schedule cron") from exc
record = await _put_value({**record, "cron_id": cron_id})
return _schedule_summary(record)
async def update_agent_schedule(
schedule_id: str, login: str, body: ScheduleUpdateBody, *, email: str | None = None
) -> dict[str, Any]:
existing = await get_agent_schedule(schedule_id)
_assert_schedule_owner(existing, login, email)
assert existing is not None
patch: dict[str, Any] = {}
if body.prompt is not None:
patch["prompt"] = body.prompt.strip()
if body.schedule is not None:
patch["schedule"] = body.schedule
if body.name is not None:
patch["name"] = body.name.strip() or _derive_name(patch.get("prompt", existing["prompt"]))
if body.repo is not None:
patch["repo"] = await repo_config_for_user(login, body.repo)
if body.model_id is not None or body.effort is not None:
model, effort = _normalize_model_choice(body.model_id, body.effort)
if model and effort:
patch["model"] = model
patch["effort"] = effort
if body.enabled is not None:
patch["enabled"] = body.enabled
updated = {**existing, **patch}
schedule_changed = updated.get("schedule") != existing.get("schedule")
enabled_changed = updated.get("enabled") != existing.get("enabled")
needs_new_cron = bool(updated.get("enabled")) and (schedule_changed or enabled_changed)
if needs_new_cron:
try:
new_cron_id = await _create_cron(updated)
except Exception as exc:
logger.exception("Failed to recreate schedule cron for %s", schedule_id)
raise HTTPException(502, "failed to create schedule cron") from exc
await _delete_cron(existing.get("cron_id"))
updated["cron_id"] = new_cron_id
elif updated.get("enabled") is False and existing.get("cron_id"):
await _delete_cron(existing.get("cron_id"))
updated["cron_id"] = None
updated = await _put_value(updated)
return _schedule_summary(updated)
async def delete_agent_schedule(schedule_id: str, login: str, *, email: str | None = None) -> None:
existing = await get_agent_schedule(schedule_id)
_assert_schedule_owner(existing, login, email)
assert existing is not None
await _delete_cron(existing.get("cron_id"))
await _client().store.delete_item(SCHEDULES_NAMESPACE, schedule_id)
def _agent_run_metadata(record: dict[str, Any], thread_id: str) -> dict[str, Any]:
repo = record.get("repo") if isinstance(record.get("repo"), dict) else None
now_ms = _now_ms()
metadata: dict[str, Any] = {
"source": "schedule",
"schedule_id": record["id"],
"schedule_name": record.get("name"),
"github_login": record.get("created_by"),
"triggering_user_email": record.get("user_email"),
"title": f"Scheduled: {record.get('name') or 'Agent'}",
"base_branch": record.get("base_branch") or "main",
"branch_prefix": record.get("branch_prefix"),
"model": record.get("model") or "Default",
"effort": record.get("effort"),
"created_at_ms": now_ms,
"updated_at_ms": now_ms,
}
if repo and repo.get("owner") and repo.get("name"):
metadata["repo_owner"] = repo["owner"]
metadata["repo_name"] = repo["name"]
return metadata
def _agent_run_config(record: dict[str, Any], thread_id: str) -> dict[str, Any]:
configurable: dict[str, Any] = {
"thread_id": thread_id,
"source": "schedule",
"github_login": record.get("created_by"),
"user_email": record.get("user_email"),
"schedule_id": record["id"],
}
repo = record.get("repo") if isinstance(record.get("repo"), dict) else None
if repo and repo.get("owner") and repo.get("name"):
configurable["repo"] = repo
model, effort = _normalize_model_choice(record.get("model"), record.get("effort"))
if model and effort:
configurable["agent_model_id"] = model
configurable["agent_effort"] = effort
return {"configurable": configurable, "metadata": _agent_version_metadata()}
async def launch_scheduled_agent_run(schedule_id: str) -> dict[str, Any]:
record = await get_agent_schedule(schedule_id)
if not record:
return {"status": "missing", "schedule_id": schedule_id}
if not record.get("enabled"):
return {"status": "disabled", "schedule_id": schedule_id}
repo = record.get("repo") if isinstance(record.get("repo"), dict) else None
full_name = _repo_full_name(repo)
login = record.get("created_by")
if full_name:
if not (isinstance(login, str) and login):
await _put_value(
{
**record,
"last_error": "schedule owner unavailable",
"last_error_at": _now_iso(),
}
)
return {
"status": "unauthorized",
"schedule_id": schedule_id,
"error": "schedule owner unavailable",
}
try:
await require_repo_access_for_user(login, full_name)
except HTTPException as exc:
await _put_value(
{
**record,
"last_error": str(exc.detail),
"last_error_at": _now_iso(),
}
)
return {"status": "unauthorized", "schedule_id": schedule_id, "error": exc.detail}
thread_id = str(uuid.uuid4())
metadata = _agent_run_metadata(record, thread_id)
client = _client()
await client.threads.create(thread_id=thread_id, metadata=metadata, if_exists="do_nothing")
await client.threads.update(thread_id=thread_id, metadata=metadata)
run = await client.runs.create(
thread_id,
_AGENT_ASSISTANT_ID,
input={"messages": [{"role": "user", "content": record["prompt"]}]},
config=_agent_run_config(record, thread_id),
if_not_exists="create",
stream_mode=["values", "updates", "messages-tuple"],
stream_resumable=True,
)
run_id = run.get("run_id") if isinstance(run, dict) else getattr(run, "run_id", None)
now_ms = _now_ms()
await client.threads.update(
thread_id=thread_id,
metadata={"latest_run_id": run_id, "latest_run_status": "pending", "updated_at_ms": now_ms},
)
await _put_value(
{
**record,
"last_thread_id": thread_id,
"last_run_id": run_id,
"last_triggered_at": _now_iso(),
"last_error": None,
"last_error_at": None,
}
)
return {
"status": "started",
"schedule_id": schedule_id,
"thread_id": thread_id,
"run_id": run_id,
}

View file

@ -26,7 +26,7 @@ _ASSISTANT_ID = "agent"
_DASHBOARD_SOURCE = "dashboard"
_DASHBOARD_STREAM_MODES: tuple[str, ...] = ("values", "updates", "messages-tuple")
# Sources whose threads should surface in the Agents UI (besides "dashboard").
_SURFACED_SOURCES: tuple[str, ...] = ("dashboard", "github", "slack", "linear")
_SURFACED_SOURCES: tuple[str, ...] = ("dashboard", "github", "slack", "linear", "schedule")
def _agent_version_metadata() -> dict[str, str]:

35
agent/scheduler.py Normal file
View file

@ -0,0 +1,35 @@
"""LangGraph entrypoint that fans cron ticks into fresh agent threads."""
from __future__ import annotations
import logging
from typing import Any, TypedDict
from langgraph.graph import END, START, StateGraph
from langgraph.graph.state import RunnableConfig
from .dashboard.schedules import launch_scheduled_agent_run
logger = logging.getLogger(__name__)
class SchedulerState(TypedDict, total=False):
schedule_id: str
result: dict[str, Any]
async def _launch(state: SchedulerState, config: RunnableConfig) -> dict[str, Any]:
configurable = config.get("configurable") or {}
schedule_id = state.get("schedule_id") or configurable.get("schedule_id")
if not isinstance(schedule_id, str) or not schedule_id:
logger.warning("Scheduled agent tick missing schedule_id")
return {"result": {"status": "missing_schedule_id"}}
return {"result": await launch_scheduled_agent_run(schedule_id)}
def get_scheduler(config: RunnableConfig | None = None):
builder = StateGraph(SchedulerState)
builder.add_node("launch", _launch)
builder.add_edge(START, "launch")
builder.add_edge("launch", END)
return builder.compile().with_config(config or {})

View file

@ -421,7 +421,11 @@ async def resolve_github_token(config: RunnableConfig, thread_id: str) -> tuple[
# Per-user OAuth from the dashboard store wins even in bot-token-only mode,
# for sources that carry a mapped GitHub login (Slack, dashboard). This is
# what lets the agent open PRs as the triggering user.
if source in ("slack", "dashboard") and isinstance(github_login, str) and github_login.strip():
if (
source in ("slack", "dashboard", "schedule")
and isinstance(github_login, str)
and github_login.strip()
):
try:
user_token = await _resolve_dashboard_user_token(thread_id, github_login)
except ValueError as exc:

View file

@ -4,7 +4,8 @@
"graphs": {
"agent": "agent.server:get_agent",
"reviewer": "agent.reviewer:get_reviewer_agent",
"analyzer": "agent.analyzer:get_analyzer"
"analyzer": "agent.analyzer:get_analyzer",
"scheduler": "agent.scheduler:get_scheduler"
},
"dependencies": ["."],
"http": {

View file

@ -0,0 +1,376 @@
from __future__ import annotations
from typing import Any
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from agent.dashboard import schedules
from agent.dashboard.schedules import ScheduleCreateBody, ScheduleUpdateBody
class _FakeStore:
def __init__(self) -> None:
self.items: dict[tuple[tuple[str, ...], str], dict[str, Any]] = {}
self.deleted: list[tuple[tuple[str, ...], str]] = []
async def get_item(self, namespace: list[str], key: str) -> dict[str, Any] | None:
value = self.items.get((tuple(namespace), key))
return {"value": value} if value is not None else None
async def put_item(self, namespace: list[str], key: str, value: dict[str, Any]) -> None:
self.items[(tuple(namespace), key)] = value
async def delete_item(self, namespace: list[str], key: str) -> None:
self.deleted.append((tuple(namespace), key))
self.items.pop((tuple(namespace), key), None)
async def search_items(
self,
namespace: list[str],
filter: dict[str, Any] | None = None,
limit: int = 1000,
offset: int = 0,
) -> dict[str, Any]:
values = [
value
for (stored_namespace, _), value in self.items.items()
if stored_namespace == tuple(namespace)
]
if filter:
values = [
value
for value in values
if all(value.get(key) == expected for key, expected in filter.items())
]
return {"items": [{"value": value} for value in values[offset : offset + limit]]}
class _FakeCrons:
def __init__(self) -> None:
self.created: list[dict[str, Any]] = []
self.deleted: list[str] = []
async def create(self, assistant_id: str, **kwargs: Any) -> dict[str, Any]:
self.created.append({"assistant_id": assistant_id, **kwargs})
return {"cron_id": f"cron_{len(self.created)}"}
async def delete(self, cron_id: str) -> None:
self.deleted.append(cron_id)
class _FakeThreads:
def __init__(self) -> None:
self.created: list[dict[str, Any]] = []
self.updated: list[dict[str, Any]] = []
async def create(self, **kwargs: Any) -> None:
self.created.append(kwargs)
async def update(self, **kwargs: Any) -> None:
self.updated.append(kwargs)
class _FakeRuns:
def __init__(self) -> None:
self.created: list[dict[str, Any]] = []
async def create(self, thread_id: str, assistant_id: str, **kwargs: Any) -> dict[str, Any]:
self.created.append({"thread_id": thread_id, "assistant_id": assistant_id, **kwargs})
return {"run_id": "run_123"}
class _FakeClient:
def __init__(self) -> None:
self.store = _FakeStore()
self.crons = _FakeCrons()
self.threads = _FakeThreads()
self.runs = _FakeRuns()
@pytest.fixture
def fake_client(monkeypatch) -> _FakeClient: # noqa: ANN001
client = _FakeClient()
monkeypatch.setattr(schedules, "_client", lambda: client)
return client
@pytest.fixture
def auth(monkeypatch) -> None: # noqa: ANN001
async def fake_get_valid_access_token(login: str) -> str:
return "gho_token"
async def fake_get_profile(login: str) -> dict[str, Any]:
return {"base_branch": "main", "branch_prefix": "open-swe"}
async def fake_resolve_run_email(login: str, profile: dict[str, Any]) -> str:
return "alice@example.com"
async def fake_repo_config_for_user(login: str, full_name: str | None) -> dict[str, str] | None:
if not full_name:
return None
owner, name = full_name.split("/", 1)
return {"owner": owner, "name": name}
async def fake_require_repo_access_for_user(login: str, full_name: str) -> str:
return "gho_token"
monkeypatch.setattr(schedules, "get_valid_access_token", fake_get_valid_access_token)
monkeypatch.setattr(schedules, "get_profile", fake_get_profile)
monkeypatch.setattr(schedules, "_resolve_run_email", fake_resolve_run_email)
monkeypatch.setattr(schedules, "repo_config_for_user", fake_repo_config_for_user)
monkeypatch.setattr(
schedules, "require_repo_access_for_user", fake_require_repo_access_for_user
)
def test_cron_validation_rejects_non_five_field_expression() -> None:
with pytest.raises(ValidationError):
ScheduleCreateBody(prompt="hello", schedule="0 9 * *")
def test_cron_validation_accepts_steps_ranges_and_lists() -> None:
body = ScheduleCreateBody(prompt="hello", schedule="*/15 9-17 * * 1,3,5")
assert body.schedule == "*/15 9-17 * * 1,3,5"
async def test_create_agent_schedule_registers_scheduler_cron(fake_client, auth) -> None: # noqa: ANN001, ARG001
body = ScheduleCreateBody(
name="Daily report",
prompt="Summarize merged PRs",
schedule="0 9 * * 1-5",
repo="langchain-ai/open-swe",
)
result = await schedules.create_agent_schedule("alice", body, email="alice@example.com")
assert result["name"] == "Daily report"
assert result["enabled"] is True
assert result["cronId"] == "cron_1"
created = fake_client.crons.created[0]
assert created["assistant_id"] == "scheduler"
assert created["schedule"] == "0 9 * * 1-5"
assert created["input"]["schedule_id"] == result["id"]
assert created["config"]["configurable"]["schedule_id"] == result["id"]
assert created["metadata"]["kind"] == "agent_schedule"
async def test_create_agent_schedule_requires_dashboard_token(fake_client, monkeypatch) -> None: # noqa: ANN001, ARG001
async def no_token(login: str) -> None:
return None
monkeypatch.setattr(schedules, "get_valid_access_token", no_token)
with pytest.raises(HTTPException) as exc:
await schedules.create_agent_schedule(
"alice", ScheduleCreateBody(prompt="hello", schedule="0 9 * * 1")
)
assert exc.value.status_code == 401
assert fake_client.crons.created == []
async def test_create_agent_schedule_requires_repo_access(fake_client, auth, monkeypatch) -> None: # noqa: ANN001, ARG001
async def deny_repo(login: str, full_name: str | None) -> dict[str, str] | None:
raise HTTPException(403, "no access to this private repository")
monkeypatch.setattr(schedules, "repo_config_for_user", deny_repo)
with pytest.raises(HTTPException) as exc:
await schedules.create_agent_schedule(
"alice",
ScheduleCreateBody(
prompt="hello",
schedule="0 9 * * 1",
repo="victim/private",
),
)
assert exc.value.status_code == 403
assert fake_client.crons.created == []
async def test_list_agent_schedules_uses_owner_filters_and_paginates(fake_client) -> None: # noqa: ANN001
for i in range(125):
await fake_client.store.put_item(
schedules.SCHEDULES_NAMESPACE,
f"alice_{i}",
{
"id": f"alice_{i}",
"name": f"Alice {i}",
"prompt": "Run daily",
"schedule": "0 9 * * *",
"repo": None,
"model": "Default",
"enabled": True,
"created_by": "alice",
"user_email": "alice@example.com",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": f"2026-01-01T00:{i % 60:02d}:00+00:00",
},
)
await fake_client.store.put_item(
schedules.SCHEDULES_NAMESPACE,
"bob_1",
{
"id": "bob_1",
"name": "Bob",
"prompt": "Run daily",
"schedule": "0 9 * * *",
"repo": None,
"model": "Default",
"enabled": True,
"created_by": "bob",
"user_email": "bob@example.com",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
},
)
result = await schedules.list_agent_schedules("alice", email="alice@example.com")
assert len(result) == 125
assert {item["id"] for item in result} == {f"alice_{i}" for i in range(125)}
async def test_update_agent_schedule_rechecks_repo_access(fake_client, auth, monkeypatch) -> None: # noqa: ANN001, ARG001
record = {
"id": "sched_1",
"name": "Daily",
"prompt": "Run daily",
"schedule": "0 9 * * *",
"repo": None,
"model": "Default",
"effort": None,
"enabled": True,
"cron_id": "cron_old",
"created_by": "alice",
"user_email": "alice@example.com",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
}
await fake_client.store.put_item(schedules.SCHEDULES_NAMESPACE, "sched_1", record)
async def repo_config(login: str, full_name: str | None) -> dict[str, str] | None:
assert full_name == "langchain-ai/open-swe"
return {"owner": "langchain-ai", "name": "open-swe"}
monkeypatch.setattr(schedules, "repo_config_for_user", repo_config)
result = await schedules.update_agent_schedule(
"sched_1",
"alice",
ScheduleUpdateBody(repo="langchain-ai/open-swe"),
email="alice@example.com",
)
assert result["repo"] == "langchain-ai/open-swe"
async def test_update_agent_schedule_pause_deletes_cron(fake_client) -> None: # noqa: ANN001
record = {
"id": "sched_1",
"name": "Daily",
"prompt": "Run daily",
"schedule": "0 9 * * *",
"repo": None,
"model": "Default",
"effort": None,
"enabled": True,
"cron_id": "cron_old",
"created_by": "alice",
"user_email": "alice@example.com",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
}
await fake_client.store.put_item(schedules.SCHEDULES_NAMESPACE, "sched_1", record)
result = await schedules.update_agent_schedule(
"sched_1", "alice", ScheduleUpdateBody(enabled=False), email="alice@example.com"
)
assert result["enabled"] is False
assert result["cronId"] is None
assert fake_client.crons.deleted == ["cron_old"]
async def test_launch_scheduled_agent_run_skips_when_repo_access_revoked(
fake_client, auth, monkeypatch
) -> None: # noqa: ANN001, ARG001
record = {
"id": "sched_1",
"name": "Weekly dependencies",
"prompt": "Check dependencies and open a PR if needed",
"schedule": "0 9 * * 1",
"repo": {"owner": "langchain-ai", "name": "open-swe"},
"model": "Default",
"effort": None,
"base_branch": "main",
"branch_prefix": "open-swe",
"enabled": True,
"cron_id": "cron_1",
"created_by": "alice",
"user_email": "alice@example.com",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
}
await fake_client.store.put_item(schedules.SCHEDULES_NAMESPACE, "sched_1", record)
async def deny_access(login: str, full_name: str) -> str:
raise HTTPException(403, "no access to this private repository")
monkeypatch.setattr(schedules, "require_repo_access_for_user", deny_access)
result = await schedules.launch_scheduled_agent_run("sched_1")
assert result == {
"status": "unauthorized",
"schedule_id": "sched_1",
"error": "no access to this private repository",
}
assert fake_client.runs.created == []
stored = fake_client.store.items[(tuple(schedules.SCHEDULES_NAMESPACE), "sched_1")]
assert stored["last_error"] == "no access to this private repository"
async def test_launch_scheduled_agent_run_starts_fresh_agent_thread(fake_client, auth) -> None: # noqa: ANN001, ARG001
record = {
"id": "sched_1",
"name": "Weekly dependencies",
"prompt": "Check dependencies and open a PR if needed",
"schedule": "0 9 * * 1",
"repo": {"owner": "langchain-ai", "name": "open-swe"},
"model": "Default",
"effort": None,
"base_branch": "main",
"branch_prefix": "open-swe",
"enabled": True,
"cron_id": "cron_1",
"created_by": "alice",
"user_email": "alice@example.com",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
}
await fake_client.store.put_item(schedules.SCHEDULES_NAMESPACE, "sched_1", record)
result = await schedules.launch_scheduled_agent_run("sched_1")
assert result["status"] == "started"
thread_id = result["thread_id"]
assert fake_client.threads.created[0]["thread_id"] == thread_id
metadata = fake_client.threads.created[0]["metadata"]
assert metadata["source"] == "schedule"
assert metadata["repo_owner"] == "langchain-ai"
assert metadata["repo_name"] == "open-swe"
run = fake_client.runs.created[0]
assert run["thread_id"] == thread_id
assert run["assistant_id"] == "agent"
assert run["input"]["messages"][0]["content"] == record["prompt"]
assert run["config"]["configurable"]["source"] == "schedule"
assert run["config"]["configurable"]["repo"] == record["repo"]
stored = fake_client.store.items[(tuple(schedules.SCHEDULES_NAMESPACE), "sched_1")]
assert stored["last_thread_id"] == thread_id
assert stored["last_run_id"] == "run_123"

View file

@ -1,36 +1,38 @@
import { Link } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router"
import {
CalendarBlankIcon,
ChatCircleIcon,
CheckCircleIcon,
GitBranchIcon,
GitPullRequestIcon,
} from "@phosphor-icons/react";
import { IoLogoGithub, IoLogoSlack } from "react-icons/io5";
import { SiLinear } from "react-icons/si";
import type { ComponentType, SVGProps } from "react";
} from "@phosphor-icons/react"
import { IoLogoGithub, IoLogoSlack } from "react-icons/io5"
import { SiLinear } from "react-icons/si"
import type { ComponentType, SVGProps } from "react"
import type { AgentSource, AgentThread } from "@/lib/agents/types";
import { formatRelativeTime } from "@/lib/agents/api";
import { cn } from "@/lib/utils";
import type { AgentSource, AgentThread } from "@/lib/agents/types"
import { formatRelativeTime } from "@/lib/agents/api"
import { cn } from "@/lib/utils"
type SourceIcon = ComponentType<SVGProps<SVGSVGElement>>;
type SourceIcon = ComponentType<SVGProps<SVGSVGElement>>
const SOURCE_META: Record<AgentSource, { icon: SourceIcon; label: string }> = {
dashboard: { icon: ChatCircleIcon, label: "Dashboard" },
github: { icon: IoLogoGithub, label: "GitHub" },
slack: { icon: IoLogoSlack, label: "Slack" },
linear: { icon: SiLinear, label: "Linear" },
};
schedule: { icon: CalendarBlankIcon, label: "Schedule" },
}
interface AgentRunCardProps {
thread: AgentThread;
thread: AgentThread
}
export function AgentRunCard({ thread }: AgentRunCardProps) {
const stats = thread.diffStats;
const hasPr = Boolean(thread.pr);
const source = thread.source ? SOURCE_META[thread.source] : null;
const SourceIcon = source?.icon;
const stats = thread.diffStats
const hasPr = Boolean(thread.pr)
const source = thread.source ? SOURCE_META[thread.source] : null
const SourceIcon = source?.icon
return (
<Link
@ -45,8 +47,12 @@ export function AgentRunCard({ thread }: AgentRunCardProps) {
{stats.files} {stats.files === 1 ? "file" : "files"}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-xs font-medium">
<span className="text-[var(--ui-success)]">+{stats.additions}</span>
<span className="text-[var(--ui-danger)]">-{stats.deletions}</span>
<span className="text-[var(--ui-success)]">
+{stats.additions}
</span>
<span className="text-[var(--ui-danger)]">
-{stats.deletions}
</span>
</div>
</>
) : (
@ -68,11 +74,16 @@ export function AgentRunCard({ thread }: AgentRunCardProps) {
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-[var(--ui-text)]">{thread.title}</div>
<div className="truncate text-sm font-medium text-[var(--ui-text)]">
{thread.title}
</div>
<div className="mt-1 flex min-w-0 items-center gap-2 text-xs text-[var(--ui-text-dim)]">
{source && SourceIcon && (
<>
<span className="flex shrink-0 items-center gap-1" title={source.label}>
<span
className="flex shrink-0 items-center gap-1"
title={source.label}
>
<SourceIcon className="size-3.5" aria-label={source.label} />
{source.label}
</span>
@ -91,7 +102,9 @@ export function AgentRunCard({ thread }: AgentRunCardProps) {
</>
)}
<span className="shrink-0">·</span>
<span className="shrink-0 whitespace-nowrap">{formatRelativeTime(thread.updatedAt)}</span>
<span className="shrink-0 whitespace-nowrap">
{formatRelativeTime(thread.updatedAt)}
</span>
</div>
</div>
@ -100,10 +113,10 @@ export function AgentRunCard({ thread }: AgentRunCardProps) {
"size-5 shrink-0",
thread.status === "finished"
? "text-[var(--ui-text-dim)] opacity-100"
: "opacity-0",
: "opacity-0"
)}
weight="regular"
/>
</Link>
);
)
}

View file

@ -1,50 +1,59 @@
import { Link } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router"
import {
CalendarBlankIcon,
ChartLineUpIcon,
ChatCircleIcon,
LightningIcon,
PlusIcon,
XIcon,
} from "@phosphor-icons/react";
import { IoLogoGithub, IoLogoSlack } from "react-icons/io5";
import { SiLinear } from "react-icons/si";
import type { ComponentType, SVGProps } from "react";
} from "@phosphor-icons/react"
import { IoLogoGithub, IoLogoSlack } from "react-icons/io5"
import { SiLinear } from "react-icons/si"
import type { ComponentType, SVGProps } from "react"
import type { SessionUser } from "@/lib/api";
import type { AgentSource, AgentThread } from "@/lib/agents/types";
import { SidebarUserMenu } from "@/components/SidebarUserMenu";
import type { SessionUser } from "@/lib/api"
import type { AgentSource, AgentThread } from "@/lib/agents/types"
import { SidebarUserMenu } from "@/components/SidebarUserMenu"
import {
SidebarCollapseButton,
SidebarFrame,
useSidebarLayout,
} from "@/components/sidebar-layout";
import { groupThreads } from "@/lib/agents/api";
import { useAgentThreads, useDeleteAgentThread } from "@/lib/agents/queries";
import { cn } from "@/lib/utils";
} from "@/components/sidebar-layout"
import { groupThreads } from "@/lib/agents/api"
import { useAgentThreads, useDeleteAgentThread } from "@/lib/agents/queries"
import { cn } from "@/lib/utils"
type SourceIcon = ComponentType<SVGProps<SVGSVGElement>>;
type SourceIcon = ComponentType<SVGProps<SVGSVGElement>>
const SOURCE_META: Record<AgentSource, { icon: SourceIcon; label: string }> = {
dashboard: { icon: ChatCircleIcon, label: "Started from the dashboard" },
github: { icon: IoLogoGithub, label: "Triggered from GitHub" },
slack: { icon: IoLogoSlack, label: "Triggered from Slack" },
linear: { icon: SiLinear, label: "Triggered from Linear" },
};
interface AgentsSidebarProps {
user: SessionUser;
activeThreadId?: string;
schedule: { icon: CalendarBlankIcon, label: "Triggered from a schedule" },
}
const NAV = [{ to: "/my-settings", label: "Dashboard", icon: ChartLineUpIcon }] as const;
interface AgentsSidebarProps {
user: SessionUser
activeThreadId?: string
}
const NAV = [
{ to: "/agents/automations", label: "Automations", icon: LightningIcon },
{ to: "/my-settings", label: "Dashboard", icon: ChartLineUpIcon },
] as const
export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
const threadsQuery = useAgentThreads();
const threads = threadsQuery.data ?? [];
const groups = groupThreads(threads);
const layout = useSidebarLayout();
const threadsQuery = useAgentThreads()
const threads = threadsQuery.data ?? []
const groups = groupThreads(threads)
const layout = useSidebarLayout()
return (
<SidebarFrame {...layout} className="border-r border-[var(--ui-border)] bg-[var(--ui-sidebar)]">
<SidebarFrame
{...layout}
className="border-r border-[var(--ui-border)] bg-[var(--ui-sidebar)]"
>
<div className="flex items-center justify-between px-4 pt-5 pb-4">
<Link
to="/my-settings"
@ -69,18 +78,22 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
<nav className="flex flex-col gap-0.5 px-2 pb-4">
{NAV.map((item) => {
const Icon = item.icon;
const Icon = item.icon
return (
<Link
key={item.to}
to={item.to}
onClick={layout.closeOnMobile}
className="flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--ui-text-muted)] transition-colors hover:bg-[var(--ui-sidebar-hover)] hover:text-[var(--ui-text)]"
activeProps={{
className:
"bg-[var(--ui-sidebar-hover)] !text-[var(--ui-text)] font-medium",
}}
>
<Icon className="size-4" />
{item.label}
</Link>
);
)
})}
</nav>
@ -109,7 +122,7 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
<SidebarUserMenu user={user} showSettingsLink />
</div>
</SidebarFrame>
);
)
}
function ThreadGroup({
@ -118,12 +131,12 @@ function ThreadGroup({
activeThreadId,
onNavigate,
}: {
label: string;
threads: Array<AgentThread>;
activeThreadId?: string;
onNavigate?: () => void;
label: string
threads: Array<AgentThread>
activeThreadId?: string
onNavigate?: () => void
}) {
if (threads.length === 0) return null;
if (threads.length === 0) return null
return (
<div className="mb-3">
@ -139,7 +152,7 @@ function ThreadGroup({
/>
))}
</div>
);
)
}
function ThreadRow({
@ -147,27 +160,32 @@ function ThreadRow({
isActive,
onNavigate,
}: {
thread: AgentThread;
isActive: boolean;
onNavigate?: () => void;
thread: AgentThread
isActive: boolean
onNavigate?: () => void
}) {
const deleteThread = useDeleteAgentThread();
const deleteThread = useDeleteAgentThread()
const badge =
thread.diffStats && thread.diffStats.additions > 0
? `+${thread.diffStats.additions}`
: null;
const isDeleting = deleteThread.isPending && deleteThread.variables === thread.id;
: null
const isDeleting =
deleteThread.isPending && deleteThread.variables === thread.id
const onDelete = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isDeleting) return;
if (!window.confirm(`Delete "${thread.title}"? This cannot be undone.`)) return;
deleteThread.mutate(thread.id);
};
e.preventDefault()
e.stopPropagation()
if (isDeleting) return
if (!window.confirm(`Delete "${thread.title}"? This cannot be undone.`))
return
deleteThread.mutate(thread.id)
}
const source = thread.source && thread.source !== "dashboard" ? SOURCE_META[thread.source] : null;
const SourceIcon = source?.icon;
const source =
thread.source && thread.source !== "dashboard"
? SOURCE_META[thread.source]
: null
const SourceIcon = source?.icon
return (
<Link
@ -179,7 +197,7 @@ function ThreadRow({
isActive
? "bg-[var(--ui-accent-bubble)] text-[var(--ui-text)]"
: "text-[var(--ui-text-muted)] hover:bg-[var(--ui-sidebar-hover)]",
isDeleting && "opacity-50",
isDeleting && "opacity-50"
)}
>
<span
@ -189,7 +207,7 @@ function ThreadRow({
? "animate-pulse bg-[var(--ui-accent)]"
: thread.status === "finished"
? "bg-[var(--ui-accent)]"
: "bg-[var(--ui-border)]",
: "bg-[var(--ui-border)]"
)}
/>
{source && SourceIcon && (
@ -211,12 +229,12 @@ function ThreadRow({
aria-label="Delete thread"
onClick={onDelete}
disabled={isDeleting}
className="hidden size-4 shrink-0 items-center justify-center rounded text-[var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)] group-hover:flex"
className="hidden size-4 shrink-0 items-center justify-center rounded text-[var(--ui-text-dim)] group-hover:flex hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
>
<XIcon className="size-3" weight="bold" />
</button>
</Link>
);
)
}
export function AgentsShell({
@ -224,14 +242,14 @@ export function AgentsShell({
activeThreadId,
children,
}: {
user: SessionUser;
activeThreadId?: string;
children: React.ReactNode;
user: SessionUser
activeThreadId?: string
children: React.ReactNode
}) {
return (
<div className="agents-ui flex h-svh overflow-hidden bg-[var(--ui-bg)]">
<AgentsSidebar user={user} activeThreadId={activeThreadId} />
<div className="flex min-w-0 flex-1">{children}</div>
</div>
);
)
}

View file

@ -0,0 +1,351 @@
import { useEffect, useMemo, useRef, useState } from "react"
import { Link, useNavigate } from "@tanstack/react-router"
import {
CaretDownIcon,
CheckIcon,
ClockIcon,
TrashIcon,
} from "@phosphor-icons/react"
import type { ModelOption } from "@/lib/api"
import type { AgentSchedule } from "@/lib/agents/types"
import type { ModelSelection } from "@/lib/agents/useModelOptions"
import { RepoSelector } from "@/components/agents/RepoSelector"
import { ScheduleTriggerPicker } from "@/components/agents/ScheduleTriggerPicker"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { describeCron, presetForCron } from "@/lib/agents/cron"
import {
useCreateAgentSchedule,
useDeleteAgentSchedule,
useUpdateAgentSchedule,
} from "@/lib/agents/queries"
import {
formatModelSelection,
useModelOptions,
} from "@/lib/agents/useModelOptions"
import { useRepos } from "@/lib/profile"
import { cn } from "@/lib/utils"
interface AutomationEditorProps {
mode: "create" | "edit"
schedule?: AgentSchedule
}
function scheduleToSelection(
models: Array<ModelOption>,
schedule?: AgentSchedule
): ModelSelection | null {
if (!schedule?.model || !schedule.effort) return null
const supported = models.some(
(model) =>
model.id === schedule.model && model.efforts.includes(schedule.effort!)
)
return supported
? { modelId: schedule.model, effort: schedule.effort }
: null
}
export function AutomationEditor({ mode, schedule }: AutomationEditorProps) {
const navigate = useNavigate()
const reposQuery = useRepos()
const { models, defaultSelection } = useModelOptions()
const createSchedule = useCreateAgentSchedule()
const updateSchedule = useUpdateAgentSchedule()
const deleteSchedule = useDeleteAgentSchedule()
const [name, setName] = useState(schedule?.name ?? "")
const [prompt, setPrompt] = useState(schedule?.prompt ?? "")
const [cron, setCron] = useState<string | null>(schedule?.schedule ?? null)
const [customMode, setCustomMode] = useState(
schedule ? presetForCron(schedule.schedule) === "custom" : false
)
const [repo, setRepo] = useState<string | null>(schedule?.repo ?? null)
const [enabled, setEnabled] = useState(schedule?.enabled ?? true)
// undefined = untouched (derive from the schedule / default as models load).
const [selectionOverride, setSelectionOverride] = useState<
ModelSelection | null | undefined
>(undefined)
const activeSelection =
selectionOverride !== undefined
? selectionOverride
: (scheduleToSelection(models, schedule) ?? defaultSelection)
const error =
createSchedule.error || updateSchedule.error || deleteSchedule.error
const errorMessage = error instanceof Error ? error.message : null
const isSaving = createSchedule.isPending || updateSchedule.isPending
const canSave = name.trim().length > 0 && prompt.trim().length > 0 && !!cron
const onPickTrigger = (value: string | null) => {
if (value === null) {
setCustomMode(true)
setCron((current) => current ?? "0 9 * * *")
} else {
setCustomMode(false)
setCron(value)
}
}
const handleSave = () => {
if (!canSave || !cron) return
const modelIsReal = models.some((m) => m.id === activeSelection?.modelId)
const modelId = modelIsReal ? (activeSelection?.modelId ?? null) : null
const effort = modelIsReal ? (activeSelection?.effort ?? null) : null
if (mode === "create") {
createSchedule.mutate(
{
name: name.trim(),
prompt: prompt.trim(),
schedule: cron.trim(),
repo,
model_id: modelId,
effort,
},
{ onSuccess: () => navigate({ to: "/agents/automations" }) }
)
return
}
if (!schedule) return
updateSchedule.mutate(
{
scheduleId: schedule.id,
body: {
name: name.trim(),
prompt: prompt.trim(),
schedule: cron.trim(),
repo: repo ?? "",
model_id: modelId,
effort,
enabled,
},
},
{ onSuccess: () => navigate({ to: "/agents/automations" }) }
)
}
const handleDelete = () => {
if (!schedule) return
if (!window.confirm(`Delete "${schedule.name}"?`)) return
deleteSchedule.mutate(schedule.id, {
onSuccess: () => navigate({ to: "/agents/automations" }),
})
}
return (
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto">
<header className="flex items-center justify-between gap-3 px-6 py-4 max-md:pt-14">
<div className="flex min-w-0 items-center gap-1.5 text-sm text-[var(--ui-text-dim)]">
<Link
to="/agents/automations"
className="shrink-0 transition-colors hover:text-[var(--ui-text)]"
>
Automations
</Link>
<span className="shrink-0">/</span>
<span className="truncate text-[var(--ui-text)]">
{name.trim() || "New automation"}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
{mode === "edit" && (
<Button
variant="ghost"
size="icon"
onClick={handleDelete}
disabled={deleteSchedule.isPending}
aria-label="Delete automation"
className="text-[var(--ui-text-dim)] hover:text-[var(--ui-danger)]"
>
<TrashIcon className="size-4" />
</Button>
)}
<Button onClick={handleSave} disabled={!canSave || isSaving}>
{isSaving
? "Saving…"
: mode === "create"
? "Create"
: "Save changes"}
</Button>
</div>
</header>
<div className="mx-auto w-full max-w-3xl px-6 pt-2 pb-16">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Untitled automation"
className="w-full bg-transparent text-2xl font-semibold text-[var(--ui-text)] outline-none placeholder:text-[var(--ui-text-dim)]"
/>
<div className="mt-3 flex items-center gap-3 text-sm">
<div className="flex items-center gap-2">
<Switch checked={enabled} onCheckedChange={setEnabled} />
<span className="text-[var(--ui-text-muted)]">
{enabled ? "Active" : "Paused"}
</span>
</div>
<span className="text-[var(--ui-border)]">|</span>
<RepoSelector
repos={reposQuery.data?.repositories}
selectedRepo={repo}
onRepoChange={setRepo}
placeholder="No repository"
triggerClassName="text-[var(--ui-text-muted)]"
/>
</div>
<SectionLabel>Triggers</SectionLabel>
<div className="rounded-xl border border-[var(--ui-border)] bg-[var(--ui-surface)] p-1.5">
{cron && (
<div className="flex items-center gap-3 rounded-lg px-3 py-2.5">
<ClockIcon className="size-4 shrink-0 text-[var(--ui-text-muted)]" />
{customMode ? (
<input
value={cron}
onChange={(e) => setCron(e.target.value)}
placeholder="0 9 * * 1-5"
className="flex-1 bg-transparent font-mono text-sm text-[var(--ui-text)] outline-none placeholder:text-[var(--ui-text-dim)]"
/>
) : (
<span className="flex-1 text-sm text-[var(--ui-text)]">
{describeCron(cron)}
</span>
)}
<button
type="button"
onClick={() => {
setCron(null)
setCustomMode(false)
}}
aria-label="Remove trigger"
className="shrink-0 rounded p-1 text-[var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
>
<TrashIcon className="size-3.5" />
</button>
</div>
)}
{cron && <div className="mx-3 h-px bg-[var(--ui-border-subtle)]" />}
<ScheduleTriggerPicker
onSelect={onPickTrigger}
triggerLabel={cron ? "Change trigger" : "Add Trigger"}
/>
</div>
<SectionLabel>Agent Instructions</SectionLabel>
<div className="rounded-xl border border-[var(--ui-border)] bg-[var(--ui-surface)] p-3">
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="What should Open SWE do each time this runs?"
rows={5}
className="w-full resize-none bg-transparent text-sm leading-relaxed text-[var(--ui-text)] outline-none placeholder:text-[var(--ui-text-dim)]"
/>
<div className="mt-2 flex items-center">
<ModelPicker
models={models}
selection={activeSelection}
onSelectionChange={setSelectionOverride}
/>
</div>
</div>
{errorMessage && (
<p className="mt-4 text-sm text-[var(--ui-danger)]">{errorMessage}</p>
)}
</div>
</div>
)
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<h2 className="mt-8 mb-2 text-sm font-medium text-[var(--ui-text-muted)]">
{children}
</h2>
)
}
function ModelPicker({
models,
selection,
onSelectionChange,
}: {
models: Array<ModelOption>
selection: ModelSelection | null
onSelectionChange: (next: ModelSelection) => void
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const combos = useMemo<Array<ModelSelection>>(() => {
const list: Array<ModelSelection> = []
for (const model of models) {
for (const effort of model.efforts) {
list.push({ modelId: model.id, effort })
}
}
return list
}, [models])
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
const disabled = combos.length === 0
return (
<div ref={ref} className="relative">
<button
type="button"
disabled={disabled}
onClick={() => setOpen((value) => !value)}
className="flex items-center gap-1 text-sm text-[var(--ui-text-muted)] transition-opacity hover:opacity-80 disabled:opacity-60"
>
<span>{formatModelSelection(models, selection)}</span>
{!disabled && <CaretDownIcon className="size-3.5 opacity-60" />}
</button>
{open && combos.length > 0 && (
<div className="absolute bottom-full left-0 z-50 mb-1 max-h-72 overflow-y-auto rounded-lg border border-[var(--ui-border)] bg-[var(--ui-surface)] py-1 shadow-lg">
{combos.map((combo) => {
const selected =
!!selection &&
selection.modelId === combo.modelId &&
selection.effort === combo.effort
return (
<button
key={`${combo.modelId}::${combo.effort}`}
type="button"
onClick={() => {
onSelectionChange(combo)
setOpen(false)
}}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm whitespace-nowrap transition-colors hover:bg-[var(--ui-panel-2)]",
selected
? "text-[var(--ui-text)]"
: "text-[var(--ui-text-muted)]"
)}
>
{formatModelSelection(models, combo)}
{selected && (
<CheckIcon className="ml-auto size-3.5 text-[var(--ui-text-dim)]" />
)}
</button>
)
})}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,204 @@
import { Link } from "@tanstack/react-router"
import {
ClockIcon,
LightningIcon,
PauseIcon,
PlayIcon,
PlusIcon,
WarningCircleIcon,
} from "@phosphor-icons/react"
import type { AgentSchedule } from "@/lib/agents/types"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { describeCron } from "@/lib/agents/cron"
import {
useAgentSchedules,
useUpdateAgentSchedule,
} from "@/lib/agents/queries"
import { cn } from "@/lib/utils"
function formatDate(value?: string | null): string {
if (!value) return "Never run"
const date = new Date(value)
if (Number.isNaN(date.getTime())) return "Never run"
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
}
export function AutomationsList() {
const schedulesQuery = useAgentSchedules()
const schedules = schedulesQuery.data ?? []
const total = schedules.length
const active = schedules.filter((s) => s.enabled).length
const paused = total - active
const issues = schedules.filter((s) => !!s.lastError).length
return (
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto">
<div className="mx-auto w-full max-w-4xl px-6 py-8 max-md:pt-16">
<h1 className="text-2xl font-semibold text-[var(--ui-text)]">
Automations
</h1>
<p className="mt-1 text-sm text-[var(--ui-text-muted)]">
Run Open SWE on a recurring schedule. Each run starts a fresh agent
thread.
</p>
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Total" value={total} />
<StatCard label="Active" value={active} />
<StatCard label="Paused" value={paused} />
<StatCard label="Needs attention" value={issues} highlight={issues > 0} />
</div>
<div className="mt-8 flex items-center justify-between">
<span className="text-sm font-medium text-[var(--ui-text-muted)]">
{total} {total === 1 ? "automation" : "automations"}
</span>
<Link to="/agents/automations/new" className={buttonVariants()}>
<PlusIcon className="size-4" />
New Automation
</Link>
</div>
<div className="mt-3">
{schedulesQuery.isLoading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full rounded-xl" />
<Skeleton className="h-16 w-full rounded-xl" />
</div>
) : total === 0 ? (
<EmptyState />
) : (
<div className="space-y-2">
{schedules.map((schedule) => (
<AutomationRow key={schedule.id} schedule={schedule} />
))}
</div>
)}
</div>
</div>
</div>
)
}
function StatCard({
label,
value,
highlight,
}: {
label: string
value: number
highlight?: boolean
}) {
return (
<div className="rounded-xl border border-[var(--ui-border)] bg-[var(--ui-surface)] px-4 py-3">
<div className="text-xs text-[var(--ui-text-dim)]">{label}</div>
<div
className={cn(
"mt-1 text-2xl font-semibold",
highlight ? "text-[var(--ui-danger)]" : "text-[var(--ui-text)]"
)}
>
{value}
</div>
</div>
)
}
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-[var(--ui-border)] bg-[var(--ui-surface)] px-6 py-14 text-center">
<div className="rounded-full bg-[var(--ui-panel-2)] p-3 text-[var(--ui-text-muted)]">
<LightningIcon className="size-5" />
</div>
<h3 className="mt-4 text-sm font-medium text-[var(--ui-text)]">
No automations yet
</h3>
<p className="mt-1 max-w-sm text-sm text-[var(--ui-text-muted)]">
Schedule Open SWE to run on a recurring cadence — review code, triage
issues, or keep docs up to date.
</p>
<Link
to="/agents/automations/new"
className={cn(buttonVariants(), "mt-4")}
>
<PlusIcon className="size-4" />
New Automation
</Link>
</div>
)
}
function AutomationRow({ schedule }: { schedule: AgentSchedule }) {
const updateSchedule = useUpdateAgentSchedule()
const isToggling =
updateSchedule.isPending &&
updateSchedule.variables.scheduleId === schedule.id
const onToggle = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
updateSchedule.mutate({
scheduleId: schedule.id,
body: { enabled: !schedule.enabled },
})
}
return (
<Link
to="/agents/automations/$scheduleId"
params={{ scheduleId: schedule.id }}
className="flex items-center gap-3 rounded-xl border border-[var(--ui-border)] bg-[var(--ui-surface)] px-4 py-3 transition-colors hover:border-[var(--ui-text-dim)]"
>
<span
className={cn(
"size-2 shrink-0 rounded-full",
schedule.enabled ? "bg-[var(--ui-success)]" : "bg-[var(--ui-border)]"
)}
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium text-[var(--ui-text)]">
{schedule.name}
</span>
{schedule.lastError && (
<WarningCircleIcon
className="size-3.5 shrink-0 text-[var(--ui-danger)]"
aria-label="Last run failed"
>
<title>Last run failed</title>
</WarningCircleIcon>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--ui-text-dim)]">
<span className="flex items-center gap-1">
<ClockIcon className="size-3.5" />
{describeCron(schedule.schedule)}
</span>
{schedule.repo && <span>{schedule.repo}</span>}
<span>Last run: {formatDate(schedule.lastTriggeredAt)}</span>
</div>
</div>
<button
type="button"
onClick={onToggle}
disabled={isToggling}
aria-label={schedule.enabled ? "Pause automation" : "Resume automation"}
className="shrink-0 rounded-md p-1.5 text-[var(--ui-text-dim)] transition-colors hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)] disabled:opacity-40"
>
{schedule.enabled ? (
<PauseIcon className="size-4" />
) : (
<PlayIcon className="size-4" />
)}
</button>
</Link>
)
}

View file

@ -0,0 +1,78 @@
import { useEffect, useRef, useState } from "react"
import { CaretRightIcon, ClockIcon, PlusIcon } from "@phosphor-icons/react"
import { CRON_PRESETS } from "@/lib/agents/cron"
import { cn } from "@/lib/utils"
interface ScheduleTriggerPickerProps {
/** Called with a cron value for a preset, or null when the user picks Custom. */
onSelect: (cron: string | null) => void
triggerLabel?: string
}
const OPTIONS: Array<{ id: string; label: string; cron: string | null }> = [
...CRON_PRESETS.map((preset) => ({
id: preset.id,
label: preset.label,
cron: preset.value,
})),
{ id: "custom", label: "Custom (cron)", cron: null },
]
export function ScheduleTriggerPicker({
onSelect,
triggerLabel = "Add Trigger",
}: ScheduleTriggerPickerProps) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-sm text-[var(--ui-text-muted)] transition-colors hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
>
<PlusIcon className="size-4" />
{triggerLabel}
</button>
{open && (
<div className="absolute top-full left-0 z-50 mt-1 w-72 overflow-hidden rounded-xl border border-[var(--ui-border)] bg-[var(--ui-surface)] py-1 shadow-lg">
<div className="flex items-center gap-2 px-3 py-1.5 text-[11px] font-semibold tracking-wide text-[var(--ui-text-dim)] uppercase">
<ClockIcon className="size-3.5" />
Scheduled
</div>
{OPTIONS.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
onSelect(option.cron)
setOpen(false)
}}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--ui-text-muted)] transition-colors hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
)}
>
<span className="flex-1">{option.label}</span>
{option.cron === null && (
<CaretRightIcon className="size-3.5 opacity-50" />
)}
</button>
))}
</div>
)}
</div>
)
}

View file

@ -1,23 +1,48 @@
import type { AgentThread, Message } from "./types";
import type { AgentSchedule, AgentThread, Message } from "./types"
export type { AgentThread, Message };
export type { AgentSchedule, AgentThread, Message }
export interface ThreadCreateRequest {
prompt: string;
repo?: string | null;
model_id?: string | null;
effort?: string | null;
prompt: string
repo?: string | null
model_id?: string | null
effort?: string | null
}
export interface ThreadMessageRequest {
content: string;
model_id?: string | null;
effort?: string | null;
content: string
model_id?: string | null
effort?: string | null
}
const API_BASE = (import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "").replace(/\/$/, "");
export interface ScheduleCreateRequest {
prompt: string
schedule: string
name?: string | null
repo?: string | null
model_id?: string | null
effort?: string | null
}
async function agentsRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
export interface ScheduleUpdateRequest {
prompt?: string | null
schedule?: string | null
name?: string | null
repo?: string | null
model_id?: string | null
effort?: string | null
enabled?: boolean | null
}
const API_BASE = (import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "").replace(
/\/$/,
""
)
async function agentsRequest<T>(
path: string,
init: RequestInit = {}
): Promise<T> {
const res = await fetch(`${API_BASE}/dashboard/api${path}`, {
...init,
credentials: "include",
@ -25,25 +50,46 @@ async function agentsRequest<T>(path: string, init: RequestInit = {}): Promise<T
"Content-Type": "application/json",
...(init.headers ?? {}),
},
});
})
if (!res.ok) {
let message = res.statusText;
let message = res.statusText
try {
const body = await res.json();
const body = await res.json()
if (body?.detail) {
message = typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail);
message =
typeof body.detail === "string"
? body.detail
: JSON.stringify(body.detail)
}
} catch {
/* ignore */
}
throw new Error(message);
throw new Error(message)
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
if (res.status === 204) return undefined as T
return (await res.json()) as T
}
export const agentsApi = {
listThreads: () => agentsRequest<AgentThread[]>("/threads"),
listThreads: () => agentsRequest<Array<AgentThread>>("/threads"),
listSchedules: () => agentsRequest<Array<AgentSchedule>>("/schedules"),
createSchedule: (body: ScheduleCreateRequest) =>
agentsRequest<AgentSchedule>("/schedules", {
method: "POST",
body: JSON.stringify(body),
}),
updateSchedule: (scheduleId: string, body: ScheduleUpdateRequest) =>
agentsRequest<AgentSchedule>(
`/schedules/${encodeURIComponent(scheduleId)}`,
{
method: "PATCH",
body: JSON.stringify(body),
}
),
deleteSchedule: (scheduleId: string) =>
agentsRequest<void>(`/schedules/${encodeURIComponent(scheduleId)}`, {
method: "DELETE",
}),
getThread: (threadId: string) =>
agentsRequest<AgentThread>(`/threads/${encodeURIComponent(threadId)}`),
createThread: (body: ThreadCreateRequest) =>
@ -52,56 +98,64 @@ export const agentsApi = {
body: JSON.stringify(body),
}),
sendMessage: (threadId: string, body: ThreadMessageRequest) =>
agentsRequest<AgentThread>(`/threads/${encodeURIComponent(threadId)}/messages`, {
method: "POST",
body: JSON.stringify(body),
}),
agentsRequest<AgentThread>(
`/threads/${encodeURIComponent(threadId)}/messages`,
{
method: "POST",
body: JSON.stringify(body),
}
),
cancelThread: (threadId: string) =>
agentsRequest<AgentThread>(`/threads/${encodeURIComponent(threadId)}/cancel`, {
method: "POST",
}),
agentsRequest<AgentThread>(
`/threads/${encodeURIComponent(threadId)}/cancel`,
{
method: "POST",
}
),
deleteThread: (threadId: string) =>
agentsRequest<void>(`/threads/${encodeURIComponent(threadId)}`, {
method: "DELETE",
}),
streamUrl: (threadId: string) =>
`${API_BASE}/dashboard/api/threads/${encodeURIComponent(threadId)}/stream`,
};
export function formatRelativeTime(ts: number): string {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d`;
const weeks = Math.floor(days / 7);
return `${weeks}w`;
}
export type ThreadGroup = "today" | "last30" | "older";
export function formatRelativeTime(ts: number): string {
const diff = Date.now() - ts
const mins = Math.floor(diff / 60000)
if (mins < 60) return `${mins}m`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d`
const weeks = Math.floor(days / 7)
return `${weeks}w`
}
export function groupThreads(threads: AgentThread[]): Record<ThreadGroup, AgentThread[]> {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
export type ThreadGroup = "today" | "last30" | "older"
const groups: Record<ThreadGroup, AgentThread[]> = {
export function groupThreads(
threads: Array<AgentThread>
): Record<ThreadGroup, Array<AgentThread>> {
const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0)
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000
const groups: Record<ThreadGroup, Array<AgentThread>> = {
today: [],
last30: [],
older: [],
};
}
for (const thread of [...threads].sort((a, b) => b.updatedAt - a.updatedAt)) {
if (thread.updatedAt >= todayStart.getTime()) {
groups.today.push(thread);
groups.today.push(thread)
} else if (thread.updatedAt >= thirtyDaysAgo) {
groups.last30.push(thread);
groups.last30.push(thread)
} else {
groups.older.push(thread);
groups.older.push(thread)
}
}
return groups;
return groups
}

73
ui/src/lib/agents/cron.ts Normal file
View file

@ -0,0 +1,73 @@
export interface CronPreset {
id: "hourly" | "daily" | "weekly"
label: string
value: string
}
export const CRON_PRESETS: Array<CronPreset> = [
{ id: "hourly", label: "Hourly", value: "0 * * * *" },
{ id: "daily", label: "Daily", value: "0 9 * * *" },
{ id: "weekly", label: "Weekly", value: "0 9 * * 1" },
]
const DAY_NAMES = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
]
function pad(n: number): string {
return n.toString().padStart(2, "0")
}
function formatTime(minute: number, hour: number): string {
return `${pad(hour)}:${pad(minute)} UTC`
}
/** Best-effort human description of a 5-field cron expression. */
export function describeCron(expr: string): string {
const parts = expr.trim().split(/\s+/)
if (parts.length !== 5) return expr
const [min, hour, dom, mon, dow] = parts
if (!min || !hour || !dom || !mon || !dow) return expr
if (min === "0" && hour === "*" && dom === "*" && mon === "*" && dow === "*") {
return "Every hour"
}
const everyNHours = hour.match(/^\*\/(\d+)$/)
if (everyNHours && min === "0" && dom === "*" && mon === "*" && dow === "*") {
return `Every ${everyNHours[1]} hours`
}
const minNum = Number(min)
const hourNum = Number(hour)
const timeKnown =
Number.isInteger(minNum) &&
Number.isInteger(hourNum) &&
minNum >= 0 &&
hourNum >= 0
if (timeKnown && dom === "*" && mon === "*") {
if (dow === "*") return `Daily at ${formatTime(minNum, hourNum)}`
if (dow === "1-5") return `Weekdays at ${formatTime(minNum, hourNum)}`
const dowNum = Number(dow)
if (Number.isInteger(dowNum) && dowNum >= 0 && dowNum <= 7) {
const name = DAY_NAMES[dowNum % 7]
return `Weekly on ${name} at ${formatTime(minNum, hourNum)}`
}
}
return expr
}
/** Match a cron expression back to a known preset id, if any. */
export function presetForCron(expr: string): CronPreset["id"] | "custom" {
const normalized = expr.trim().split(/\s+/).join(" ")
const match = CRON_PRESETS.find((p) => p.value === normalized)
return match?.id ?? "custom"
}

View file

@ -1,19 +1,24 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { agentsApi } from "./api";
import { addPendingPrompt } from "./pendingPrompts";
import { agentsApi } from "./api"
import { addPendingPrompt } from "./pendingPrompts"
import type { ScheduleUpdateRequest } from "./api"
export const agentThreadKeys = {
all: ["agent-threads"] as const,
detail: (threadId: string) => ["agent-threads", threadId] as const,
};
}
export const agentScheduleKeys = {
all: ["agent-schedules"] as const,
}
export function useAgentThreads() {
return useQuery({
queryKey: agentThreadKeys.all,
queryFn: () => agentsApi.listThreads(),
});
})
}
export function useAgentThread(threadId: string) {
@ -21,38 +26,79 @@ export function useAgentThread(threadId: string) {
queryKey: agentThreadKeys.detail(threadId),
queryFn: () => agentsApi.getThread(threadId),
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "running" ? 2000 : false;
const status = query.state.data?.status
return status === "running" ? 2000 : false
},
});
})
}
export function useAgentSchedules() {
return useQuery({
queryKey: agentScheduleKeys.all,
queryFn: () => agentsApi.listSchedules(),
})
}
export function useCreateAgentSchedule() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: agentsApi.createSchedule,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: agentScheduleKeys.all })
},
})
}
export function useUpdateAgentSchedule() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (vars: { scheduleId: string; body: ScheduleUpdateRequest }) =>
agentsApi.updateSchedule(vars.scheduleId, vars.body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: agentScheduleKeys.all })
},
})
}
export function useDeleteAgentSchedule() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: agentsApi.deleteSchedule,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: agentScheduleKeys.all })
},
})
}
export function useCreateAgentThread() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const queryClient = useQueryClient()
const navigate = useNavigate()
return useMutation({
mutationFn: agentsApi.createThread,
onSuccess: (thread, variables) => {
addPendingPrompt(thread.id, variables.prompt, thread.messages.length);
addPendingPrompt(thread.id, variables.prompt, thread.messages.length)
queryClient.setQueryData(agentThreadKeys.detail(thread.id), {
...thread,
status: thread.status === "idle" ? "running" : thread.status,
});
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all });
navigate({ to: "/agents/$threadId", params: { threadId: thread.id } });
})
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all })
navigate({ to: "/agents/$threadId", params: { threadId: thread.id } })
},
});
})
}
export interface SendAgentMessageVariables {
content: string;
model_id?: string | null;
effort?: string | null;
content: string
model_id?: string | null
effort?: string | null
}
export function useSendAgentMessage(threadId: string) {
const queryClient = useQueryClient();
const queryClient = useQueryClient()
return useMutation({
mutationFn: (vars: SendAgentMessageVariables) =>
@ -62,50 +108,56 @@ export function useSendAgentMessage(threadId: string) {
effort: vars.effort,
}),
onMutate: (vars) => {
const cached = queryClient.getQueryData<{ messages?: unknown[] }>(
agentThreadKeys.detail(threadId),
);
const insertAt = Array.isArray(cached?.messages) ? cached.messages.length : 0;
addPendingPrompt(threadId, vars.content, insertAt);
const cached = queryClient.getQueryData<{ messages?: Array<unknown> }>(
agentThreadKeys.detail(threadId)
)
const insertAt = Array.isArray(cached?.messages)
? cached.messages.length
: 0
addPendingPrompt(threadId, vars.content, insertAt)
},
onSuccess: (thread) => {
queryClient.setQueryData(agentThreadKeys.detail(threadId), (prev: typeof thread | undefined) => {
if (!prev) return thread;
return {
...thread,
messages: thread.messages.length > 0 ? thread.messages : prev.messages,
};
});
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all });
queryClient.setQueryData(
agentThreadKeys.detail(threadId),
(prev: typeof thread | undefined) => {
if (!prev) return thread
return {
...thread,
messages:
thread.messages.length > 0 ? thread.messages : prev.messages,
}
}
)
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all })
},
});
})
}
export function useCancelAgentThread(threadId: string) {
const queryClient = useQueryClient();
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => agentsApi.cancelThread(threadId),
onSuccess: (thread) => {
queryClient.setQueryData(agentThreadKeys.detail(threadId), thread);
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all });
queryClient.setQueryData(agentThreadKeys.detail(threadId), thread)
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all })
},
});
})
}
export function useDeleteAgentThread() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const queryClient = useQueryClient()
const navigate = useNavigate()
return useMutation({
mutationFn: (threadId: string) => agentsApi.deleteThread(threadId),
onSuccess: (_, threadId) => {
queryClient.removeQueries({ queryKey: agentThreadKeys.detail(threadId) });
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all });
const path = window.location.pathname;
queryClient.removeQueries({ queryKey: agentThreadKeys.detail(threadId) })
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all })
const path = window.location.pathname
if (path.includes(`/agents/${threadId}`)) {
navigate({ to: "/agents" });
navigate({ to: "/agents" })
}
},
});
})
}

View file

@ -1,4 +1,4 @@
export type Author = "user" | "agent" | "system" | "tool";
export type Author = "user" | "agent" | "system" | "tool"
export type ChunkKind =
| "text"
@ -7,17 +7,27 @@ export type ChunkKind =
| "list"
| "tool-execution"
| "todo"
| "image";
| "image"
export type TodoStatus = "pending" | "in_progress" | "completed";
export type TodoStatus = "pending" | "in_progress" | "completed"
export type AgentStatus = "idle" | "running" | "finished" | "interrupted" | "error";
export type AgentStatus =
| "idle"
| "running"
| "finished"
| "interrupted"
| "error"
export type AgentSource = "dashboard" | "github" | "slack" | "linear";
export type AgentSource =
| "dashboard"
| "github"
| "slack"
| "linear"
| "schedule"
export interface TodoItem {
content: string;
status: TodoStatus;
content: string
status: TodoStatus
}
export type AcpToolKind =
@ -31,71 +41,71 @@ export type AcpToolKind =
| "fetch"
| "slack"
| "linear"
| "other";
| "other"
export type AcpToolStatus = "pending" | "in_progress" | "completed" | "error";
export type AcpToolStatus = "pending" | "in_progress" | "completed" | "error"
export interface AcpToolLocation {
path: string;
line?: number;
path: string
line?: number
}
export interface DiffData {
originalContent: string | null;
newContent: string;
filePath: string;
isNewFile: boolean;
isBinary: boolean;
isTruncated: boolean;
totalLines: number;
originalContent: string | null
newContent: string
filePath: string
isNewFile: boolean
isBinary: boolean
isTruncated: boolean
totalLines: number
}
export interface ToolExecutionChunk {
kind: "tool-execution";
toolCallId: string;
title: string;
toolKind: AcpToolKind;
input?: Record<string, unknown>;
status: AcpToolStatus;
output?: string;
elapsedMs?: number;
approvalRequestId?: string;
diffData?: DiffData;
diffs?: DiffData[];
locations?: AcpToolLocation[];
kind: "tool-execution"
toolCallId: string
title: string
toolKind: AcpToolKind
input?: Record<string, unknown>
status: AcpToolStatus
output?: string
elapsedMs?: number
approvalRequestId?: string
diffData?: DiffData
diffs?: Array<DiffData>
locations?: Array<AcpToolLocation>
}
export interface TextChunk {
kind: "text";
text: string;
kind: "text"
text: string
}
export interface CodeChunk {
kind: "code";
text: string;
language?: string;
kind: "code"
text: string
language?: string
}
export interface ErrorChunk {
kind: "error";
text: string;
kind: "error"
text: string
}
export interface ListChunk {
kind: "list";
lines: string[];
kind: "list"
lines: Array<string>
}
export interface TodoChunk {
kind: "todo";
todos: TodoItem[];
kind: "todo"
todos: Array<TodoItem>
}
export interface ImageChunk {
kind: "image";
base64: string;
mimeType: string;
fileName?: string;
kind: "image"
base64: string
mimeType: string
fileName?: string
}
export type Chunk =
@ -105,57 +115,76 @@ export type Chunk =
| ListChunk
| ToolExecutionChunk
| TodoChunk
| ImageChunk;
| ImageChunk
export interface Message {
id: string;
author: Author;
timestamp: string;
chunks: Chunk[];
hidden?: boolean;
id: string
author: Author
timestamp: string
chunks: Array<Chunk>
hidden?: boolean
}
export interface Project {
id: string;
path: string;
name: string;
createdAt: number;
lastOpenedAt: number;
gitBranch?: string;
id: string
path: string
name: string
createdAt: number
lastOpenedAt: number
gitBranch?: string
}
export interface AgentSchedule {
id: string
name: string
prompt: string
schedule: string
repo: string | null
model: string
effort?: string | null
enabled: boolean
cronId?: string | null
lastThreadId?: string | null
lastRunId?: string | null
lastTriggeredAt?: string | null
lastError?: string | null
lastErrorAt?: string | null
createdAt?: string | null
updatedAt?: string | null
}
export interface AgentThread {
id: string;
title: string;
repo: string;
repoFullName: string;
branch: string;
model: string;
effort?: string | null;
source?: AgentSource;
status: AgentStatus;
createdAt: number;
updatedAt: number;
messages: Message[];
id: string
title: string
repo: string
repoFullName: string
branch: string
model: string
effort?: string | null
source?: AgentSource
status: AgentStatus
createdAt: number
updatedAt: number
messages: Array<Message>
pr?: {
number: number;
title: string;
state: "draft" | "open" | "merged";
headRef: string;
baseRef: string;
url: string;
};
number: number
title: string
state: "draft" | "open" | "merged"
headRef: string
baseRef: string
url: string
}
diffStats?: {
files: number;
additions: number;
deletions: number;
};
files: number
additions: number
deletions: number
}
changedFiles?: Array<{
path: string;
additions: number;
deletions: number;
patch?: string;
}>;
path: string
additions: number
deletions: number
patch?: string
}>
}
export type GitFileStatus =
@ -164,11 +193,11 @@ export type GitFileStatus =
| "index-deleted"
| "modified"
| "deleted"
| "untracked";
| "untracked"
export interface GitStatusEntry {
path: string;
status: GitFileStatus;
staged: boolean;
originalPath?: string;
path: string
status: GitFileStatus
staged: boolean
originalPath?: string
}

View file

@ -21,7 +21,10 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as AgentsIndexRouteImport } from './routes/agents/index'
import { Route as ReviewStylesRouteImport } from './routes/review_.styles'
import { Route as AgentsThreadIdRouteImport } from './routes/agents/$threadId'
import { Route as AgentsAutomationsIndexRouteImport } from './routes/agents/automations/index'
import { Route as ReviewRepositoriesOwnerRouteImport } from './routes/review_.repositories.$owner'
import { Route as AgentsAutomationsNewRouteImport } from './routes/agents/automations/new'
import { Route as AgentsAutomationsScheduleIdRouteImport } from './routes/agents/automations/$scheduleId'
const UsageRoute = UsageRouteImport.update({
id: '/usage',
@ -83,11 +86,27 @@ const AgentsThreadIdRoute = AgentsThreadIdRouteImport.update({
path: '/$threadId',
getParentRoute: () => AgentsRoute,
} as any)
const AgentsAutomationsIndexRoute = AgentsAutomationsIndexRouteImport.update({
id: '/automations/',
path: '/automations/',
getParentRoute: () => AgentsRoute,
} as any)
const ReviewRepositoriesOwnerRoute = ReviewRepositoriesOwnerRouteImport.update({
id: '/review_/repositories/$owner',
path: '/review/repositories/$owner',
getParentRoute: () => rootRouteImport,
} as any)
const AgentsAutomationsNewRoute = AgentsAutomationsNewRouteImport.update({
id: '/automations/new',
path: '/automations/new',
getParentRoute: () => AgentsRoute,
} as any)
const AgentsAutomationsScheduleIdRoute =
AgentsAutomationsScheduleIdRouteImport.update({
id: '/automations/$scheduleId',
path: '/automations/$scheduleId',
getParentRoute: () => AgentsRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@ -102,7 +121,10 @@ export interface FileRoutesByFullPath {
'/agents/$threadId': typeof AgentsThreadIdRoute
'/review/styles': typeof ReviewStylesRoute
'/agents/': typeof AgentsIndexRoute
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
'/agents/automations/new': typeof AgentsAutomationsNewRoute
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
'/agents/automations/': typeof AgentsAutomationsIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@ -116,7 +138,10 @@ export interface FileRoutesByTo {
'/agents/$threadId': typeof AgentsThreadIdRoute
'/review/styles': typeof ReviewStylesRoute
'/agents': typeof AgentsIndexRoute
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
'/agents/automations/new': typeof AgentsAutomationsNewRoute
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
'/agents/automations': typeof AgentsAutomationsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@ -132,7 +157,10 @@ export interface FileRoutesById {
'/agents/$threadId': typeof AgentsThreadIdRoute
'/review_/styles': typeof ReviewStylesRoute
'/agents/': typeof AgentsIndexRoute
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
'/agents/automations/new': typeof AgentsAutomationsNewRoute
'/review_/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
'/agents/automations/': typeof AgentsAutomationsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@ -149,7 +177,10 @@ export interface FileRouteTypes {
| '/agents/$threadId'
| '/review/styles'
| '/agents/'
| '/agents/automations/$scheduleId'
| '/agents/automations/new'
| '/review/repositories/$owner'
| '/agents/automations/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@ -163,7 +194,10 @@ export interface FileRouteTypes {
| '/agents/$threadId'
| '/review/styles'
| '/agents'
| '/agents/automations/$scheduleId'
| '/agents/automations/new'
| '/review/repositories/$owner'
| '/agents/automations'
id:
| '__root__'
| '/'
@ -178,7 +212,10 @@ export interface FileRouteTypes {
| '/agents/$threadId'
| '/review_/styles'
| '/agents/'
| '/agents/automations/$scheduleId'
| '/agents/automations/new'
| '/review_/repositories/$owner'
| '/agents/automations/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@ -197,13 +234,6 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/review': {
id: '/review'
path: '/review'
fullPath: '/review'
preLoaderRoute: typeof ReviewRouteImport
parentRoute: typeof rootRouteImport
}
'/usage': {
id: '/usage'
path: '/usage'
@ -211,6 +241,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof UsageRouteImport
parentRoute: typeof rootRouteImport
}
'/review': {
id: '/review'
path: '/review'
fullPath: '/review'
preLoaderRoute: typeof ReviewRouteImport
parentRoute: typeof rootRouteImport
}
'/my-settings': {
id: '/my-settings'
path: '/my-settings'
@ -281,6 +318,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AgentsThreadIdRouteImport
parentRoute: typeof AgentsRoute
}
'/agents/automations/': {
id: '/agents/automations/'
path: '/automations'
fullPath: '/agents/automations/'
preLoaderRoute: typeof AgentsAutomationsIndexRouteImport
parentRoute: typeof AgentsRoute
}
'/review_/repositories/$owner': {
id: '/review_/repositories/$owner'
path: '/review/repositories/$owner'
@ -288,17 +332,37 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ReviewRepositoriesOwnerRouteImport
parentRoute: typeof rootRouteImport
}
'/agents/automations/new': {
id: '/agents/automations/new'
path: '/automations/new'
fullPath: '/agents/automations/new'
preLoaderRoute: typeof AgentsAutomationsNewRouteImport
parentRoute: typeof AgentsRoute
}
'/agents/automations/$scheduleId': {
id: '/agents/automations/$scheduleId'
path: '/automations/$scheduleId'
fullPath: '/agents/automations/$scheduleId'
preLoaderRoute: typeof AgentsAutomationsScheduleIdRouteImport
parentRoute: typeof AgentsRoute
}
}
}
interface AgentsRouteChildren {
AgentsThreadIdRoute: typeof AgentsThreadIdRoute
AgentsIndexRoute: typeof AgentsIndexRoute
AgentsAutomationsScheduleIdRoute: typeof AgentsAutomationsScheduleIdRoute
AgentsAutomationsNewRoute: typeof AgentsAutomationsNewRoute
AgentsAutomationsIndexRoute: typeof AgentsAutomationsIndexRoute
}
const AgentsRouteChildren: AgentsRouteChildren = {
AgentsThreadIdRoute: AgentsThreadIdRoute,
AgentsIndexRoute: AgentsIndexRoute,
AgentsAutomationsScheduleIdRoute: AgentsAutomationsScheduleIdRoute,
AgentsAutomationsNewRoute: AgentsAutomationsNewRoute,
AgentsAutomationsIndexRoute: AgentsAutomationsIndexRoute,
}
const AgentsRouteWithChildren =

View file

@ -0,0 +1,46 @@
import { Link, createFileRoute } from "@tanstack/react-router";
import { AutomationEditor } from "@/components/agents/AutomationEditor";
import { AgentsShell } from "@/components/agents/AgentsSidebar";
import { Skeleton } from "@/components/ui/skeleton";
import { useAgentSchedules } from "@/lib/agents/queries";
import { useSession } from "@/lib/session";
export const Route = createFileRoute("/agents/automations/$scheduleId")({
component: EditAutomationPage,
});
function EditAutomationPage() {
const session = useSession();
const { scheduleId } = Route.useParams();
const schedulesQuery = useAgentSchedules();
if (!session.data) return null;
const schedule = schedulesQuery.data?.find((s) => s.id === scheduleId);
return (
<AgentsShell user={session.data}>
{schedulesQuery.isLoading ? (
<div className="mx-auto w-full max-w-3xl px-6 py-10">
<Skeleton className="h-9 w-64" />
<Skeleton className="mt-6 h-32 w-full" />
</div>
) : schedule ? (
<AutomationEditor mode="edit" schedule={schedule} />
) : (
<div className="mx-auto w-full max-w-3xl px-6 py-16 text-center">
<p className="text-sm text-[var(--ui-text-muted)]">
This automation could not be found.
</p>
<Link
to="/agents/automations"
className="mt-3 inline-block text-sm text-[var(--ui-accent)] hover:underline"
>
Back to Automations
</Link>
</div>
)}
</AgentsShell>
);
}

View file

@ -0,0 +1,20 @@
import { createFileRoute } from "@tanstack/react-router";
import { AutomationsList } from "@/components/agents/AutomationsList";
import { AgentsShell } from "@/components/agents/AgentsSidebar";
import { useSession } from "@/lib/session";
export const Route = createFileRoute("/agents/automations/")({
component: AutomationsIndexPage,
});
function AutomationsIndexPage() {
const session = useSession();
if (!session.data) return null;
return (
<AgentsShell user={session.data}>
<AutomationsList />
</AgentsShell>
);
}

View file

@ -0,0 +1,20 @@
import { createFileRoute } from "@tanstack/react-router";
import { AutomationEditor } from "@/components/agents/AutomationEditor";
import { AgentsShell } from "@/components/agents/AgentsSidebar";
import { useSession } from "@/lib/session";
export const Route = createFileRoute("/agents/automations/new")({
component: NewAutomationPage,
});
function NewAutomationPage() {
const session = useSession();
if (!session.data) return null;
return (
<AgentsShell user={session.data}>
<AutomationEditor mode="create" />
</AgentsShell>
);
}