From e189ee98e1fadbe070ee9b23d2ce1f03e36513a7 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 17 May 2026 11:54:19 -0700 Subject: [PATCH] [eric] workflows as canvas-resident cards + hub + schedule UX pass, unstable version --- backend/apps/dashboards/models.py | 18 + backend/apps/workflows/__init__.py | 0 backend/apps/workflows/executor.py | 173 ++++++ backend/apps/workflows/models.py | 114 ++++ backend/apps/workflows/notifier.py | 43 ++ backend/apps/workflows/scheduler.py | 199 ++++++ backend/apps/workflows/storage.py | 153 +++++ backend/apps/workflows/workflows.py | 148 +++++ backend/main.py | 3 +- .../src/app/pages/Dashboard/AgentCard.tsx | 74 +++ .../src/app/pages/Dashboard/Dashboard.tsx | 204 ++++++- .../app/pages/Dashboard/DashboardToolbar.tsx | 141 ++--- .../pages/Dashboard/useDashboardSelection.ts | 20 +- .../app/pages/Workflows/ScheduleCalendar.tsx | 212 +++++++ .../app/pages/Workflows/SchedulePopover.tsx | 176 ++++++ .../src/app/pages/Workflows/WorkflowCard.tsx | 508 ++++++++++++++++ .../pages/Workflows/WorkflowCardSubviews.tsx | 184 ++++++ .../app/pages/Workflows/WorkflowEditViews.tsx | 565 ++++++++++++++++++ .../app/pages/Workflows/WorkflowsHubCard.tsx | 455 ++++++++++++++ .../src/app/pages/Workflows/scheduleUtils.ts | 127 ++++ .../src/shared/state/dashboardLayoutSlice.ts | 213 ++++++- frontend/src/shared/state/store.ts | 2 + frontend/src/shared/state/workflowsSlice.ts | 210 +++++++ frontend/src/shared/ws/WebSocketManager.ts | 17 + 24 files changed, 3851 insertions(+), 108 deletions(-) create mode 100644 backend/apps/workflows/__init__.py create mode 100644 backend/apps/workflows/executor.py create mode 100644 backend/apps/workflows/models.py create mode 100644 backend/apps/workflows/notifier.py create mode 100644 backend/apps/workflows/scheduler.py create mode 100644 backend/apps/workflows/storage.py create mode 100644 backend/apps/workflows/workflows.py create mode 100644 frontend/src/app/pages/Workflows/ScheduleCalendar.tsx create mode 100644 frontend/src/app/pages/Workflows/SchedulePopover.tsx create mode 100644 frontend/src/app/pages/Workflows/WorkflowCard.tsx create mode 100644 frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx create mode 100644 frontend/src/app/pages/Workflows/WorkflowEditViews.tsx create mode 100644 frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx create mode 100644 frontend/src/app/pages/Workflows/scheduleUtils.ts create mode 100644 frontend/src/shared/state/workflowsSlice.ts diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py index 22ede85c..56bc7fc6 100644 --- a/backend/apps/dashboards/models.py +++ b/backend/apps/dashboards/models.py @@ -52,10 +52,28 @@ class NotePosition(BaseModel): color: str = "yellow" +class WorkflowCardPosition(BaseModel): + workflow_id: str + x: float = 0 + y: float = 0 + width: float = 440 + height: float = 520 + source_session_id: Optional[str] = None + + +class WorkflowsHubPosition(BaseModel): + x: float = 0 + y: float = 0 + width: float = 1200 + height: float = 640 + + class DashboardLayout(BaseModel): cards: dict[str, CardPosition] = Field(default_factory=dict) view_cards: dict[str, ViewCardPosition] = Field(default_factory=dict) browser_cards: dict[str, BrowserCardPosition] = Field(default_factory=dict) + workflow_cards: dict[str, WorkflowCardPosition] = Field(default_factory=dict) + workflows_hub: Optional[WorkflowsHubPosition] = None notes: dict[str, NotePosition] = Field(default_factory=dict) expanded_session_ids: list[str] = Field(default_factory=list) diff --git a/backend/apps/workflows/__init__.py b/backend/apps/workflows/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/workflows/executor.py b/backend/apps/workflows/executor.py new file mode 100644 index 00000000..255a8cdd --- /dev/null +++ b/backend/apps/workflows/executor.py @@ -0,0 +1,173 @@ +"""Run a workflow by launching an agent session and feeding it the steps. + +The executor is intentionally thin: it leans entirely on agent_manager's +existing launch + send_message path so a scheduled run looks identical to +a manual chat. That keeps the MCP gate, action filtering, provider +routing, retries, and history all aligned with the rest of the app. +""" + +import asyncio +import logging +from datetime import datetime +from typing import Optional + +from backend.apps.agents.models import AgentConfig +from backend.apps.workflows.models import Workflow, WorkflowRun +from backend.apps.workflows import storage + +logger = logging.getLogger(__name__) + + +# In-process map: workflow_id -> currently running run id. Prevents two +# overlapping fires for the same workflow (e.g. cron tick races a manual +# Run button) without serializing across the whole executor. +_running: dict[str, str] = {} +_running_lock = asyncio.Lock() + + +def _resolve_system_prompt(wf: Workflow) -> Optional[str]: + if wf.use_synced_prompt: + return None + return wf.system_prompt or None + + +def _resolve_allowed_tools(wf: Workflow) -> list[str]: + if not wf.actions.freeze: + return [] + return list(wf.actions.configured_sets) + + +async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: Optional[datetime] = None) -> WorkflowRun: + from backend.apps.agents.agent_manager import agent_manager + + run = WorkflowRun( + workflow_id=wf.id, + status="running", + scheduled_for=scheduled_for, + started_at=datetime.now(), + triggered_by=triggered_by, + ) + storage.record_run(run) + + async with _running_lock: + if wf.id in _running: + run.status = "skipped" + run.error = "Previous run still active" + run.finished_at = datetime.now() + storage.record_run(run) + return run + _running[wf.id] = run.id + + wf.last_run_at = run.started_at + wf.last_run_status = "running" + wf.last_run_id = run.id + storage.save_workflow(wf) + + try: + steps = [s.text for s in wf.steps if s.text and s.text.strip()] + if not steps: + raise ValueError("Workflow has no steps") + + config = AgentConfig( + name=wf.title or "Workflow", + model=wf.model or "sonnet", + mode=wf.mode or "agent", + provider=wf.provider or "anthropic", + system_prompt=_resolve_system_prompt(wf), + allowed_tools=_resolve_allowed_tools(wf) or [ + "Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion", + ], + dashboard_id=wf.dashboard_id, + ) + + session = await agent_manager.launch_agent(config) + run.session_id = session.id + storage.record_run(run) + + # Send each step sequentially. agent_manager.send_message is a no-op + # while a prior turn is still streaming, so we await until the + # session is idle before posting the next step. Keeps the runner + # safe regardless of how long each turn takes. + step_error: Optional[str] = None + for step in steps: + await agent_manager.send_message(session.id, step) + await _await_session_idle(session.id) + sess_state = agent_manager.sessions.get(session.id) + if sess_state is not None and getattr(sess_state, "status", None) == "error": + step_error = "Agent session entered error state" + break + + run.finished_at = datetime.now() + sess_state = agent_manager.sessions.get(session.id) + if sess_state is not None: + run.cost_usd = float(getattr(sess_state, "cost_usd", 0.0) or 0.0) + + if step_error is not None: + run.status = "failure" + run.error = step_error + wf.last_run_status = "failure" + elif scheduled_for is not None and (run.finished_at - scheduled_for).total_seconds() > 300: + # Started more than 5 minutes after its slot (app was closed, + # event loop backed up, etc.). Surface in History as ran_late + # so the user can tell apart "fired on time" from "caught up". + run.status = "ran_late" + wf.last_run_status = "ran_late" + else: + run.status = "success" + wf.last_run_status = "success" + storage.record_run(run) + wf.last_run_at = run.finished_at + storage.save_workflow(wf) + except Exception as e: + logger.exception("Workflow run failed: %s", e) + run.status = "failure" + run.error = str(e)[:500] + run.finished_at = datetime.now() + storage.record_run(run) + wf.last_run_status = "failure" + storage.save_workflow(wf) + finally: + async with _running_lock: + _running.pop(wf.id, None) + + try: + from backend.apps.workflows.notifier import notify_run_complete + await notify_run_complete(wf, run) + except Exception: + logger.debug("notifier failed", exc_info=True) + + try: + from backend.apps.agents.ws_manager import ws_manager + await ws_manager.broadcast_global("workflow:run", { + "workflow_id": wf.id, + "run": run.model_dump(mode="json"), + }) + except Exception: + pass + + return run + + +async def _await_session_idle(session_id: str, timeout_s: float = 600.0) -> None: + """Block until the agent session reaches a non-running terminal state. + + Polls cheaply (50ms) since the agent_manager doesn't expose a per-session + completion future. Bounded by timeout_s so a stuck step doesn't hang the + runner forever. + """ + from backend.apps.agents.agent_manager import agent_manager + + deadline = asyncio.get_event_loop().time() + timeout_s + while True: + sess = agent_manager.sessions.get(session_id) + if not sess: + return + task = agent_manager.tasks.get(session_id) + if task is not None and task.done(): + return + status = getattr(sess, "status", None) + if status in ("completed", "error", "stopped"): + return + if asyncio.get_event_loop().time() > deadline: + raise TimeoutError(f"Step exceeded {timeout_s}s on session {session_id}") + await asyncio.sleep(0.05) diff --git a/backend/apps/workflows/models.py b/backend/apps/workflows/models.py new file mode 100644 index 00000000..4f6e2aa9 --- /dev/null +++ b/backend/apps/workflows/models.py @@ -0,0 +1,114 @@ +from pydantic import BaseModel, ConfigDict, Field +from typing import Optional, Literal, Any +from datetime import datetime +from uuid import uuid4 + + +# Each "tier" in the permission chain: notify in app, fall through to text +# after N minutes if no response, then to call after a further N minutes/hours. +# Matches images 17 to 19 (Schedule edit). Order in the list = escalation order. +class PermissionTier(BaseModel): + kind: Literal["notify", "text", "call"] = "notify" + after_minutes: int = 0 + phone: Optional[str] = None + + +class ScheduleConfig(BaseModel): + enabled: bool = False + repeat_every: int = 1 + repeat_unit: Literal["day", "week", "month"] = "week" + on_days: list[int] = Field(default_factory=list) + hour: int = 9 + minute: int = 0 + timezone: str = "local" + on_missed: Literal["skip", "run_once", "run_all"] = "skip" + + +class ActionsConfig(BaseModel): + prevent_unused: bool = False + freeze: bool = False + configured_sets: list[str] = Field(default_factory=list) + + +class WorkflowStep(BaseModel): + id: str = Field(default_factory=lambda: uuid4().hex) + text: str = "" + + +class Workflow(BaseModel): + # validate_assignment is load-bearing for the PATCH /workflows/{id} path + # (workflows.py:update_workflow setattr's raw dicts from body.model_dump + # straight onto the cached Workflow). Without coercion the nested + # schedule/steps/actions/permissions fields become plain dicts in + # memory, and every downstream call — scheduler tick, executor.execute, + # subsequent PATCHes — crashes on `.enabled` / `.text`. + model_config = ConfigDict(validate_assignment=True) + + id: str = Field(default_factory=lambda: uuid4().hex) + title: str = "Untitled workflow" + description: str = "" + icon: str = "" + system_prompt: Optional[str] = None + use_synced_prompt: bool = True + steps: list[WorkflowStep] = Field(default_factory=list) + actions: ActionsConfig = Field(default_factory=ActionsConfig) + schedule: ScheduleConfig = Field(default_factory=ScheduleConfig) + permissions: list[PermissionTier] = Field( + default_factory=lambda: [PermissionTier(kind="notify")] + ) + source_session_id: Optional[str] = None + dashboard_id: Optional[str] = None + model: str = "sonnet" + mode: str = "agent" + provider: str = "anthropic" + created_at: datetime = Field(default_factory=datetime.now) + updated_at: datetime = Field(default_factory=datetime.now) + last_run_at: Optional[datetime] = None + last_run_status: Optional[Literal["success", "failure", "ran_late", "running"]] = None + last_run_id: Optional[str] = None + next_run_at: Optional[datetime] = None + + +class WorkflowRun(BaseModel): + id: str = Field(default_factory=lambda: uuid4().hex) + workflow_id: str + status: Literal["running", "success", "failure", "ran_late", "skipped"] = "running" + scheduled_for: Optional[datetime] = None + started_at: datetime = Field(default_factory=datetime.now) + finished_at: Optional[datetime] = None + session_id: Optional[str] = None + error: Optional[str] = None + cost_usd: float = 0.0 + triggered_by: Literal["schedule", "manual", "retry"] = "schedule" + + +class WorkflowCreate(BaseModel): + title: str = "Untitled workflow" + description: str = "" + icon: str = "" + system_prompt: Optional[str] = None + use_synced_prompt: bool = True + steps: list[WorkflowStep] = Field(default_factory=list) + actions: ActionsConfig = Field(default_factory=ActionsConfig) + schedule: ScheduleConfig = Field(default_factory=ScheduleConfig) + permissions: Optional[list[PermissionTier]] = None + source_session_id: Optional[str] = None + dashboard_id: Optional[str] = None + model: Optional[str] = None + mode: Optional[str] = None + provider: Optional[str] = None + + +class WorkflowUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + icon: Optional[str] = None + system_prompt: Optional[str] = None + use_synced_prompt: Optional[bool] = None + steps: Optional[list[WorkflowStep]] = None + actions: Optional[ActionsConfig] = None + schedule: Optional[ScheduleConfig] = None + permissions: Optional[list[PermissionTier]] = None + model: Optional[str] = None + mode: Optional[str] = None + provider: Optional[str] = None diff --git a/backend/apps/workflows/notifier.py b/backend/apps/workflows/notifier.py new file mode 100644 index 00000000..03972c70 --- /dev/null +++ b/backend/apps/workflows/notifier.py @@ -0,0 +1,43 @@ +"""Permission/escalation chain notifier. + +Today we only emit the in-app notify tier (via ws broadcast). The text/call +tiers are wired into the schema and exposed in the UI so the permission +chain is editable today; the actual SMS/voice integration ships with the +cloud-side affiliate billing infra and is intentionally stubbed here. +""" + +import asyncio +import logging +from datetime import datetime + +from backend.apps.workflows.models import Workflow, WorkflowRun + +logger = logging.getLogger(__name__) + + +async def notify_run_complete(wf: Workflow, run: WorkflowRun) -> None: + from backend.apps.agents.ws_manager import ws_manager + + primary = (wf.permissions or [None])[0] + kind = getattr(primary, "kind", "notify") if primary else "notify" + + payload = { + "workflow_id": wf.id, + "workflow_title": wf.title, + "run_id": run.id, + "status": run.status, + "session_id": run.session_id, + "started_at": run.started_at.isoformat() if isinstance(run.started_at, datetime) else run.started_at, + "finished_at": run.finished_at.isoformat() if isinstance(run.finished_at, datetime) else run.finished_at, + } + + if kind == "notify": + await ws_manager.broadcast_global("workflow:notify", payload) + return + + # text/call tiers stubbed; emit the same notify event so the UI still + # surfaces completion. Escalation timing is enforced client-side until + # the cloud-side bridge ships. + await ws_manager.broadcast_global("workflow:notify", payload) + logger.info("workflow:notify (escalation tier=%s stubbed): %s", kind, wf.id) + await asyncio.sleep(0) diff --git a/backend/apps/workflows/scheduler.py b/backend/apps/workflows/scheduler.py new file mode 100644 index 00000000..a0b429b1 --- /dev/null +++ b/backend/apps/workflows/scheduler.py @@ -0,0 +1,199 @@ +"""In-process cron-style scheduler. + +One long-lived asyncio task wakes on the next-due workflow boundary, fires +matching workflows, then re-computes. We deliberately avoid one-task-per- +workflow (turns rescheduling into a thundering re-spawn problem). On +startup we walk persisted workflows once, decide what to do about missed +fires via on_missed, and queue each. + +Schedule semantics: + unit=day: fires every repeat_every days at hour:minute + unit=week: fires on the listed weekday(s) every repeat_every weeks + unit=month: fires on the original day-of-month every repeat_every months + +Local clock only. We avoid timezone math here; users see all calendars in +their machine local time, which matches the in-app calendar in the images. +""" + +import asyncio +import logging +from datetime import datetime, timedelta +from typing import Optional + +from backend.apps.workflows.models import Workflow, ScheduleConfig +from backend.apps.workflows import storage, executor + +logger = logging.getLogger(__name__) + + +_loop_task: Optional[asyncio.Task] = None +_wake = asyncio.Event() + + +def _next_fire_after(sched: ScheduleConfig, ref: datetime) -> Optional[datetime]: + if not sched.enabled: + return None + base = ref.replace(second=0, microsecond=0) + candidate = base.replace(hour=sched.hour, minute=sched.minute) + if candidate <= ref: + candidate = candidate + timedelta(days=1) + + if sched.repeat_unit == "day": + step = max(1, sched.repeat_every) + # Walk forward in step-day increments until we find a day strictly + # after `ref`. Cheap because step is small. + while candidate <= ref: + candidate = candidate + timedelta(days=step) + return candidate + + if sched.repeat_unit == "week": + # Frontend uses JS getDay() convention (Sun=0..Sat=6). Python's + # datetime.weekday() is Mon=0..Sun=6, so we translate before + # matching. Keep the wire format JS-style so the UI math stays + # trivial and the cron picker stays self-explanatory. + def _js_weekday(d: datetime) -> int: + return (d.weekday() + 1) % 7 + allowed = sched.on_days or [_js_weekday(ref)] + for _ in range(0, 14): + if _js_weekday(candidate) in allowed and candidate > ref: + return candidate + candidate = candidate + timedelta(days=1) + return candidate + + if sched.repeat_unit == "month": + target_day = ref.day + step = max(1, sched.repeat_every) + # Walk month-by-month preserving the original day-of-month when it + # exists (Feb 30 falls back to the month's last day). + c = candidate.replace(day=min(target_day, 28)) + while c <= ref: + month = c.month + step + year = c.year + (month - 1) // 12 + month = ((month - 1) % 12) + 1 + c = c.replace(year=year, month=month) + return c + + return None + + +def compute_next_fire(wf: Workflow, ref: Optional[datetime] = None) -> Optional[datetime]: + return _next_fire_after(wf.schedule, ref or datetime.now()) + + +def kick() -> None: + _wake.set() + + +async def _tick() -> None: + now = datetime.now() + due: list[Workflow] = [] + for wf in storage.list_workflows(): + if not wf.schedule.enabled: + continue + if wf.next_run_at and wf.next_run_at <= now: + due.append(wf) + + for wf in due: + scheduled_for = wf.next_run_at + nxt = compute_next_fire(wf, now) + wf.next_run_at = nxt + storage.save_workflow(wf) + asyncio.create_task(_fire(wf, scheduled_for=scheduled_for)) + + +async def _fire(wf: Workflow, scheduled_for: Optional[datetime]) -> None: + try: + await executor.execute(wf, triggered_by="schedule", scheduled_for=scheduled_for) + except Exception: + logger.exception("scheduler fire failed for workflow=%s", wf.id) + + +def _seconds_until_next() -> float: + now = datetime.now() + soonest: Optional[datetime] = None + for wf in storage.list_workflows(): + if not wf.schedule.enabled or not wf.next_run_at: + continue + if soonest is None or wf.next_run_at < soonest: + soonest = wf.next_run_at + if soonest is None: + return 60.0 + delta = (soonest - now).total_seconds() + return max(1.0, min(delta, 60.0)) + + +async def _loop() -> None: + logger.info("workflow scheduler loop started") + while True: + try: + await _tick() + except Exception: + logger.exception("scheduler tick error") + try: + await asyncio.wait_for(_wake.wait(), timeout=_seconds_until_next()) + except asyncio.TimeoutError: + pass + _wake.clear() + + +def _mark_stuck_runs_failed() -> None: + """Any run marked 'running' that survives a backend restart is dead. + + The owning event loop is gone, so there's no way to resume. Mark it + failed once at startup instead of letting the History tab show a + forever-spinning row that misleads the user. + """ + now = datetime.now() + for wf in storage.list_workflows(): + for r in storage.list_runs(wf.id, limit=200): + if r.status == "running": + storage.update_run(r.id, status="failure", error="Killed by restart", finished_at=now) + + +def reconcile_on_startup() -> None: + """Walk persisted workflows once and resolve missed fires per policy. + + Missed-run policies: + skip -> roll forward to next future fire, ignore missed + run_once -> if any fires were missed, schedule a single catch-up at now + run_all -> not actually run_all in v1 (would burn tokens); same as run_once + but we mark the run.status as ran_late so the UI surfaces it + """ + now = datetime.now() + for wf in storage.list_workflows(): + if not wf.schedule.enabled: + wf.next_run_at = None + storage.save_workflow(wf) + continue + + missed = bool(wf.next_run_at and wf.next_run_at <= now) + if missed and wf.schedule.on_missed in ("run_once", "run_all"): + # Leave next_run_at <= now so the very next tick fires it. The + # executor records the run with started_at=now; the UI badges + # it ran_late if scheduled_for is more than a few minutes + # behind started_at. + pass + else: + wf.next_run_at = compute_next_fire(wf, now) + storage.save_workflow(wf) + + +async def start() -> None: + global _loop_task + if _loop_task is not None: + return + _mark_stuck_runs_failed() + reconcile_on_startup() + _loop_task = asyncio.create_task(_loop()) + + +async def stop() -> None: + global _loop_task + if _loop_task is None: + return + _loop_task.cancel() + try: + await _loop_task + except (asyncio.CancelledError, Exception): + pass + _loop_task = None diff --git a/backend/apps/workflows/storage.py b/backend/apps/workflows/storage.py new file mode 100644 index 00000000..e94a2cf1 --- /dev/null +++ b/backend/apps/workflows/storage.py @@ -0,0 +1,153 @@ +"""On-disk store for workflows + workflow runs. + +Layout under DATA_ROOT/workflows/: + .json workflow record + runs/.json bounded log (latest N) of runs for that workflow + +A separate runs file per workflow keeps history reads O(history size) instead +of O(total runs across all workflows). The workflow record only carries +last_run_* / next_run_at summary fields; full history lives in the runs file. +""" + +import json +import os +from threading import Lock +from typing import Optional + +from backend.config.paths import DATA_ROOT +from backend.apps.workflows.models import Workflow, WorkflowRun + +DATA_DIR = os.path.join(DATA_ROOT, "workflows") +RUNS_DIR = os.path.join(DATA_DIR, "runs") + +_io_lock = Lock() +_workflow_cache: dict[str, Workflow] = {} +_runs_cache: dict[str, list[WorkflowRun]] = {} +_cache_loaded = False + +# Keep this much run history per workflow on disk. Older runs are pruned; +# the History tab caps at ~20 anyway, and unbounded growth turned the JSON +# read into a real cost on hot-reload of the schedule page. +RUNS_PER_WORKFLOW = 200 + + +def _ensure_dirs() -> None: + os.makedirs(DATA_DIR, exist_ok=True) + os.makedirs(RUNS_DIR, exist_ok=True) + + +def _wf_path(wid: str) -> str: + return os.path.join(DATA_DIR, f"{wid}.json") + + +def _runs_path(wid: str) -> str: + return os.path.join(RUNS_DIR, f"{wid}.json") + + +def _load_all_from_disk() -> None: + global _cache_loaded + _ensure_dirs() + _workflow_cache.clear() + _runs_cache.clear() + for fname in os.listdir(DATA_DIR): + if not fname.endswith(".json"): + continue + try: + with open(os.path.join(DATA_DIR, fname)) as f: + wf = Workflow(**json.load(f)) + _workflow_cache[wf.id] = wf + except Exception: + continue + if os.path.exists(RUNS_DIR): + for fname in os.listdir(RUNS_DIR): + if not fname.endswith(".json"): + continue + wid = fname[:-5] + try: + with open(os.path.join(RUNS_DIR, fname)) as f: + arr = json.load(f) + _runs_cache[wid] = [WorkflowRun(**r) for r in arr] + except Exception: + _runs_cache[wid] = [] + _cache_loaded = True + + +def init() -> None: + with _io_lock: + _load_all_from_disk() + + +def list_workflows() -> list[Workflow]: + if not _cache_loaded: + init() + return list(_workflow_cache.values()) + + +def get_workflow(wid: str) -> Optional[Workflow]: + if not _cache_loaded: + init() + return _workflow_cache.get(wid) + + +def save_workflow(wf: Workflow) -> Workflow: + with _io_lock: + _ensure_dirs() + _workflow_cache[wf.id] = wf + with open(_wf_path(wf.id), "w") as f: + json.dump(wf.model_dump(mode="json"), f, indent=2) + return wf + + +def delete_workflow(wid: str) -> bool: + with _io_lock: + existed = wid in _workflow_cache + _workflow_cache.pop(wid, None) + _runs_cache.pop(wid, None) + wf_file = _wf_path(wid) + if os.path.exists(wf_file): + os.remove(wf_file) + rf = _runs_path(wid) + if os.path.exists(rf): + os.remove(rf) + return existed + + +def list_runs(wid: str, limit: int = 50) -> list[WorkflowRun]: + if not _cache_loaded: + init() + runs = _runs_cache.get(wid, []) + return runs[-limit:][::-1] + + +def record_run(run: WorkflowRun) -> WorkflowRun: + with _io_lock: + _ensure_dirs() + arr = _runs_cache.setdefault(run.workflow_id, []) + # Replace prior entry with same id if we're updating an in-flight run. + for i, prior in enumerate(arr): + if prior.id == run.id: + arr[i] = run + break + else: + arr.append(run) + # Bound the per-workflow history to keep disk + memory cheap. + if len(arr) > RUNS_PER_WORKFLOW: + del arr[: len(arr) - RUNS_PER_WORKFLOW] + with open(_runs_path(run.workflow_id), "w") as f: + json.dump([r.model_dump(mode="json") for r in arr], f, indent=2) + return run + + +def update_run(run_id: str, **fields) -> Optional[WorkflowRun]: + if not _cache_loaded: + init() + for arr in _runs_cache.values(): + for i, r in enumerate(arr): + if r.id == run_id: + updated = r.model_copy(update=fields) + arr[i] = updated + with _io_lock: + with open(_runs_path(updated.workflow_id), "w") as f: + json.dump([x.model_dump(mode="json") for x in arr], f, indent=2) + return updated + return None diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py new file mode 100644 index 00000000..8c23a3db --- /dev/null +++ b/backend/apps/workflows/workflows.py @@ -0,0 +1,148 @@ +import asyncio +import logging +from contextlib import asynccontextmanager +from datetime import datetime +from typing import Optional + +from fastapi import HTTPException + +from backend.config.Apps import SubApp +from backend.apps.workflows.models import ( + Workflow, + WorkflowCreate, + WorkflowUpdate, + WorkflowRun, +) +from backend.apps.workflows import storage, scheduler, executor + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def workflows_lifespan(): + storage.init() + await scheduler.start() + try: + yield + finally: + await scheduler.stop() + + +workflows = SubApp("workflows", workflows_lifespan) + + +def _derive_icon(wf: Workflow) -> str: + """Cheap icon hint used until proper auto-icon generation lands. + + Pull the first emoji from the title, falling back to the first + letter. Keeps the Search list (image 2 annotation) populated without + waiting on the LLM-based icon generator. + """ + title = (wf.title or "").strip() + for ch in title: + if ord(ch) > 0x2700: + return ch + if title: + return title[:1].upper() + return "W" + + +@workflows.router.get("/list") +async def list_workflows(dashboard_id: Optional[str] = None): + items = storage.list_workflows() + if dashboard_id: + items = [w for w in items if not w.dashboard_id or w.dashboard_id == dashboard_id] + items.sort(key=lambda w: w.updated_at or w.created_at, reverse=True) + return {"workflows": [w.model_dump(mode="json") for w in items]} + + +@workflows.router.post("/create") +async def create_workflow(body: WorkflowCreate): + wf = Workflow( + title=body.title, + description=body.description, + icon=body.icon, + system_prompt=body.system_prompt, + use_synced_prompt=body.use_synced_prompt, + steps=body.steps, + actions=body.actions, + schedule=body.schedule, + permissions=body.permissions or [], + source_session_id=body.source_session_id, + dashboard_id=body.dashboard_id, + model=body.model or "sonnet", + mode=body.mode or "agent", + provider=body.provider or "anthropic", + ) + if not wf.icon: + wf.icon = _derive_icon(wf) + if wf.schedule.enabled: + wf.next_run_at = scheduler.compute_next_fire(wf) + storage.save_workflow(wf) + scheduler.kick() + return wf.model_dump(mode="json") + + +@workflows.router.get("/{workflow_id}") +async def get_workflow(workflow_id: str): + wf = storage.get_workflow(workflow_id) + if not wf: + raise HTTPException(status_code=404, detail="Workflow not found") + return wf.model_dump(mode="json") + + +@workflows.router.patch("/{workflow_id}") +async def update_workflow(workflow_id: str, body: WorkflowUpdate): + wf = storage.get_workflow(workflow_id) + if not wf: + raise HTTPException(status_code=404, detail="Workflow not found") + data = body.model_dump(exclude_unset=True) + for k, v in data.items(): + setattr(wf, k, v) + wf.updated_at = datetime.now() + if not wf.icon: + wf.icon = _derive_icon(wf) + wf.next_run_at = scheduler.compute_next_fire(wf) if wf.schedule.enabled else None + storage.save_workflow(wf) + scheduler.kick() + return wf.model_dump(mode="json") + + +@workflows.router.delete("/{workflow_id}") +async def delete_workflow(workflow_id: str): + existed = storage.delete_workflow(workflow_id) + if not existed: + raise HTTPException(status_code=404, detail="Workflow not found") + scheduler.kick() + return {"ok": True} + + +@workflows.router.post("/{workflow_id}/run") +async def run_workflow_now(workflow_id: str): + wf = storage.get_workflow(workflow_id) + if not wf: + raise HTTPException(status_code=404, detail="Workflow not found") + # executor.execute() owns the run record. Don't pre-create a stub here + # or we end up with two rows per manual fire (one orphan "running" + # row from this handler plus the real one from the executor). + pre_ids = {r.id for r in storage.list_runs(wf.id, limit=10)} + asyncio.create_task(executor.execute(wf, triggered_by="manual")) + + # Poll briefly for the newly created run id (anything not already in + # the pre-fire snapshot). Falls back to empty if the executor hasn't + # written within 250ms — frontend reconciles via WS afterwards. + for _ in range(25): + for r in storage.list_runs(wf.id, limit=10): + if r.id not in pre_ids and r.triggered_by == "manual": + return {"run_id": r.id} + await asyncio.sleep(0.01) + return {"run_id": ""} + + +@workflows.router.get("/{workflow_id}/runs") +async def list_workflow_runs(workflow_id: str, limit: int = 50): + wf = storage.get_workflow(workflow_id) + if not wf: + raise HTTPException(status_code=404, detail="Workflow not found") + runs = storage.list_runs(workflow_id, limit=limit) + return {"runs": [r.model_dump(mode="json") for r in runs]} diff --git a/backend/main.py b/backend/main.py index 1e9e643b..65efbb0d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -44,11 +44,12 @@ from backend.apps.subscription.router import subscription from backend.apps.auth.router import auth from backend.apps.web.web import web from backend.apps.agents.anthropic_proxy import anthropic_proxy +from backend.apps.workflows.workflows import workflows from fastapi.middleware.cors import CORSMiddleware from fastapi import WebSocket, WebSocketDisconnect import json -main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, service, subscription, auth, web, anthropic_proxy]) +main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, service, subscription, auth, web, anthropic_proxy, workflows]) app = main_app.app # Generate per-install auth token BEFORE we bind the HTTP port. By the diff --git a/frontend/src/app/pages/Dashboard/AgentCard.tsx b/frontend/src/app/pages/Dashboard/AgentCard.tsx index 3fdca4c6..965939ad 100644 --- a/frontend/src/app/pages/Dashboard/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/AgentCard.tsx @@ -34,6 +34,32 @@ import { useDashboardActive } from '@/shared/hooks/useDashboardActive'; import { useOverlayScrollPassthrough } from './useOverlayScrollPassthrough'; import { useStreamingMessage } from '@/shared/state/streamingSlice'; import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canvasInteractionState'; +import { openWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice'; +import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice'; +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesomeOutlined'; + +// Extract up to 3 user-prompt steps from a completed chat to seed a workflow. +// Skips trivial messages (one-liners, single-word replies) so the preview +// card matches the substantive steps the agent actually executed. +function extractStepsFromSession(session: { messages: Array<{ role: string; content: unknown; hidden?: boolean }> }): Array<{ id: string; text: string }> { + const out: Array<{ id: string; text: string }> = []; + for (const msg of session.messages || []) { + if (msg.role !== 'user' || msg.hidden) continue; + const text = typeof msg.content === 'string' ? msg.content : (Array.isArray(msg.content) ? msg.content.map((b: any) => (typeof b === 'string' ? b : b?.text || '')).join(' ') : ''); + const trimmed = text.trim(); + if (trimmed.length < 6) continue; + out.push({ id: `step-${out.length + 1}-${Date.now().toString(36)}`, text: trimmed.slice(0, 400) }); + if (out.length === 3) break; + } + if (out.length === 0 && session.messages?.length) { + const fallback = session.messages.find((m) => m.role === 'user'); + if (fallback) { + const text = typeof fallback.content === 'string' ? fallback.content : ''; + out.push({ id: `step-1-${Date.now().toString(36)}`, text: text.slice(0, 400) || 'Run the original task' }); + } + } + return out; +} // --------------------------------------------------------------------------- // Helper components & functions (unchanged) @@ -992,6 +1018,54 @@ const AgentCard: React.FC = ({ onPointerDown={(e) => e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }} > + {(session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2 && ( + + { + e.stopPropagation(); + const steps = extractStepsFromSession(session); + if (steps.length === 0) return; + const draft: Partial = { + title: session.name || 'New workflow', + description: 'Auto-generated from this chat. Edit anytime in Workflows.', + steps, + source_session_id: session.id, + dashboard_id: session.dashboard_id || null, + model: session.model, + mode: session.mode, + provider: session.provider, + }; + const tempId = `draft-${session.id}`; + dispatch(addWorkflowCard({ + workflowId: tempId, + sourceSessionId: session.id, + })); + dispatch(openWorkflowCard({ + workflowId: tempId, + sourceSessionId: session.id, + view: 'preview', + draft, + })); + }} + onMouseDown={(e) => e.stopPropagation()} + sx={{ + display: 'inline-flex', alignItems: 'center', gap: 0.5, + color: c.accent.primary, + bgcolor: c.accent.primary + '12', + border: `1px solid ${c.accent.primary}40`, + fontSize: '0.78rem', fontWeight: 600, + px: 1, py: 0.45, + borderRadius: `${c.radius.md}px`, + cursor: 'pointer', + '&:hover': { bgcolor: c.accent.primary + '22' }, + }} + > + + Make workflow + + + )} = ({ dashboardId, isActive = true const cards = useAppSelector((state) => state.dashboardLayout.cards); const viewCards = useAppSelector((state) => state.dashboardLayout.viewCards); const browserCards = useAppSelector((state) => state.dashboardLayout.browserCards); + const workflowCards = useAppSelector((state) => state.dashboardLayout.workflowCards); + const workflowsHub = useAppSelector((state) => state.dashboardLayout.workflowsHub); const notes = useAppSelector((state) => state.dashboardLayout.notes); const pendingFocusNoteId = useAppSelector((state) => state.dashboardLayout.pendingFocusNoteId); const layoutInitialized = useAppSelector((state) => state.dashboardLayout.initialized); @@ -133,6 +141,8 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true ...Object.values(cards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })), ...Object.values(viewCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })), ...Object.values(browserCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })), + ...Object.values(workflowCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })), + ...(workflowsHub ? [{ x: workflowsHub.x, y: workflowsHub.y, w: workflowsHub.width, h: workflowsHub.height }] : []), ]; if (allRects.length === 0) return undefined; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; @@ -143,7 +153,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true maxY = Math.max(maxY, r.y + r.h); } return { minX, minY, maxX, maxY }; - }, [cards, viewCards, browserCards]); + }, [cards, viewCards, browserCards, workflowCards, workflowsHub]); const canvas = useCanvasControls(zoomSensitivity, contentBounds, isActive); const selection = useDashboardSelection( @@ -152,6 +162,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true viewCards, browserCards, notes, + workflowCards, ); const toolbarRef = useRef(null); @@ -341,6 +352,10 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true const n = layoutState.notes[id]; if (!n) return undefined; return { x: n.x, y: n.y, width: n.width, height: n.height }; + } else if (type === 'workflow') { + const wc = layoutState.workflowCards[id]; + if (!wc) return undefined; + return { x: wc.x, y: wc.y, width: wc.width, height: wc.height }; } return undefined; }, []); @@ -495,11 +510,13 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true ? (window as any).requestIdleCallback(() => { dispatch(fetchHistory({ dashboardId })); dispatch(fetchOutputs()); + dispatch(fetchWorkflows(dashboardId)); dashboardWs.connect(); }, { timeout: 2000 }) : window.setTimeout(() => { dispatch(fetchHistory({ dashboardId })); dispatch(fetchOutputs()); + dispatch(fetchWorkflows(dashboardId)); dashboardWs.connect(); }, 200); @@ -552,6 +569,8 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true const pendingBrowserUrl = useAppSelector((state) => state.tempState.pendingBrowserUrl); const pendingFocusAgentId = useAppSelector((state) => state.tempState.pendingFocusAgentId); const pendingFocusBrowserId = useAppSelector((state) => state.dashboardLayout.pendingFocusBrowserId); + const pendingFocusWorkflowId = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowId); + const pendingFocusWorkflowsHub = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowsHub); useEffect(() => { if (!dashboardId) return; @@ -673,6 +692,53 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true }, 200); }, [isActive, pendingFocusBrowserId, layoutInitialized, dispatch, canvas.actions, handleHighlightCard]); + // Same pan/highlight choreography for newly-spawned workflow cards + // ("Make workflow", "+ New workflow", or list-picker → canvas). + useEffect(() => { + if (!isActive) return; + if (!pendingFocusWorkflowId || !layoutInitialized) return; + const workflowId = pendingFocusWorkflowId; + dispatch(clearPendingFocusWorkflowId()); + setTimeout(() => { + const card = store.getState().dashboardLayout.workflowCards[workflowId]; + if (card) { + canvas.actions.fitToCards( + [{ x: card.x, y: card.y, width: card.width, height: card.height }], + 1.15, + true, + ); + handleHighlightCard(workflowId); + } + }, 200); + }, [isActive, pendingFocusWorkflowId, layoutInitialized, dispatch, canvas.actions, handleHighlightCard]); + + // Pan/zoom to the Workflows Hub singleton when Expand is clicked. Without + // this the hub spawns at an open grid cell, which can be far from the + // current viewport — making the click look like a no-op. + // + // We chain rAFs so the fit runs after Dashboard's next render has actually + // committed the hub
with its new coordinates. A bare setTimeout(100) + // raced the layout on slower mounts. + useEffect(() => { + if (!isActive) return; + if (!pendingFocusWorkflowsHub || !layoutInitialized) return; + dispatch(clearPendingFocusWorkflowsHub()); + const fit = () => { + const hub = store.getState().dashboardLayout.workflowsHub; + if (!hub) return; + canvas.actions.fitToCards( + [{ x: hub.x, y: hub.y, width: hub.width, height: hub.height }], + 1.1, + true, + ); + }; + // Two rAFs: one for the workflowsHub state to land in the rendered tree, + // one for layout to settle. Then a fallback timeout for slow boots. + requestAnimationFrame(() => requestAnimationFrame(fit)); + const fallback = setTimeout(fit, 300); + return () => clearTimeout(fallback); + }, [isActive, pendingFocusWorkflowsHub, layoutInitialized, dispatch, canvas.actions]); + useEffect(() => { if (!layoutInitialized || restoredExpandedRef.current) return; restoredExpandedRef.current = true; @@ -822,7 +888,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true skipInitialSave.current = false; return; } - const payload = { dashboardId, cards, viewCards, browserCards, notes, expandedSessionIds }; + const payload = { dashboardId, cards, viewCards, browserCards, workflowCards, workflowsHub, notes, expandedSessionIds }; pendingSaveRef.current = payload; if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { @@ -831,7 +897,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true saveTimerRef.current = null; captureNow(); }, 500); - }, [isActive, cards, viewCards, browserCards, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]); + }, [isActive, cards, viewCards, browserCards, workflowCards, workflowsHub, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]); useEffect(() => { return () => { @@ -901,6 +967,10 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true dispatch(removeBrowserCard(id)); } else if (type === 'note') { dispatch(removeNote(id)); + } else if (type === 'workflow') { + dispatch(removeWorkflowCard(id)); + // also drop transient view state so re-opening starts fresh + dispatch(closeWorkflowCard(id)); } } selection.deselectAll(); @@ -1051,6 +1121,9 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true for (const bc of Object.values(browserCards)) { allCardEntries.push({ id: bc.browser_id, type: 'browser', cx: bc.x + bc.width / 2, cy: bc.y + bc.height / 2 }); } + for (const wc of Object.values(workflowCards)) { + allCardEntries.push({ id: wc.workflow_id, type: 'workflow', cx: wc.x + wc.width / 2, cy: wc.y + wc.height / 2 }); + } const current = allCardEntries.find((c) => c.id === currentId); if (!current) return null; @@ -1083,7 +1156,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true } return best ? { id: best.id, type: best.type } : null; - }, [cards, viewCards, browserCards]); + }, [cards, viewCards, browserCards, workflowCards]); // Compute which directions have neighbors from the focused card const neighborDirections = useMemo(() => { @@ -1725,9 +1798,93 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true const browserTethers = Array.from(glowTethers.values()).filter(Boolean) as Array<{ key: string; path: string; labelX: number; labelY: number; label: string; fading: boolean }>; - return [...agentTethers, ...browserTethers]; + // Workflow tethers: every workflow card with a source agent gets a + // persistent "Make workflow" arrow back to the agent it was generated + // from. Reuses the same anchor-picking + elbow-path math as the + // browser tether so visual style stays uniform across card kinds. + const workflowTethers: Array<{ key: string; path: string; labelX: number; labelY: number; label: string; fading: boolean }> = []; + for (const wc of Object.values(workflowCards)) { + const sourceId = wc.source_session_id; + if (!sourceId) continue; + const src = cards[sourceId]; + if (!src) continue; + + let srcX = src.x, srcY = src.y; + let dstX = wc.x, dstY = wc.y; + if (liveDragInfo) { + if (liveDragInfo.cardId === sourceId) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; } + if (liveDragInfo.cardId === wc.workflow_id) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; } + } + + const srcMeasured = measuredHeightsRef.current[sourceId]; + const srcH = srcMeasured ?? (expandedSessionIds.includes(sourceId) + ? Math.max(EXPANDED_CARD_MIN_H, src.height) + : src.height); + + const srcCx = srcX + src.width / 2; + const dstCx = dstX + wc.width / 2; + const srcAnchors: Anchor[] = [ + { x: srcX + src.width, y: srcY + srcH * 0.54, side: 'right' }, + { x: srcX, y: srcY + srcH * 0.54, side: 'left' }, + { x: srcCx, y: srcY, side: 'top' }, + { x: srcCx, y: srcY + srcH, side: 'bottom' }, + ]; + const dstAnchors: Anchor[] = [ + { x: dstX, y: dstY + wc.height * 0.54, side: 'left' }, + { x: dstX + wc.width, y: dstY + wc.height * 0.54, side: 'right' }, + { x: dstCx, y: dstY, side: 'top' }, + { x: dstCx, y: dstY + wc.height, side: 'bottom' }, + ]; + let bestSrc = srcAnchors[0], bestDst = dstAnchors[0]; + let bestDist = Infinity; + for (const sa of srcAnchors) { + for (const da of dstAnchors) { + const d = Math.hypot(sa.x - da.x, sa.y - da.y); + if (d < bestDist) { bestDist = d; bestSrc = sa; bestDst = da; } + } + } + const x1 = bestSrc.x, y1 = bestSrc.y; + const x2 = bestDst.x, y2 = bestDst.y; + const isVertical = (bestSrc.side === 'top' || bestSrc.side === 'bottom') + && (bestDst.side === 'top' || bestDst.side === 'bottom'); + let pathD: string; + if (isVertical) { + const dx = x2 - x1; + const dy = y2 - y1; + const midY = y1 + dy / 2; + const r = (Math.abs(dx) < 1 || Math.abs(dy) < ELBOW_RADIUS * 2) + ? 0 + : Math.min(ELBOW_RADIUS, Math.abs(dx) / 2, Math.abs(dy) / 4); + const sx = dx >= 0 ? 1 : -1; + const sy = dy >= 0 ? 1 : -1; + pathD = [ + `M ${x1},${y1}`, + `V ${midY - sy * r}`, + `Q ${x1},${midY} ${x1 + sx * r},${midY}`, + `H ${x2 - sx * r}`, + `Q ${x2},${midY} ${x2},${midY + sy * r}`, + `V ${y2}`, + ].join(' '); + } else { + pathD = elbowPath(x1, y1, x2, y2); + } + const midX = x1 + (x2 - x1) / 2; + const midY = y1 + (y2 - y1) / 2; + const labelX = isVertical ? midX : midX + (x2 - midX) * 0.15; + const labelY = isVertical ? midY + (y2 - midY) * 0.15 : y2; + workflowTethers.push({ + key: `workflow-${wc.workflow_id}`, + path: pathD, + labelX, + labelY, + label: 'Make workflow', + fading: false, + }); + } + + return [...agentTethers, ...browserTethers, ...workflowTethers]; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [glowingAgentCards, glowingBrowserCards, cards, browserCards, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]); + }, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]); const dotSize = Math.max(1, 1.5 * canvas.zoom); const dotSpacing = 24 * canvas.zoom; @@ -2089,6 +2246,41 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true onBringToFront={handleBringToFront} /> ))} + {workflowsHub && ( + + )} + {Object.values(workflowCards).map((wc) => ( + + ))} {Object.values(notes).map((n) => ( ( const [viewSearch, setViewSearch] = useState(''); const [historyOpen, setHistoryOpen] = useState(false); const [historyQuery, setHistoryQuery] = useState(''); + const [popoverMode, setPopoverMode] = useState<'search' | 'schedule'>('search'); const shortcut = useAppSelector((s) => s.settings.data.new_agent_shortcut); const outputs = useAppSelector((s) => s.outputs.items); const historySearch = useAppSelector((s) => s.agents.historySearch); @@ -390,6 +394,7 @@ const DashboardToolbar = React.forwardRef( const placeholderItems: Array<{ icon: typeof StickyNote2OutlinedIcon; label: string; sub: string }> = []; return ( + <> ( style={{ display: 'flex', flexDirection: 'column', - background: c.bg.surface, - border: `1px solid ${c.border.subtle}`, + // When the schedule popover is open the chips render OUTSIDE the + // popover's own card (Figma image #30). The toolbar wrapper must + // drop its own card chrome in that mode so we don't end up with a + // double-card sandwich; the popover supplies its own surface + + // border + shadow. + background: historyOpen ? 'transparent' : c.bg.surface, + border: historyOpen ? '1px solid transparent' : `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.xl}px`, - boxShadow: c.shadow.lg, + boxShadow: historyOpen ? 'none' : c.shadow.lg, padding: isExpanded ? '6px' : '5px', userSelect: 'none' as const, - overflow: inputOpen || newAgentBounce ? 'visible' : 'hidden', - width: viewPickerOpen ? 580 : isExpanded ? 540 : undefined, + overflow: inputOpen || newAgentBounce || historyOpen ? 'visible' : 'hidden', + // When historyOpen, width is owned by SchedulePopover (POPOVER_W + // constant) so Search and Schedule modes share an identical + // fixed pixel width. Leave width=undefined here so framer-motion + // measures the popover's intrinsic size and animates to it. + width: viewPickerOpen ? 580 : historyOpen ? undefined : isExpanded ? 540 : undefined, }} > {inputOpen ? ( @@ -433,97 +447,33 @@ const DashboardToolbar = React.forwardRef(
) : historyOpen ? (
- - - setHistoryQuery(e.target.value)} - placeholder="Search past chats..." - sx={{ - flex: 1, - fontSize: '0.85rem', - color: c.text.primary, - fontFamily: c.font.sans, - '& input::placeholder': { color: c.text.ghost, opacity: 1 }, - }} - /> - {historySearch.loading && historySearch.results.length === 0 && ( - - )} - - ({ id: e.id, name: e.name, closed_at: e.closed_at }))} + historyLoading={historySearch.loading} + historyQuery={historyQuery} + onHistoryQueryChange={setHistoryQuery} + onHistorySelect={handleHistorySelect} + onNewChat={() => { handleCloseHistory(); onNewAgent(); }} + onWorkflowSelect={(wid) => { + dispatch(addWorkflowCard({ workflowId: wid })); + dispatch(openWorkflowCard({ + workflowId: wid, + view: 'saved', + })); + handleCloseHistory(); }} - > - {historySearch.results.length === 0 && !historySearch.loading ? ( - - - {historyQuery ? 'No matching chats' : 'No chat history yet'} - - - ) : ( - <> - {historySearch.results.map((entry) => ( - handleHistorySelect(entry.id)} - sx={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: 1.5, - px: 1.5, - py: 0.9, - cursor: 'pointer', - transition: 'background-color 0.1s', - '&:hover': { bgcolor: c.bg.elevated }, - }} - > - - {entry.name} - - - {formatRelativeTime(entry.closed_at)} - - - ))} - {historySearch.loading && historySearch.results.length > 0 && ( - - - - )} - - )} - + onExpand={() => { + // Expand → spawn the Workflows Hub as a canvas card and close + // the popover. The hub is a singleton per dashboard so a + // second Expand just brings the existing card forward. + dispatch(openWorkflowsHub({ expandedSessionIds: [] })); + handleCloseHistory(); + }} + historyScrollRef={historyListRef as React.RefObject} + onHistoryScroll={handleHistoryScroll} + />
) : viewPickerOpen ? (
@@ -859,6 +809,7 @@ const DashboardToolbar = React.forwardRef(
)} + ); }, ); diff --git a/frontend/src/app/pages/Dashboard/useDashboardSelection.ts b/frontend/src/app/pages/Dashboard/useDashboardSelection.ts index c89590a0..250b2e11 100644 --- a/frontend/src/app/pages/Dashboard/useDashboardSelection.ts +++ b/frontend/src/app/pages/Dashboard/useDashboardSelection.ts @@ -1,7 +1,7 @@ import { useState, useCallback, useRef, useEffect, RefObject } from 'react'; -import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition } from '@/shared/state/dashboardLayoutSlice'; +import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition, WorkflowCardPosition } from '@/shared/state/dashboardLayoutSlice'; -export type CardType = 'agent' | 'view' | 'browser' | 'note'; +export type CardType = 'agent' | 'view' | 'browser' | 'note' | 'workflow'; export interface SelectedCard { id: string; @@ -42,6 +42,7 @@ export function useDashboardSelection( viewCards: Record, browserCards: Record = {}, notes: Record = {}, + workflowCards: Record = {}, ) { const [selectedIds, setSelectedIds] = useState>(new Map()); const [marquee, setMarquee] = useState(null); @@ -149,6 +150,19 @@ export function useDashboardSelection( } } + for (const wc of Object.values(workflowCards)) { + if ( + rectsIntersect(rect, { + x: wc.x, + y: wc.y, + width: wc.width, + height: wc.height, + }) + ) { + intersecting.set(wc.workflow_id, 'workflow'); + } + } + if (shiftKey) { const base = selectionBeforeMarqueeRef.current; const next = new Map(base); @@ -164,7 +178,7 @@ export function useDashboardSelection( return intersecting; }, - [cards, viewCards, browserCards, notes], + [cards, viewCards, browserCards, notes, workflowCards], ); const handleCanvasMouseDown = useCallback( diff --git a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx new file mode 100644 index 00000000..bdb7a431 --- /dev/null +++ b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx @@ -0,0 +1,212 @@ +import React, { useMemo } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppSelector } from '@/shared/hooks'; +import type { Workflow } from '@/shared/state/workflowsSlice'; +import { WEEKDAY_LABEL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, fireTimesWithin, formatTime, formatHourLabel } from './scheduleUtils'; + +interface Props { + view: 'Week' | 'Month' | 'List'; + density: 'compact' | 'roomy'; + onSelectWorkflow?: (id: string) => void; + refDate?: Date; +} + +// Both compact (popover) and roomy (hub) show the full 24 hours scrollable — +// the user explicitly wants midnight visible at the top, not "9am" as the +// starting hour. The scroll container caps the visible window. +const HOURS_24 = Array.from({ length: 24 }, (_, i) => i); + +export default function ScheduleCalendar({ view, density, onSelectWorkflow, refDate }: Props) { + const c = useClaudeTokens(); + const workflows = useAppSelector((s) => Object.values(s.workflows.items)); + // refDate is recreated on every render unless the caller memoizes it, + // which then trips the eventsByDay memo every paint. Pin the calendar + // to a day-precision key so the heavy fireTimesWithin loop only re-runs + // when the day or workflow set actually changed. + const today = refDate || new Date(); + const dayKey = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`; + const compact = density === 'compact'; + + const eventsByDay = useMemo(() => { + const range = view === 'Month' ? 35 : view === 'Week' ? 7 : 14; + const start = view === 'Month' ? startOfMonthGrid(today) : view === 'Week' ? startOfWeek(today) : today; + const end = addDays(start, range - 1); + const map = new Map(); + for (const wf of workflows) { + if (!wf.schedule.enabled) continue; + const fires = fireTimesWithin(wf, start, end, 60); + for (const d of fires) { + const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; + const arr = map.get(key) || []; + arr.push({ workflow: wf, date: d }); + map.set(key, arr); + } + } + return { map, start, end }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workflows, view, dayKey]); + + const SLOT_H = compact ? 28 : 36; + const ROW_LABEL = compact ? '0.72rem' : '0.78rem'; + const DAY_NUM = compact ? '0.85rem' : '0.95rem'; + const DAY_LABEL = compact ? '0.7rem' : '0.78rem'; + const EVENT_FS = compact ? '0.72rem' : '0.82rem'; + + if (view === 'Week') { + const start = startOfWeek(today); + const days = Array.from({ length: 7 }, (_, i) => addDays(start, i)); + const HOURS = HOURS_24; + const TZ_LABEL = (() => { + try { + const offset = -new Date().getTimezoneOffset() / 60; + return `GMT${offset >= 0 ? '+' : ''}${offset.toString().padStart(2, '0').replace('.', ':')}`; + } catch { return ''; } + })(); + return ( + + {/* Day headers — full names in roomy, single letter in compact */} + + + {!compact && ( + {TZ_LABEL} + )} + + {days.map((d) => { + const isToday = sameDay(d, today); + return ( + + + {WEEKDAY_LABEL_SHORT[d.getDay()]} + + {d.getDate()} + + ); + })} + + + {HOURS.map((hour) => ( + + + {formatHourLabel(hour)} + + {days.map((d) => { + const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; + const evs = (eventsByDay.map.get(key) || []).filter((e) => e.date.getHours() === hour); + return ( + + {evs.map((e) => ( + onSelectWorkflow?.(e.workflow.id)} + sx={{ + position: 'absolute', + left: 3, right: 3, top: 3, bottom: 3, + bgcolor: c.accent.primary + '1f', + color: c.accent.primary, + border: `1px solid ${c.accent.primary}`, + borderRadius: 999, + px: 1.1, py: 0, + fontSize: EVENT_FS, fontWeight: 600, + overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', + cursor: 'pointer', display: 'flex', alignItems: 'center', + '&:hover': { bgcolor: c.accent.primary + '33' }, + }}> + {e.workflow.title} + + ))} + + ); + })} + + ))} + + + ); + } + + if (view === 'Month') { + const start = startOfMonthGrid(today); + const cells = Array.from({ length: 35 }, (_, i) => addDays(start, i)); + return ( + + + {WEEKDAY_LABEL_SHORT.map((l, i) => ( + {l} + ))} + + + {cells.map((d) => { + const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; + const evs = eventsByDay.map.get(key) || []; + const isToday = sameDay(d, today); + const inMonth = d.getMonth() === today.getMonth(); + return ( + + + {d.getDate()} + + {evs.slice(0, compact ? 3 : 5).map((e, idx) => ( + onSelectWorkflow?.(e.workflow.id)} + sx={{ mt: 0.3, display: 'flex', alignItems: 'center', gap: 0.4, fontSize: EVENT_FS, color: c.text.secondary, cursor: 'pointer', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', '&:hover': { color: c.accent.primary } }}> + + + {formatTime(e.date.getHours(), e.date.getMinutes())} {e.workflow.title} + + + ))} + {evs.length > (compact ? 3 : 5) && ( + +{evs.length - (compact ? 3 : 5)} more + )} + + ); + })} + + + ); + } + + const upcoming: { date: Date; events: { workflow: Workflow; date: Date }[] }[] = []; + for (let i = 0; i < 14; i += 1) { + const day = addDays(today, i); + const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`; + const arr = eventsByDay.map.get(key) || []; + if (arr.length) upcoming.push({ date: day, events: arr }); + } + return ( + + {upcoming.length === 0 && ( + No scheduled workflows + )} + {upcoming.map(({ date, events }) => ( + + + {date.getDate()} + {date.toLocaleString('en', { month: 'short' })} + {WEEKDAY_LABEL[date.getDay()]} + + + {events.map((e, idx) => ( + onSelectWorkflow?.(e.workflow.id)} + sx={{ fontSize: '0.85rem', color: c.text.secondary, cursor: 'pointer', '&:hover': { color: c.accent.primary } }}> + {e.workflow.title} + {formatTime(e.date.getHours(), e.date.getMinutes())} + + ))} + + + ))} + + ); +} diff --git a/frontend/src/app/pages/Workflows/SchedulePopover.tsx b/frontend/src/app/pages/Workflows/SchedulePopover.tsx new file mode 100644 index 00000000..63f864f6 --- /dev/null +++ b/frontend/src/app/pages/Workflows/SchedulePopover.tsx @@ -0,0 +1,176 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import InputBase from '@mui/material/InputBase'; +import SearchIcon from '@mui/icons-material/Search'; +import CalendarMonthIcon from '@mui/icons-material/CalendarMonthRounded'; +import OpenInFullIcon from '@mui/icons-material/OpenInFullRounded'; +import AddIcon from '@mui/icons-material/Add'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppSelector } from '@/shared/hooks'; +import ScheduleCalendar from './ScheduleCalendar'; + +type Mode = 'search' | 'schedule'; + +interface Props { + mode: Mode; + onModeChange: (m: Mode) => void; + historyResults: { id: string; name: string; closed_at: string | null }[]; + historyLoading: boolean; + historyQuery: string; + onHistoryQueryChange: (q: string) => void; + onHistorySelect: (id: string) => void; + onNewChat: () => void; + onWorkflowSelect: (id: string) => void; + onExpand: () => void; + historyScrollRef?: React.RefObject; + onHistoryScroll?: () => void; +} + +export default function SchedulePopover({ + mode, onModeChange, historyResults, historyLoading, historyQuery, onHistoryQueryChange, + onHistorySelect, onNewChat, onWorkflowSelect, onExpand, historyScrollRef, onHistoryScroll, +}: Props) { + const c = useClaudeTokens(); + const [calendarView, setCalendarView] = useState<'Week' | 'Month' | 'List'>('Week'); + const workflows = useAppSelector((s) => s.workflows.items); + + const workflowIconMap = useMemo(() => { + const m: Record = {}; + for (const wf of Object.values(workflows)) { + if (wf.source_session_id) m[wf.source_session_id] = wf.icon || wf.title.slice(0, 1).toUpperCase(); + } + return m; + }, [workflows]); + + // Both Search and Schedule modes render at the same fixed dimensions so + // toggling chips doesn't resize the popover. Schedule sets the floor: + // its 7-day calendar needs ~620w x ~420h, search inherits the same. + const POPOVER_W = 620; + const CONTENT_H = 420; + + return ( + + {/* Floating mode chips OUTSIDE the content card (Figma image #30) */} + + } active={mode === 'search'} onClick={() => onModeChange('search')} /> + } active={mode === 'schedule'} onClick={() => onModeChange('schedule')} /> + + + {/* Content card — separately bordered/rounded, like image #30. + Inner content crossfades on tab switch so search↔schedule isn't + a jarring jump. Outer card stays fixed-size (W×H) so the toolbar + doesn't reflow. */} + + + + {mode === 'search' && ( + + + + onHistoryQueryChange(e.target.value)} + placeholder="Search past chats..." + sx={{ flex: 1, fontSize: '0.85rem', color: c.text.primary, '& input::placeholder': { color: c.text.ghost, opacity: 1 } }} + /> + + + New + + + + {historyResults.length === 0 && !historyLoading && ( + {historyQuery ? 'No matching chats' : 'No chat history yet'} + )} + {historyResults.map((entry) => { + const icon = workflowIconMap[entry.id]; + return ( + onHistorySelect(entry.id)} sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.9, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}> + {entry.name} + {icon && ( + {icon} + )} + {relTime(entry.closed_at)} + + ); + })} + + + )} + + {mode === 'schedule' && ( + + + {(['Week', 'Month', 'List'] as const).map((v) => ( + setCalendarView(v)} role="button" sx={{ fontSize: '0.85rem', fontWeight: calendarView === v ? 700 : 500, px: 0.75, pt: 0.4, pb: 0.55, color: calendarView === v ? c.text.primary : c.text.muted, borderBottom: `2px solid ${calendarView === v ? c.accent.primary : 'transparent'}`, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>{v} + ))} + + + + Expand + + + + + + + )} + + + + + ); +} + +// Floating chip rendered ABOVE the popover card (image #30). Active gets a +// subtle filled-elevated bg + 1px border; inactive is borderless ghost. +function ModeChip({ label, icon, active, onClick }: { label: string; icon: React.ReactNode; active: boolean; onClick: () => void }) { + const c = useClaudeTokens(); + return ( + + {icon} + {label} + + ); +} + +function relTime(iso: string | null): string { + if (!iso) return ''; + const sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + if (sec < 60) return 'just now'; + const m = Math.floor(sec / 60); if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} diff --git a/frontend/src/app/pages/Workflows/WorkflowCard.tsx b/frontend/src/app/pages/Workflows/WorkflowCard.tsx new file mode 100644 index 00000000..c75c2b22 --- /dev/null +++ b/frontend/src/app/pages/Workflows/WorkflowCard.tsx @@ -0,0 +1,508 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import CloseIcon from '@mui/icons-material/Close'; +import EditIcon from '@mui/icons-material/EditOutlined'; +import HistoryIcon from '@mui/icons-material/HistoryRounded'; +import PlayArrowIcon from '@mui/icons-material/PlayArrowRounded'; +import ScheduleIcon from '@mui/icons-material/ScheduleRounded'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { + closeWorkflowCard, + fetchRuns, + openWorkflowCard as openWorkflowCardAction, + rekeyOpenCard, + runWorkflowNow, + updateWorkflowCard, + type Workflow, +} from '@/shared/state/workflowsSlice'; +import { + rekeyWorkflowCard, + removeWorkflowCard, + setWorkflowCardPosition, + setWorkflowCardSize, +} from '@/shared/state/dashboardLayoutSlice'; +import { AnimatePresence, motion } from 'framer-motion'; +import WorkflowEditViews from './WorkflowEditViews'; +import { HistoryDetail, HistoryList, PreviewView, SavedView, statusBg, statusColor } from './WorkflowCardSubviews'; + +type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; + +const EDGE_THICKNESS = 6; +const CORNER_SIZE = 14; +const MIN_W = 360; +const MIN_H = 280; + +const CURSOR_MAP: Record = { + n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize', + nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize', +}; + +// Resize handles sit at zIndex 25 so they win against the drag-header +// (zIndex 16). Same fix that landed on BrowserCard for the top edge. +const HANDLE_DEFS: { dir: ResizeDir; sx: Record }[] = [ + { dir: 'n', sx: { top: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } }, + { dir: 's', sx: { bottom: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } }, + { dir: 'w', sx: { left: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } }, + { dir: 'e', sx: { right: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } }, + { dir: 'nw', sx: { top: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, + { dir: 'ne', sx: { top: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, + { dir: 'sw', sx: { bottom: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, + { dir: 'se', sx: { bottom: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, +]; + +interface Props { + workflowId: string; + cardX: number; + cardY: number; + cardWidth: number; + cardHeight: number; + cardZOrder?: number; + zoom?: number; + panX?: number; + panY?: number; + isSelected?: boolean; + isHighlighted?: boolean; + multiDragDelta?: { dx: number; dy: number } | null; + onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser' | 'note' | 'workflow', shiftKey: boolean) => void; + onDragStart?: (id: string, type: 'agent' | 'view' | 'browser' | 'note' | 'workflow') => void; + onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void; + onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void; + onDoubleClick?: (id: string, type: 'agent' | 'view' | 'browser' | 'note' | 'workflow') => void; + onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser' | 'note' | 'workflow') => void; +} + +const WorkflowCard: React.FC = ({ + workflowId, + cardX, cardY, cardWidth, cardHeight, cardZOrder = 0, + zoom = 1, panX = 0, panY = 0, + isSelected = false, isHighlighted = false, multiDragDelta, + onCardSelect, onDragStart, onDragMove, onDragEnd, onDoubleClick, onBringToFront, +}) => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + + const card = useAppSelector((s) => s.workflows.openCards[workflowId]); + const workflow = useAppSelector((s) => s.workflows.items[workflowId]); + const runs = useAppSelector((s) => s.workflows.runs[workflowId]); + // Transient "Starting…" label state on the Run button. See onClick handler + // for the full rationale (avoid no-feedback flicker on fast manual runs). + const [runStarting, setRunStarting] = useState(false); + + // ---- Lazy-load runs for the history view ---- + useEffect(() => { + if (!card) return; + if ((card.view === 'history' || card.view === 'history_detail') && workflow && !runs) { + dispatch(fetchRuns(workflow.id)); + } + }, [card?.view, workflow?.id, runs, dispatch]); + + const title = workflow?.title || card?.draft?.title || 'Workflow'; + const isDraft = card?.view === 'preview' && !workflow; + const steps = (workflow?.steps || card?.draft?.steps || []) as Workflow['steps']; + + // ---- Card drag via title bar ---- + const DRAG_THRESHOLD = 3; + const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null); + const [isDragging, setIsDragging] = useState(false); + const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null); + const didDrag = useRef(false); + const justDraggedRef = useRef(false); + const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 }); + + const panRef = useRef({ panX, panY }); + panRef.current = { panX, panY }; + const zoomRef = useRef(zoom); + zoomRef.current = zoom; + + const handleDragPointerDown = useCallback((e: React.PointerEvent) => { + if (e.button !== 0) return; + // Don't start a card-drag when the press lands on an interactive + // child (the close button, action chips, step inputs). The header + // also hosts the X icon — bailing here is what makes the X actually + // clickable (the old overlay's setPointerCapture swallowed the click). + const target = e.target as HTMLElement; + if (target.closest('[data-no-drag], button, [role="button"], input, textarea, select')) return; + e.preventDefault(); + e.stopPropagation(); + dragState.current = { + startX: e.clientX, startY: e.clientY, + origX: cardX, origY: cardY, + startPanX: panRef.current.panX, startPanY: panRef.current.panY, + }; + lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY }; + didDrag.current = false; + setIsDragging(true); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + onDragStart?.(workflowId, 'workflow'); + }, [cardX, cardY, onDragStart, workflowId]); + + const recomputeDragPos = useCallback(() => { + const ds = dragState.current; + if (!ds || !didDrag.current) return; + const { clientX, clientY } = lastPointerRef.current; + const z = zoomRef.current; + const panDx = (panRef.current.panX - ds.startPanX) / z; + const panDy = (panRef.current.panY - ds.startPanY) / z; + const dx = (clientX - ds.startX) / z - panDx; + const dy = (clientY - ds.startY) / z - panDy; + setLocalDragPos({ x: ds.origX + dx, y: ds.origY + dy }); + onDragMove?.(dx, dy, clientX, clientY); + }, [onDragMove]); + + useEffect(() => { + if (isDragging && didDrag.current) recomputeDragPos(); + }, [panX, panY, isDragging, recomputeDragPos]); + + const handleDragPointerMove = useCallback((e: React.PointerEvent) => { + if (!dragState.current) return; + const rawDx = e.clientX - dragState.current.startX; + const rawDy = e.clientY - dragState.current.startY; + if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return; + didDrag.current = true; + lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY }; + recomputeDragPos(); + }, [recomputeDragPos]); + + const handleDragPointerUp = useCallback((e: React.PointerEvent) => { + if (!dragState.current) return; + const z = zoomRef.current; + const panDx = (panRef.current.panX - dragState.current.startPanX) / z; + const panDy = (panRef.current.panY - dragState.current.startPanY) / z; + const dx = (e.clientX - dragState.current.startX) / z - panDx; + const dy = (e.clientY - dragState.current.startY) / z - panDy; + if (didDrag.current) { + let finalX = dragState.current.origX + dx; + let finalY = dragState.current.origY + dy; + if (!e.shiftKey) { + finalX = Math.round(finalX / 24) * 24; + finalY = Math.round(finalY / 24) * 24; + } + dispatch(setWorkflowCardPosition({ workflowId, x: finalX, y: finalY })); + justDraggedRef.current = true; + requestAnimationFrame(() => { justDraggedRef.current = false; }); + } + onDragEnd?.(dx, dy, didDrag.current); + dragState.current = null; + didDrag.current = false; + setLocalDragPos(null); + setIsDragging(false); + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + }, [dispatch, workflowId, onDragEnd]); + + // ---- Resize ---- + const resizeRef = useRef<{ + dir: ResizeDir; startX: number; startY: number; + origX: number; origY: number; origW: number; origH: number; + } | null>(null); + const [isResizing, setIsResizing] = useState(false); + const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null); + + const handleResizeDown = useCallback( + (dir: ResizeDir) => (e: React.PointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + resizeRef.current = { + dir, startX: e.clientX, startY: e.clientY, + origX: cardX, origY: cardY, origW: cardWidth, origH: cardHeight, + }; + setIsResizing(true); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, + [cardX, cardY, cardWidth, cardHeight], + ); + + const computeResize = useCallback( + (e: React.PointerEvent) => { + if (!resizeRef.current) return null; + const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current; + const dx = (e.clientX - startX) / zoom; + const dy = (e.clientY - startY) / zoom; + let newX = origX, newY = origY, newW = origW, newH = origH; + if (dir.includes('e')) newW = origW + dx; + if (dir.includes('w')) { newW = origW - dx; newX = origX + dx; } + if (dir.includes('s')) newH = origH + dy; + if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; } + if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; } + if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; } + return { x: newX, y: newY, w: newW, h: newH }; + }, + [zoom], + ); + + const handleResizeMove = useCallback( + (e: React.PointerEvent) => { + const result = computeResize(e); + if (result) setLocalResize(result); + }, + [computeResize], + ); + + const handleResizeUp = useCallback((e: React.PointerEvent) => { + if (!resizeRef.current) return; + const result = computeResize(e); + if (result) { + dispatch(setWorkflowCardPosition({ workflowId, x: result.x, y: result.y })); + dispatch(setWorkflowCardSize({ workflowId, width: result.w, height: result.h })); + } + resizeRef.current = null; + setLocalResize(null); + setIsResizing(false); + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + }, [computeResize, dispatch, workflowId]); + + // ---- Close: drop transient view state AND remove from layout ---- + const onClose = useCallback(() => { + dispatch(closeWorkflowCard(workflowId)); + dispatch(removeWorkflowCard(workflowId)); + }, [dispatch, workflowId]); + + // ---- Display calculations ---- + const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0; + const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0; + const displayX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx); + const displayY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy); + const displayW = localResize?.w ?? cardWidth; + const displayH = localResize?.h ?? cardHeight; + const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta); + + if (!card) return null; + + const border = isHighlighted + ? `2px solid ${c.accent.primary}` + : isSelected + ? '2px solid #3b82f6' + : `1px solid ${c.border.medium}`; + + const shadow = isHighlighted + ? `0 0 0 3px ${c.accent.primary}50, 0 0 20px ${c.accent.primary}35, 0 0 40px ${c.accent.primary}15` + : isDragging || isResizing + ? c.shadow.lg + : isSelected + ? `0 0 0 1px #3b82f6, ${c.shadow.md}` + : c.shadow.md; + + return ( + onBringToFront?.(workflowId, 'workflow')} + onClick={(e: React.MouseEvent) => { + if (justDraggedRef.current) return; + onCardSelect?.(workflowId, 'workflow', e.shiftKey); + }} + onDoubleClick={(e: React.MouseEvent) => { + e.stopPropagation(); + onDoubleClick?.(workflowId, 'workflow'); + }} + sx={{ + position: 'absolute', + contain: 'layout style', + willChange: 'transform', + left: displayX, + top: displayY, + width: displayW, + height: displayH, + borderRadius: `${c.radius.lg}px`, + border, + bgcolor: c.bg.surface, + boxShadow: shadow, + display: 'flex', + flexDirection: 'column', + zIndex: (isDragging || isResizing) ? 999999 : cardZOrder, + transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease', + '&:hover .resize-handle': { opacity: 1 }, + }} + > + {/* ===== Title bar / drag handle ===== */} + + + + {title} + + {workflow?.last_run_status && ( + + {workflow.last_run_status} + + )} + { e.stopPropagation(); onClose(); }} + onPointerDown={(e) => e.stopPropagation()} + sx={{ p: 0.5, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }} + > + + + + + {/* ===== Action bar ===== */} + {!isDraft && workflow && ( + + } + active={card.view === 'saved'} + accent + onClick={async () => { + if (runStarting) return; + setRunStarting(true); + dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } })); + try { + await dispatch(runWorkflowNow(workflow.id)); + await dispatch(fetchRuns(workflow.id)); + } finally { + // Hold the "Starting…" label briefly so the user sees the + // state change even on fast runs. Without this the button + // flickers and feels like nothing happened. + setTimeout(() => setRunStarting(false), 600); + } + }} + /> + } + active={card.view === 'edit'} + onClick={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'edit', editFacet: card.editFacet || 'General' } }))} + /> + } + active={card.view === 'history' || card.view === 'history_detail'} + onClick={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }))} + /> + {!workflow.schedule.enabled && ( + + } + active={false} + onClick={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'edit', editFacet: 'Schedule' } }))} + /> + + )} + + )} + + {/* ===== Body — view-specific subview ===== + Crossfades between Run/Edit/History tabs so the swap doesn't + read as a "jump". Outer box is the scrollable viewport; the + animated child changes per `card.view`. */} + + + + {card.view === 'preview' && ( + { + // Migrate transient view state AND layout entry to the + // real workflow id so the card stays put visually. + dispatch(rekeyOpenCard({ oldId: workflowId, newId: wf.id })); + dispatch(rekeyWorkflowCard({ oldId: workflowId, newId: wf.id })); + dispatch(openWorkflowCardAction({ + workflowId: wf.id, + sourceSessionId: card.sourceSessionId, + view: 'saved', + draft: null, + })); + }} + /> + )} + {card.view === 'saved' && workflow && } + {card.view === 'edit' && workflow && ( + dispatch(updateWorkflowCard({ workflowId, patch: { editFacet: f } }))} + /> + )} + {card.view === 'history' && workflow && ( + dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history_detail', historyRunId: run.id } }))} + /> + )} + {card.view === 'history_detail' && workflow && ( + r.id === card.historyRunId) || null} + onBack={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }))} + /> + )} + + + + + {/* ===== Resize handles ===== */} + {HANDLE_DEFS.map(({ dir, sx }) => ( + + ))} + + ); +}; + +function TabBtn({ label, icon, active, accent, onClick }: { label: string; icon: React.ReactNode; active: boolean; accent?: boolean; onClick: () => void }) { + const c = useClaudeTokens(); + return ( + e.stopPropagation()} + role="button" + data-no-drag + sx={{ + display: 'inline-flex', alignItems: 'center', gap: 0.5, + px: 1.1, py: 0.5, + fontSize: '0.82rem', fontWeight: 600, + color: active ? c.accent.primary : c.text.secondary, + bgcolor: active || accent ? c.accent.primary + '14' : 'transparent', + border: `1px solid ${active || accent ? c.accent.primary + '40' : c.border.subtle}`, + borderRadius: `${c.radius.md}px`, + cursor: 'pointer', userSelect: 'none', + '&:hover': { bgcolor: c.accent.primary + '10' }, + }}> + {icon} + {label} + + ); +} + +export default React.memo(WorkflowCard); diff --git a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx new file mode 100644 index 00000000..6ac22562 --- /dev/null +++ b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx @@ -0,0 +1,184 @@ +import React, { useCallback, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch } from '@/shared/hooks'; +import { + closeWorkflowCard, + createWorkflow, + type Workflow, + type WorkflowRun, +} from '@/shared/state/workflowsSlice'; +import { removeWorkflowCard } from '@/shared/state/dashboardLayoutSlice'; +import { describePermissions, describeSchedule } from './scheduleUtils'; + +export function statusColor(s: string, c: ReturnType): string { + if (s === 'success') return c.status.success; + if (s === 'failure') return c.status.error; + if (s === 'ran_late') return c.status.warning; + if (s === 'running') return c.accent.primary; + return c.text.muted; +} + +export function statusBg(s: string, c: ReturnType): string { + if (s === 'success') return c.status.successBg; + if (s === 'failure') return c.status.errorBg; + if (s === 'ran_late') return c.status.warningBg; + return c.bg.secondary; +} + +export function labelForStatus(s: string): string { + if (s === 'success') return 'Success'; + if (s === 'failure') return 'Failure'; + if (s === 'ran_late') return 'Ran Late'; + if (s === 'running') return 'Running'; + if (s === 'skipped') return 'Skipped'; + return s; +} + +export function formatRunDate(iso: string): string { + try { + const d = new Date(iso); + return d.toLocaleString('en', { weekday: 'short', month: 'short', day: 'numeric' }); + } catch { return iso; } +} + +export function ActionBtn({ label, tone, disabled, onClick }: { label: string; tone: 'muted' | 'success'; disabled?: boolean; onClick: () => void }) { + const c = useClaudeTokens(); + const isSuccess = tone === 'success'; + return ( + + {label} + + ); +} + +export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, onSaved }: { + workflowId: string; + steps: Workflow['steps']; + sourceSessionId: string | null; + initialDraft: Partial | null; + onSaved: (w: Workflow) => void; +}) { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const [busy, setBusy] = useState(false); + const title = (initialDraft?.title as string) || 'Email summary request'; + const description = (initialDraft?.description as string) || "This is an ai generated description of the workflow that gets auto generated after you click complete on the last step. It's used when we wrap workflows as tool calls for other agents to invoke"; + + const onSave = useCallback(async () => { + if (busy) return; + setBusy(true); + try { + const result = await dispatch(createWorkflow({ + title, + description, + steps: steps.map((s) => ({ id: s.id, text: s.text })), + source_session_id: sourceSessionId, + use_synced_prompt: true, + } as Partial)); + const wf = (result as unknown as { payload: Workflow }).payload; + if (wf?.id) onSaved(wf); + } finally { + setBusy(false); + } + }, [busy, dispatch, title, description, steps, sourceSessionId, onSaved]); + + const onDiscard = useCallback(() => { + dispatch(closeWorkflowCard(workflowId)); + dispatch(removeWorkflowCard(workflowId)); + }, [dispatch, workflowId]); + + return ( + + {description} + + {steps.map((s, idx) => ( + + {idx + 1} + {s.text} + + ))} + + + + + + + ); +} + +export function SavedView({ workflow, steps }: { workflow: Workflow; steps: Workflow['steps'] }) { + const c = useClaudeTokens(); + return ( + + Scheduled: {describeSchedule(workflow.schedule)} + Permissions: {describePermissions(workflow)} + {workflow.description} + + {steps.map((s, idx) => ( + + {idx + 1} + {s.text} + + ))} + + + ); +} + +export function HistoryList({ runs, onOpen }: { runs: WorkflowRun[]; onOpen: (r: WorkflowRun) => void }) { + const c = useClaudeTokens(); + if (!runs || runs.length === 0) { + return No runs yet; + } + return ( + + {runs.map((r) => ( + onOpen(r)} + sx={{ display: 'flex', alignItems: 'center', gap: 1.25, py: 0.75, px: 0.5, cursor: 'pointer', borderRadius: 0.75, '&:hover': { bgcolor: c.bg.elevated } }}> + + {labelForStatus(r.status)} + + {formatRunDate(r.started_at)} + Open → + + ))} + + ); +} + +export function HistoryDetail({ run, onBack }: { run: WorkflowRun | null; onBack: () => void }) { + const c = useClaudeTokens(); + if (!run) return Run not found; + return ( + + + ← back + {labelForStatus(run.status)} + {formatRunDate(run.started_at)} + + {run.error && ( + {run.error} + )} + Started {formatRunDate(run.started_at)}, finished {run.finished_at ? formatRunDate(run.finished_at) : 'in progress'}. + {run.session_id && ( + Session: {run.session_id.slice(0, 8)} + )} + + ); +} diff --git a/frontend/src/app/pages/Workflows/WorkflowEditViews.tsx b/frontend/src/app/pages/Workflows/WorkflowEditViews.tsx new file mode 100644 index 00000000..53d7b9aa --- /dev/null +++ b/frontend/src/app/pages/Workflows/WorkflowEditViews.tsx @@ -0,0 +1,565 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import InputBase from '@mui/material/InputBase'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; +import Switch from '@mui/material/Switch'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch } from '@/shared/hooks'; +import { updateWorkflow, type Workflow, type ScheduleConfig, type PermissionTier } from '@/shared/state/workflowsSlice'; +import { WEEKDAY_LABEL, formatTime } from './scheduleUtils'; + +// JS-style weekday from a Date (Sun=0..Sat=6), matching ScheduleConfig.on_days. +function jsWeekday(d: Date): number { return d.getDay(); } + +// Compute the next fire time from a ScheduleConfig — mirrors the backend +// math in scheduler.py:_next_fire_after so the preview matches what +// actually fires. Local-clock, like the backend. +function previewNextRun(sched: ScheduleConfig): Date | null { + if (!sched.enabled) return null; + const now = new Date(); + let candidate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), sched.hour, sched.minute, 0, 0); + if (candidate <= now) candidate = new Date(candidate.getTime() + 86400000); + + if (sched.repeat_unit === 'day') { + const step = Math.max(1, sched.repeat_every); + while (candidate <= now) candidate = new Date(candidate.getTime() + step * 86400000); + return candidate; + } + if (sched.repeat_unit === 'week') { + const allowed = sched.on_days.length ? sched.on_days : [jsWeekday(now)]; + for (let i = 0; i < 14; i += 1) { + if (allowed.includes(jsWeekday(candidate)) && candidate > now) return candidate; + candidate = new Date(candidate.getTime() + 86400000); + } + return candidate; + } + if (sched.repeat_unit === 'month') { + const step = Math.max(1, sched.repeat_every); + let c = new Date(now.getFullYear(), now.getMonth(), Math.min(28, now.getDate()), sched.hour, sched.minute); + let guard = 0; + while (c <= now && guard < 60) { + c = new Date(c.getFullYear(), c.getMonth() + step, c.getDate(), sched.hour, sched.minute); + guard += 1; + } + return c; + } + return null; +} + +function formatNextRun(d: Date): string { + const wd = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()]; + const mo = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()]; + return `${wd} ${mo} ${d.getDate()} at ${formatTime(d.getHours(), d.getMinutes())}`; +} + +interface Props { + workflow: Workflow; + facet: 'General' | 'Actions' | 'Schedule'; + onChangeFacet: (facet: 'General' | 'Actions' | 'Schedule') => void; +} + +const BODY_FS = '0.88rem'; +const LABEL_FS = '0.82rem'; +const HINT_FS = '0.78rem'; +const INPUT_FS = '0.88rem'; + +// Pre-save validation. Returns the first user-visible reason save should +// be blocked, or null when the draft is good to ship. Keeps the schedule +// from silently saving a "call tier" with no phone number — the previous +// failure mode where the schedule would fire and the call attempt would +// just no-op against an empty string. +function validateDraft(draft: Workflow): string | null { + for (const tier of (draft.permissions || [])) { + if (tier.kind === 'notify') continue; + const cleaned = (tier.phone || '').replace(/[^\d+]/g, ''); + if (!cleaned) { + return tier.kind === 'text' + ? 'Add a phone number for the text-me tier.' + : 'Add a phone number for the call-me tier.'; + } + if (cleaned.replace(/^\+/, '').length < 7) { + return `Phone number looks too short (${tier.kind} tier).`; + } + } + return null; +} + +export default function WorkflowEditViews({ workflow, facet, onChangeFacet }: Props) { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const [draft, setDraft] = useState(workflow); + const [busy, setBusy] = useState(false); + // Save-feedback state: 'idle' | 'saved' | 'error'. `saved` flashes a + // checkmark + label on the Save button for 1.4s then auto-clears. + // `error` carries a string the user can read. + const [savedFlash, setSavedFlash] = useState(false); + const [saveError, setSaveError] = useState(null); + + const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(workflow), [draft, workflow]); + + const onSave = useCallback(async () => { + if (busy || !dirty) return; + const reason = validateDraft(draft); + if (reason) { + setSaveError(reason); + return; + } + setSaveError(null); + setBusy(true); + try { + const result = await dispatch(updateWorkflow({ id: workflow.id, patch: draft })); + if (updateWorkflow.fulfilled.match(result)) { + setSavedFlash(true); + setTimeout(() => setSavedFlash(false), 1400); + } else { + setSaveError('Save failed. Please try again.'); + } + } catch (e) { + setSaveError((e as Error)?.message || 'Save failed.'); + } finally { + setBusy(false); + } + }, [busy, dirty, dispatch, workflow.id, draft]); + + const onDiscard = useCallback(() => { + setDraft(workflow); + setSaveError(null); + }, [workflow]); + + return ( + + + Currently Editing + + + + + + + {saveError && ( + + {saveError} + + )} + + {facet === 'General' && } + {facet === 'Actions' && } + {facet === 'Schedule' && } + + ); +} + +function GeneralFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) { + const c = useClaudeTokens(); + const [editingPrompt, setEditingPrompt] = useState(false); + return ( + + + setDraft({ ...draft, title: e.target.value })} + sx={{ flex: 1, fontSize: INPUT_FS, color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1, py: 0.5 }} + /> + + + setDraft({ ...draft, description: e.target.value })} + sx={{ flex: 1, fontSize: INPUT_FS, color: c.text.secondary, lineHeight: 1.5, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1, py: 0.5 }} + /> + + + + setEditingPrompt((v) => !v)}> + {editingPrompt ? 'Editing…' : 'Edit'} + + + + + {editingPrompt && !draft.use_synced_prompt && ( + setDraft({ ...draft, system_prompt: e.target.value })} + sx={{ fontSize: INPUT_FS, color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, p: 1, lineHeight: 1.5 }} + /> + )} + Workflow + + {draft.steps.map((s, idx) => ( + + {idx + 1} + { + const next = [...draft.steps]; + next[idx] = { ...s, text: e.target.value }; + setDraft({ ...draft, steps: next }); + }} + sx={{ flex: 1, fontSize: INPUT_FS, color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1.25, py: 0.6, lineHeight: 1.4 }} + /> + + ))} + + + ); +} + +function ActionsFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) { + const c = useClaudeTokens(); + // Configure must ONLY appear when freeze is on (image #40 annotation). + // When "Don't freeze" is selected the entry vanishes entirely. + const [configuring, setConfiguring] = useState(false); + return ( + + + Do you want to prevent the agent from taking actions that weren't used in the original workflow? + + + + + + + Do you want to freeze the actions available to the Agent so this flow always works even if you change your settings? + + + + + + {draft.actions.freeze && ( + + setConfiguring((v) => !v)} + role="button" + sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: LABEL_FS, color: configuring ? c.accent.primary : c.text.secondary, cursor: 'pointer', fontWeight: 500, '&:hover': { color: c.accent.primary } }}> + {configuring ? '⚙ Configuring…' : '⚙ Configure'} + + + )} + + {draft.actions.freeze && configuring && ( + + BUILT-IN ACTION SETS + {(['Core Actions', 'Extended Actions', 'Apps', 'Browser'] as const).map((set) => { + const enabled = draft.actions.configured_sets.includes(set); + return ( + + {set} + { + const next = e.target.checked + ? [...draft.actions.configured_sets, set] + : draft.actions.configured_sets.filter((s) => s !== set); + setDraft({ ...draft, actions: { ...draft.actions, configured_sets: next } }); + }} + /> + + ); + })} + CUSTOM ACTION SETS + {(['Notion', 'Google Workspace', 'YouTube', 'Reddit'] as const).map((set) => { + const enabled = draft.actions.configured_sets.includes(set); + return ( + + {set} + { + const next = e.target.checked + ? [...draft.actions.configured_sets, set] + : draft.actions.configured_sets.filter((s) => s !== set); + setDraft({ ...draft, actions: { ...draft.actions, configured_sets: next } }); + }} + /> + + ); + })} + + )} + + ); +} + +function ScheduleFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) { + const c = useClaudeTokens(); + const s = draft.schedule; + const setSched = useCallback((patch: Partial) => { + setDraft({ ...draft, schedule: { ...s, ...patch, enabled: true } }); + }, [draft, s, setDraft]); + + const addBackup = useCallback(() => { + const tiers = [...(draft.permissions || [])]; + const lastKind = tiers.length ? tiers[tiers.length - 1].kind : 'notify'; + // Tier escalation chain: notify → text → call. Cap at 3 tiers since + // the chain has no fourth medium and stacking duplicates makes no + // sense (matches Figma image #44 ceiling). + if (lastKind === 'notify') tiers.push({ kind: 'text', after_minutes: 5, phone: '' }); + else if (lastKind === 'text') tiers.push({ kind: 'call', after_minutes: 60, phone: '' }); + else return; + setDraft({ ...draft, permissions: tiers }); + }, [draft, setDraft]); + + const removeTier = useCallback((idx: number) => { + // Removing tier N drops all tiers after it too, so the chain stays + // contiguous (no "call" without "text" before it). + const tiers = (draft.permissions || []).slice(0, idx); + setDraft({ ...draft, permissions: tiers }); + }, [draft, setDraft]); + + const lastTierKind = (draft.permissions || []).length + ? draft.permissions[draft.permissions.length - 1].kind + : 'notify'; + const canAddBackup = lastTierKind !== 'call'; + + const setTier = useCallback((idx: number, patch: Partial) => { + const tiers = [...(draft.permissions || [])]; + tiers[idx] = { ...tiers[idx], ...patch }; + setDraft({ ...draft, permissions: tiers }); + }, [draft, setDraft]); + + return ( + + When should this workflow run? + + Repeat every + setSched({ repeat_every: Math.max(1, Number(e.target.value) || 1) })} + sx={{ width: 48, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }} + /> + + + {s.repeat_unit === 'week' && ( + + ↳ on + {WEEKDAY_LABEL.map((label, idx) => { + const active = s.on_days.includes(idx); + return ( + setSched({ on_days: active ? s.on_days.filter((d) => d !== idx) : [...s.on_days, idx] })} + role="button" + sx={{ width: 26, height: 26, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: LABEL_FS, fontWeight: 700, cursor: 'pointer', color: active ? '#fff' : c.text.muted, bgcolor: active ? c.accent.primary : 'transparent', border: `1px solid ${active ? c.accent.primary : c.border.subtle}` }}>{label} + ); + })} + + )} + + ↳ at + {/* 12-hour picker; we store 0..23 server-side but show 1..12 + AM/PM + so users can't accidentally schedule "3" thinking it's 3pm and + get a 3am run (the previous bare-number input made that easy). */} + + : + + + + + {(() => { + // "Next run" preview is the single line that turns "did my schedule + // actually take?" from a guess into an answer. Re-renders whenever + // the schedule fields change, so users get instant feedback. + const next = previewNextRun({ ...s, enabled: true }); + return next ? ( + + Next run: {formatNextRun(next)} + + ) : null; + })()} + + How should the agent ask for your permission? + {(draft.permissions || []).map((tier, idx) => ( + setTier(idx, patch)} + onRemove={idx === 0 ? undefined : () => removeTier(idx)} + /> + ))} + {canAddBackup && ( + + add a backup + )} + + ); +} + +function PermissionRow({ idx, tier, onChange, onRemove }: { + idx: number; + tier: PermissionTier; + prevKind: PermissionTier['kind'] | null; + onChange: (p: Partial) => void; + onRemove?: () => void; +}) { + const c = useClaudeTokens(); + if (idx === 0) { + return ( + + ); + } + const verb = tier.kind === 'text' ? 'Text me' : 'Call me'; + const unitLabel = tier.kind === 'call' ? 'hour' : 'minutes'; + return ( + + + ↳ and if I don't respond after + onChange({ after_minutes: Math.max(0, Number(e.target.value) || 0) })} + sx={{ width: 44, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }} + /> + {unitLabel} + + + + at this number + onChange({ phone: e.target.value })} + sx={{ flex: 1, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4, color: c.text.primary }} + /> + {onRemove && ( + + × + + )} + + + ); +} + +function FieldRow({ label, children, align }: { label: string; children: React.ReactNode; align?: 'top' | 'center' }) { + const c = useClaudeTokens(); + return ( + + {label}: + {children} + + ); +} + +function ActionBtn({ label, tone, disabled, onClick }: { label: string; tone: 'muted' | 'success'; disabled?: boolean; onClick: () => void }) { + const c = useClaudeTokens(); + const isSuccess = tone === 'success'; + return ( + + {label} + + ); +} diff --git a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx new file mode 100644 index 00000000..e6662ab7 --- /dev/null +++ b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx @@ -0,0 +1,455 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import InputBase from '@mui/material/InputBase'; +import CloseIcon from '@mui/icons-material/Close'; +import AddIcon from '@mui/icons-material/Add'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import SearchIcon from '@mui/icons-material/Search'; +import MenuIcon from '@mui/icons-material/Menu'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { + addWorkflowCard, + closeWorkflowsHub, + setWorkflowsHubPosition, + setWorkflowsHubSize, +} from '@/shared/state/dashboardLayoutSlice'; +import { openWorkflowCard } from '@/shared/state/workflowsSlice'; +import ScheduleCalendar from './ScheduleCalendar'; +import { WEEKDAY_LABEL, addDays, sameDay, startOfMonthGrid } from './scheduleUtils'; + +type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; + +const EDGE_THICKNESS = 6; +const CORNER_SIZE = 14; +const MIN_W = 720; +const MIN_H = 420; + +const CURSOR_MAP: Record = { + n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize', + nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize', +}; + +const HANDLE_DEFS: { dir: ResizeDir; sx: Record }[] = [ + { dir: 'n', sx: { top: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } }, + { dir: 's', sx: { bottom: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } }, + { dir: 'w', sx: { left: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } }, + { dir: 'e', sx: { right: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } }, + { dir: 'nw', sx: { top: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, + { dir: 'ne', sx: { top: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, + { dir: 'sw', sx: { bottom: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, + { dir: 'se', sx: { bottom: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, +]; + +interface Props { + cardX: number; + cardY: number; + cardWidth: number; + cardHeight: number; + cardZOrder?: number; + zoom?: number; + panX?: number; + panY?: number; +} + +type CalendarView = 'Week' | 'Month' | 'List'; + +const WorkflowsHubCard: React.FC = ({ + cardX, cardY, cardWidth, cardHeight, cardZOrder = 0, + zoom = 1, panX = 0, panY = 0, +}) => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const workflows = useAppSelector((s) => s.workflows.items); + + const [view, setView] = useState('Week'); + const [viewOpen, setViewOpen] = useState(false); + const [refDate, setRefDate] = useState(new Date()); + const [search, setSearch] = useState(''); + + const scheduled = useMemo(() => Object.values(workflows).filter((w) => w.schedule.enabled), [workflows]); + const unscheduled = useMemo(() => Object.values(workflows).filter((w) => !w.schedule.enabled), [workflows]); + + const monthLabel = refDate.toLocaleString('en', { month: 'long', year: 'numeric' }); + + const onSelectWorkflow = useCallback((wid: string) => { + dispatch(addWorkflowCard({ workflowId: wid })); + dispatch(openWorkflowCard({ workflowId: wid, view: 'saved' })); + }, [dispatch]); + + const onNew = useCallback(() => { + const tempId = `draft-${Date.now()}`; + dispatch(addWorkflowCard({ workflowId: tempId })); + dispatch(openWorkflowCard({ + workflowId: tempId, + view: 'preview', + draft: { title: 'New workflow', description: 'Describe what this workflow should do.', steps: [{ id: 'step-1', text: '' }] }, + })); + }, [dispatch]); + + // ---- Card drag via header ---- + const DRAG_THRESHOLD = 3; + const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null); + const [isDragging, setIsDragging] = useState(false); + const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null); + const didDrag = useRef(false); + + const panRef = useRef({ panX, panY }); + panRef.current = { panX, panY }; + const zoomRef = useRef(zoom); + zoomRef.current = zoom; + + const onHeaderPointerDown = useCallback((e: React.PointerEvent) => { + if (e.button !== 0) return; + const target = e.target as HTMLElement; + if (target.closest('[data-no-drag], button, [role="button"], input, textarea, select')) return; + e.preventDefault(); + e.stopPropagation(); + dragState.current = { + startX: e.clientX, startY: e.clientY, + origX: cardX, origY: cardY, + startPanX: panRef.current.panX, startPanY: panRef.current.panY, + }; + didDrag.current = false; + setIsDragging(true); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + }, [cardX, cardY]); + + const onHeaderPointerMove = useCallback((e: React.PointerEvent) => { + if (!dragState.current) return; + const rawDx = e.clientX - dragState.current.startX; + const rawDy = e.clientY - dragState.current.startY; + if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return; + didDrag.current = true; + const z = zoomRef.current; + const panDx = (panRef.current.panX - dragState.current.startPanX) / z; + const panDy = (panRef.current.panY - dragState.current.startPanY) / z; + setLocalDragPos({ + x: dragState.current.origX + rawDx / z - panDx, + y: dragState.current.origY + rawDy / z - panDy, + }); + }, []); + + const onHeaderPointerUp = useCallback((e: React.PointerEvent) => { + if (!dragState.current) return; + const z = zoomRef.current; + const panDx = (panRef.current.panX - dragState.current.startPanX) / z; + const panDy = (panRef.current.panY - dragState.current.startPanY) / z; + const dx = (e.clientX - dragState.current.startX) / z - panDx; + const dy = (e.clientY - dragState.current.startY) / z - panDy; + if (didDrag.current) { + let finalX = dragState.current.origX + dx; + let finalY = dragState.current.origY + dy; + if (!e.shiftKey) { + finalX = Math.round(finalX / 24) * 24; + finalY = Math.round(finalY / 24) * 24; + } + dispatch(setWorkflowsHubPosition({ x: finalX, y: finalY })); + } + dragState.current = null; + didDrag.current = false; + setLocalDragPos(null); + setIsDragging(false); + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + }, [dispatch]); + + // ---- Resize ---- + const resizeRef = useRef<{ dir: ResizeDir; sx0: number; sy0: number; ox: number; oy: number; ow: number; oh: number } | null>(null); + const [isResizing, setIsResizing] = useState(false); + const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null); + + const onResizeDown = useCallback((dir: ResizeDir) => (e: React.PointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + resizeRef.current = { dir, sx0: e.clientX, sy0: e.clientY, ox: cardX, oy: cardY, ow: cardWidth, oh: cardHeight }; + setIsResizing(true); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, [cardX, cardY, cardWidth, cardHeight]); + + const compute = useCallback((e: React.PointerEvent) => { + if (!resizeRef.current) return null; + const { dir, sx0, sy0, ox, oy, ow, oh } = resizeRef.current; + const dx = (e.clientX - sx0) / zoom; + const dy = (e.clientY - sy0) / zoom; + let nx = ox, ny = oy, nw = ow, nh = oh; + if (dir.includes('e')) nw = ow + dx; + if (dir.includes('w')) { nw = ow - dx; nx = ox + dx; } + if (dir.includes('s')) nh = oh + dy; + if (dir.includes('n')) { nh = oh - dy; ny = oy + dy; } + if (nw < MIN_W) { if (dir.includes('w')) nx = ox + ow - MIN_W; nw = MIN_W; } + if (nh < MIN_H) { if (dir.includes('n')) ny = oy + oh - MIN_H; nh = MIN_H; } + return { x: nx, y: ny, w: nw, h: nh }; + }, [zoom]); + + const onResizeMove = useCallback((e: React.PointerEvent) => { + const r = compute(e); + if (r) setLocalResize(r); + }, [compute]); + + const onResizeUp = useCallback((e: React.PointerEvent) => { + if (!resizeRef.current) return; + const r = compute(e); + if (r) { + dispatch(setWorkflowsHubPosition({ x: r.x, y: r.y })); + dispatch(setWorkflowsHubSize({ width: r.w, height: r.h })); + } + resizeRef.current = null; + setLocalResize(null); + setIsResizing(false); + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + }, [compute, dispatch]); + + const dx = localResize?.x ?? localDragPos?.x ?? cardX; + const dy = localResize?.y ?? localDragPos?.y ?? cardY; + const dw = localResize?.w ?? cardWidth; + const dh = localResize?.h ?? cardHeight; + + return ( + + {/* ===== Title strip (drag handle) ===== */} + + + Workflows + { e.stopPropagation(); dispatch(closeWorkflowsHub()); }} + onPointerDown={(e) => e.stopPropagation()} + sx={{ p: 0.35, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }} + > + + + + + {/* ===== Toolbar row (matches Figma image #8 header) ===== */} + + + + + + + New + + + + setRefDate(new Date())} + role="button" + data-no-drag + sx={{ + fontSize: '0.82rem', fontWeight: 500, color: c.text.secondary, + border: `1px solid ${c.border.subtle}`, + px: 1.1, py: 0.35, borderRadius: `${c.radius.md}px`, cursor: 'pointer', + '&:hover': { color: c.text.primary, borderColor: c.border.medium }, + }}>Today + setRefDate(addDays(refDate, view === 'Month' ? -28 : -7))} sx={{ p: 0.3 }}> + setRefDate(addDays(refDate, view === 'Month' ? 28 : 7))} sx={{ p: 0.3 }}> + {monthLabel} + + + + + + + setViewOpen((v) => !v)} + role="button" + data-no-drag + sx={{ + display: 'inline-flex', alignItems: 'center', gap: 0.25, + fontSize: '0.82rem', fontWeight: 500, color: c.text.secondary, + border: `1px solid ${c.border.subtle}`, px: 1, py: 0.35, + borderRadius: `${c.radius.md}px`, cursor: 'pointer', + '&:hover': { color: c.text.primary, borderColor: c.border.medium }, + }}> + {view} + + + {viewOpen && ( + + {(['Week', 'Month', 'List'] as const).map((v) => ( + { setView(v); setViewOpen(false); }} + sx={{ px: 1.25, py: 0.65, fontSize: '0.85rem', color: view === v ? c.accent.primary : c.text.primary, fontWeight: view === v ? 600 : 400, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}> + {v} + + ))} + + )} + + + + {/* ===== Body: sidebar + main calendar ===== */} + + {/* Sidebar */} + + + setSearch(e.target.value)} + placeholder="Search workflows" + startAdornment={} + sx={{ fontSize: '0.82rem', color: c.text.primary, width: '100%', '& input::placeholder': { color: c.text.ghost, opacity: 1 } }} + /> + + + + match(w.title, search))} onPick={onSelectWorkflow} scheduled /> + match(w.title, search))} onPick={onSelectWorkflow} scheduled={false} /> + + + + {/* Main calendar area */} + + + + + + {/* Resize handles */} + {HANDLE_DEFS.map(({ dir, sx }) => ( + + ))} + + ); +}; + +function MiniMonth({ refDate, onPick }: { refDate: Date; onPick: (d: Date) => void }) { + const c = useClaudeTokens(); + const start = startOfMonthGrid(refDate); + const cells = Array.from({ length: 35 }, (_, i) => addDays(start, i)); + const today = new Date(); + const label = refDate.toLocaleString('en', { month: 'long', year: 'numeric' }); + return ( + + + {label} + onPick(addMonths(refDate, -1))} sx={{ p: 0.15 }}> + onPick(addMonths(refDate, 1))} sx={{ p: 0.15 }}> + + + {WEEKDAY_LABEL.map((l, i) => ( + {l} + ))} + {cells.map((d) => { + const isToday = sameDay(d, today); + const inMonth = d.getMonth() === refDate.getMonth(); + const selected = sameDay(d, refDate); + return ( + onPick(d)} data-no-drag sx={{ textAlign: 'center', py: 0.2, opacity: inMonth ? 1 : 0.4, cursor: 'pointer' }}> + {d.getDate()} + + ); + })} + + + ); +} + +function SidebarSection({ title, items, onPick, scheduled }: { + title: string; + items: { id: string; title: string; schedule: { enabled: boolean } }[]; + onPick: (id: string) => void; + scheduled: boolean; +}) { + const c = useClaudeTokens(); + const [open, setOpen] = useState(true); + return ( + + setOpen((v) => !v)} + role="button" + data-no-drag + sx={{ display: 'flex', alignItems: 'center', mb: 0.5, cursor: 'pointer', '&:hover .section-chev': { color: c.text.primary } }}> + {title} + + + {open && items.length === 0 && ( + None yet + )} + {open && items.map((w) => ( + onPick(w.id)} + data-no-drag + sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.4, pl: 0.5, color: c.text.primary, borderRadius: 0.5, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}> + {scheduled ? ( + + ) : ( + + )} + {w.title} + + ))} + + ); +} + +function match(title: string, query: string): boolean { + if (!query.trim()) return true; + return title.toLowerCase().includes(query.trim().toLowerCase()); +} + +function addMonths(d: Date, n: number): Date { + const x = new Date(d); + x.setMonth(x.getMonth() + n); + return x; +} + +export default React.memo(WorkflowsHubCard); diff --git a/frontend/src/app/pages/Workflows/scheduleUtils.ts b/frontend/src/app/pages/Workflows/scheduleUtils.ts new file mode 100644 index 00000000..d6f5c67a --- /dev/null +++ b/frontend/src/app/pages/Workflows/scheduleUtils.ts @@ -0,0 +1,127 @@ +import type { Workflow, ScheduleConfig } from '@/shared/state/workflowsSlice'; + +export const WEEKDAY_LABEL = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; +export const WEEKDAY_LABEL_SHORT = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; +export const WEEKDAY_FULL = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +export function defaultSchedule(): ScheduleConfig { + return { + enabled: false, + repeat_every: 1, + repeat_unit: 'week', + on_days: [], + hour: 9, + minute: 0, + timezone: 'local', + on_missed: 'skip', + }; +} + +export function formatTime(hour: number, minute: number): string { + const h12 = ((hour + 11) % 12) + 1; + const suffix = hour < 12 ? 'am' : 'pm'; + const mm = String(minute).padStart(2, '0'); + return minute === 0 ? `${h12}${suffix}` : `${h12}:${mm}${suffix}`; +} + +// Used in the roomy hub calendar: "10 AM", "12 PM", "1 PM"... +// Matches Figma image #8 styling for the left-column time labels. +export function formatHourLabel(hour: number): string { + const h12 = ((hour + 11) % 12) + 1; + const suffix = hour < 12 ? 'AM' : 'PM'; + return `${h12} ${suffix}`; +} + +export function describeSchedule(sched: ScheduleConfig): string { + if (!sched.enabled) return 'Not scheduled'; + const time = formatTime(sched.hour, sched.minute); + if (sched.repeat_unit === 'day') { + return sched.repeat_every === 1 ? `Every day at ${time}` : `Every ${sched.repeat_every} days at ${time}`; + } + if (sched.repeat_unit === 'month') { + return sched.repeat_every === 1 ? `Every month at ${time}` : `Every ${sched.repeat_every} months at ${time}`; + } + const days = sched.on_days.length === 0 ? 'week' : sched.on_days + .slice() + .sort() + .map((d) => WEEKDAY_FULL[d]) + .join(', '); + const cadence = sched.repeat_every === 1 ? `Every ${days}` : `Every ${sched.repeat_every} weeks on ${days}`; + return `${cadence} at ${time}`; +} + +export function describePermissions(workflow: Workflow): string { + if (!workflow.permissions || workflow.permissions.length === 0) return 'Notify only'; + const labels: string[] = []; + for (const p of workflow.permissions) { + if (p.kind === 'notify') labels.push('notify in app'); + else if (p.kind === 'text') labels.push('text'); + else if (p.kind === 'call') labels.push('call'); + } + return `First ${labels.join(', then ')}`; +} + +export function startOfWeek(date: Date): Date { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() - d.getDay()); + return d; +} + +export function startOfMonthGrid(date: Date): Date { + const d = new Date(date.getFullYear(), date.getMonth(), 1); + d.setDate(d.getDate() - d.getDay()); + return d; +} + +export function sameDay(a: Date, b: Date): boolean { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +} + +export function addDays(date: Date, n: number): Date { + const d = new Date(date); + d.setDate(d.getDate() + n); + return d; +} + +export function fireTimesWithin(workflow: Workflow, from: Date, to: Date, cap = 40): Date[] { + const sched = workflow.schedule; + if (!sched.enabled) return []; + const out: Date[] = []; + const cursor = new Date(from); + cursor.setHours(0, 0, 0, 0); + + if (sched.repeat_unit === 'day') { + const step = Math.max(1, sched.repeat_every); + for (let i = 0; i < 366 && out.length < cap; i += step) { + const d = new Date(cursor); + d.setDate(d.getDate() + i); + d.setHours(sched.hour, sched.minute, 0, 0); + if (d >= from && d <= to) out.push(d); + if (d > to) break; + } + return out; + } + + if (sched.repeat_unit === 'month') { + let d = new Date(from.getFullYear(), from.getMonth(), Math.min(28, from.getDate()), sched.hour, sched.minute); + let guard = 0; + while (d <= to && out.length < cap && guard < 60) { + if (d >= from) out.push(new Date(d)); + d = new Date(d.getFullYear(), d.getMonth() + Math.max(1, sched.repeat_every), d.getDate(), sched.hour, sched.minute); + guard += 1; + } + return out; + } + + const allowed = sched.on_days.length ? sched.on_days : [from.getDay()]; + for (let i = 0; i < 60 && out.length < cap; i += 1) { + const day = new Date(cursor); + day.setDate(day.getDate() + i); + if (!allowed.includes(day.getDay())) continue; + day.setHours(sched.hour, sched.minute, 0, 0); + if (day >= from && day <= to) out.push(day); + if (day > to) break; + } + return out; +} diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 33ea67ae..a513b221 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -20,6 +20,10 @@ export const DEFAULT_VIEW_CARD_W = 1280; export const DEFAULT_VIEW_CARD_H = 800; export const DEFAULT_BROWSER_CARD_W = 1280; export const DEFAULT_BROWSER_CARD_H = 800; +export const DEFAULT_WORKFLOW_CARD_W = 440; +export const DEFAULT_WORKFLOW_CARD_H = 520; +export const DEFAULT_WORKFLOWS_HUB_W = 1200; +export const DEFAULT_WORKFLOWS_HUB_H = 640; export const EXPANDED_CARD_MIN_H = 620; export const GRID_GAP = 24; const GRID_ORIGIN = { x: 40, y: 100 }; @@ -66,6 +70,26 @@ export interface BrowserCardPosition { spawned_by?: string | null; } +export interface WorkflowCardPosition { + workflow_id: string; + x: number; + y: number; + width: number; + height: number; + zOrder: number; + source_session_id?: string | null; +} + +// Singleton per dashboard. There's only one Workflows Hub card open at a +// time (the calendar + sidebar view), so it doesn't need an id keyspace. +export interface WorkflowsHubPosition { + x: number; + y: number; + width: number; + height: number; + zOrder: number; +} + export type NoteColor = 'yellow' | 'pink' | 'blue' | 'green' | 'purple' | 'gray'; export interface NotePosition { @@ -86,6 +110,8 @@ export interface DashboardLayoutState { cards: Record; viewCards: Record; browserCards: Record; + workflowCards: Record; + workflowsHub: WorkflowsHubPosition | null; notes: Record; closedCardPositions: Record; glowingBrowserCards: Record; @@ -100,12 +126,20 @@ export interface DashboardLayoutState { // to center on the new card, then dispatches clearPendingFocusBrowserId. pendingFocusBrowserId: string | null; pendingFocusNoteId: string | null; + pendingFocusWorkflowId: string | null; + // Transient flag: when openWorkflowsHub creates/raises the singleton hub + // card, set to true so Dashboard.tsx can pan/zoom-to-fit on the new card. + // Without this, the hub spawns at an open grid cell which may be far + // from the user's current pan, and "click Expand" looks like a no-op. + pendingFocusWorkflowsHub: boolean; } const initialState: DashboardLayoutState = { cards: {}, viewCards: {}, browserCards: {}, + workflowCards: {}, + workflowsHub: null, notes: {}, closedCardPositions: {}, glowingBrowserCards: {}, @@ -116,12 +150,16 @@ const initialState: DashboardLayoutState = { initialized: false, pendingFocusBrowserId: null, pendingFocusNoteId: null, + pendingFocusWorkflowId: null, + pendingFocusWorkflowsHub: false, }; interface LayoutPayload { cards: Record; viewCards: Record; browserCards: Record; + workflowCards: Record; + workflowsHub: WorkflowsHubPosition | null; notes: Record; expandedSessionIds: string[]; } @@ -154,6 +192,8 @@ export const fetchLayout = createAsyncThunk( cards: (layout.cards ?? {}) as Record, viewCards: (layout.view_cards ?? {}) as Record, browserCards: browserCards as Record, + workflowCards: (layout.workflow_cards ?? {}) as Record, + workflowsHub: (layout.workflows_hub ?? null) as WorkflowsHubPosition | null, notes: (layout.notes ?? {}) as Record, expandedSessionIds: (layout.expanded_session_ids ?? []) as string[], } satisfies LayoutPayload; @@ -175,6 +215,8 @@ export const saveLayout = createAsyncThunk( cards: payload.cards, view_cards: payload.viewCards, browser_cards: payload.browserCards, + workflow_cards: payload.workflowCards, + workflows_hub: payload.workflowsHub, notes: payload.notes, expanded_session_ids: payload.expandedSessionIds, }, @@ -211,6 +253,12 @@ function collectOccupiedRects( for (const c of Object.values(state.browserCards)) { rects.push({ x: c.x, y: c.y, w: c.width, h: c.height }); } + for (const w of Object.values(state.workflowCards)) { + rects.push({ x: w.x, y: w.y, w: w.width, h: w.height }); + } + if (state.workflowsHub) { + rects.push({ x: state.workflowsHub.x, y: state.workflowsHub.y, w: state.workflowsHub.width, h: state.workflowsHub.height }); + } for (const n of Object.values(state.notes)) { rects.push({ x: n.x, y: n.y, w: n.width, h: n.height }); } @@ -371,7 +419,7 @@ const dashboardLayoutSlice = createSlice({ bringToFront( state, - action: PayloadAction<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' }>, + action: PayloadAction<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' | 'workflow' | 'workflows-hub' }>, ) { const { id, type } = action.payload; const z = state.nextZOrder++; @@ -384,6 +432,11 @@ const dashboardLayoutSlice = createSlice({ } else if (type === 'note') { const note = state.notes[id]; if (note) note.zOrder = z; + } else if (type === 'workflow') { + const card = state.workflowCards[id]; + if (card) card.zOrder = z; + } else if (type === 'workflows-hub') { + if (state.workflowsHub) state.workflowsHub.zOrder = z; } else { const card = state.browserCards[id]; if (card) card.zOrder = z; @@ -439,13 +492,15 @@ const dashboardLayoutSlice = createSlice({ const agentCards = Object.values(state.cards); const viewCards = Object.values(state.viewCards); const bCards = Object.values(state.browserCards); - const total = agentCards.length + viewCards.length + bCards.length; + const wCards = Object.values(state.workflowCards); + const total = agentCards.length + viewCards.length + bCards.length + wCards.length; if (total === 0) return; const allItems = [ ...agentCards.map((c) => ({ kind: 'agent' as const, id: c.session_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })), ...viewCards.map((c) => ({ kind: 'view' as const, id: c.output_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })), ...bCards.map((c) => ({ kind: 'browser' as const, id: c.browser_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })), + ...wCards.map((c) => ({ kind: 'workflow' as const, id: c.workflow_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })), ]; allItems.sort((a, b) => a.y - b.y || a.x - b.x); @@ -470,6 +525,9 @@ const dashboardLayoutSlice = createSlice({ } else if (item.kind === 'view') { const card = state.viewCards[item.id]; if (card) { card.x = pos.x; card.y = pos.y; } + } else if (item.kind === 'workflow') { + const card = state.workflowCards[item.id]; + if (card) { card.x = pos.x; card.y = pos.y; } } else { const card = state.browserCards[item.id]; if (card) { card.x = pos.x; card.y = pos.y; } @@ -601,6 +659,129 @@ const dashboardLayoutSlice = createSlice({ delete state.browserCards[action.payload]; }, + addWorkflowCard( + state, + action: PayloadAction<{ + workflowId: string; + sourceSessionId?: string | null; + expandedSessionIds?: string[]; + }>, + ) { + const { workflowId, sourceSessionId, expandedSessionIds } = action.payload; + if (state.workflowCards[workflowId]) { + // Already on the canvas; just raise it and signal focus. + state.workflowCards[workflowId].zOrder = state.nextZOrder++; + state.pendingFocusWorkflowId = workflowId; + return; + } + const rects = collectOccupiedRects(state, expandedSessionIds); + let posX: number, posY: number; + const parentCard = sourceSessionId ? state.cards[sourceSessionId] : null; + if (parentCard) { + const anchorX = parentCard.x + parentCard.width + GRID_GAP * 6; + const anchorY = parentCard.y; + const pos = findOpenSpotNear(anchorX, anchorY, rects, DEFAULT_WORKFLOW_CARD_W, DEFAULT_WORKFLOW_CARD_H); + posX = pos.x; + posY = pos.y; + } else { + const pos = findOpenGridCell(rects, DEFAULT_WORKFLOW_CARD_W, DEFAULT_WORKFLOW_CARD_H); + posX = pos.x; + posY = pos.y; + } + state.workflowCards[workflowId] = { + workflow_id: workflowId, + x: posX, + y: posY, + width: DEFAULT_WORKFLOW_CARD_W, + height: DEFAULT_WORKFLOW_CARD_H, + zOrder: state.nextZOrder++, + source_session_id: sourceSessionId || null, + }; + state.pendingFocusWorkflowId = workflowId; + }, + + setWorkflowCardPosition( + state, + action: PayloadAction<{ workflowId: string; x: number; y: number }>, + ) { + const { workflowId, x, y } = action.payload; + const card = state.workflowCards[workflowId]; + if (card) { card.x = x; card.y = y; } + }, + + setWorkflowCardSize( + state, + action: PayloadAction<{ workflowId: string; width: number; height: number }>, + ) { + const { workflowId, width, height } = action.payload; + const card = state.workflowCards[workflowId]; + if (card) { + card.width = Math.max(360, width); + card.height = Math.max(280, height); + } + }, + + removeWorkflowCard(state, action: PayloadAction) { + delete state.workflowCards[action.payload]; + }, + + // When a draft workflow is saved, the temporary `draft-...` id is + // replaced by the real workflow id from the server. Rekey the layout + // entry in-place so the card doesn't visibly hop position. + rekeyWorkflowCard( + state, + action: PayloadAction<{ oldId: string; newId: string }>, + ) { + const { oldId, newId } = action.payload; + const card = state.workflowCards[oldId]; + if (!card) return; + delete state.workflowCards[oldId]; + state.workflowCards[newId] = { ...card, workflow_id: newId }; + if (state.pendingFocusWorkflowId === oldId) state.pendingFocusWorkflowId = newId; + }, + + clearPendingFocusWorkflowId(state) { + state.pendingFocusWorkflowId = null; + }, + + openWorkflowsHub(state, action: PayloadAction<{ expandedSessionIds?: string[] } | undefined>) { + if (state.workflowsHub) { + state.workflowsHub.zOrder = state.nextZOrder++; + state.pendingFocusWorkflowsHub = true; + return; + } + const rects = collectOccupiedRects(state, action.payload?.expandedSessionIds); + const pos = findOpenGridCell(rects, DEFAULT_WORKFLOWS_HUB_W, DEFAULT_WORKFLOWS_HUB_H); + state.workflowsHub = { + x: pos.x, + y: pos.y, + width: DEFAULT_WORKFLOWS_HUB_W, + height: DEFAULT_WORKFLOWS_HUB_H, + zOrder: state.nextZOrder++, + }; + state.pendingFocusWorkflowsHub = true; + }, + + clearPendingFocusWorkflowsHub(state) { + state.pendingFocusWorkflowsHub = false; + }, + + closeWorkflowsHub(state) { + state.workflowsHub = null; + }, + + setWorkflowsHubPosition(state, action: PayloadAction<{ x: number; y: number }>) { + if (!state.workflowsHub) return; + state.workflowsHub.x = action.payload.x; + state.workflowsHub.y = action.payload.y; + }, + + setWorkflowsHubSize(state, action: PayloadAction<{ width: number; height: number }>) { + if (!state.workflowsHub) return; + state.workflowsHub.width = Math.max(720, action.payload.width); + state.workflowsHub.height = Math.max(420, action.payload.height); + }, + pasteBrowserCard( state, action: PayloadAction<{ @@ -749,7 +930,7 @@ const dashboardLayoutSlice = createSlice({ moveCards( state, action: PayloadAction<{ - items: Array<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' }>; + items: Array<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' | 'workflow' }>; dx: number; dy: number; }>, @@ -774,6 +955,12 @@ const dashboardLayoutSlice = createSlice({ note.x += dx; note.y += dy; } + } else if (item.type === 'workflow') { + const card = state.workflowCards[item.id]; + if (card) { + card.x += dx; + card.y += dy; + } } else { const card = state.browserCards[item.id]; if (card) { @@ -901,6 +1088,8 @@ const dashboardLayoutSlice = createSlice({ state.cards = {}; state.viewCards = {}; state.browserCards = {}; + state.workflowCards = {}; + state.workflowsHub = null; state.notes = {}; state.closedCardPositions = {}; state.glowingBrowserCards = {}; @@ -909,6 +1098,7 @@ const dashboardLayoutSlice = createSlice({ state.nextZOrder = 1; state.initialized = false; state.pendingFocusNoteId = null; + state.pendingFocusWorkflowId = null; }, }, @@ -923,6 +1113,8 @@ const dashboardLayoutSlice = createSlice({ state.cards = action.payload.cards; state.viewCards = action.payload.viewCards; state.browserCards = action.payload.browserCards; + state.workflowCards = action.payload.workflowCards || {}; + state.workflowsHub = action.payload.workflowsHub || null; state.notes = action.payload.notes || {}; state.persistedExpandedSessionIds = action.payload.expandedSessionIds; @@ -940,6 +1132,10 @@ const dashboardLayoutSlice = createSlice({ if (!c.zOrder) c.zOrder = 0; if (c.zOrder > maxZ) maxZ = c.zOrder; } + for (const w of Object.values(state.workflowCards)) { + if (!w.zOrder) w.zOrder = 0; + if (w.zOrder > maxZ) maxZ = w.zOrder; + } for (const n of Object.values(state.notes)) { if (!n.zOrder) n.zOrder = 0; if (n.zOrder > maxZ) maxZ = n.zOrder; @@ -1010,6 +1206,17 @@ export const { fadeGlowingAgentCard, clearGlowingAgentCard, clearPendingFocusBrowserId, + addWorkflowCard, + setWorkflowCardPosition, + setWorkflowCardSize, + removeWorkflowCard, + rekeyWorkflowCard, + clearPendingFocusWorkflowId, + openWorkflowsHub, + closeWorkflowsHub, + setWorkflowsHubPosition, + setWorkflowsHubSize, + clearPendingFocusWorkflowsHub, addNote, setNotePosition, setNoteSize, diff --git a/frontend/src/shared/state/store.ts b/frontend/src/shared/state/store.ts index ada25436..89155cb4 100644 --- a/frontend/src/shared/state/store.ts +++ b/frontend/src/shared/state/store.ts @@ -15,6 +15,7 @@ import updateReducer from './updateSlice'; import modelsReducer from './modelsSlice'; import interactionReducer from './interactionSlice'; import subscriptionsReducer from './subscriptionsSlice'; +import workflowsReducer from './workflowsSlice'; import onboardingProgressReducer from '@/app/components/Onboarding/OnboardingProgressSlice'; export const store = configureStore({ @@ -35,6 +36,7 @@ export const store = configureStore({ models: modelsReducer, interaction: interactionReducer, subscriptions: subscriptionsReducer, + workflows: workflowsReducer, onboardingProgress: onboardingProgressReducer, }, // Disable Redux Toolkit's dev-mode invariant middleware (serializable + diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts new file mode 100644 index 00000000..1c435776 --- /dev/null +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -0,0 +1,210 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { API_BASE } from '@/shared/config'; + +const API = `${API_BASE}/workflows`; + +export type PermissionKind = 'notify' | 'text' | 'call'; + +export interface PermissionTier { + kind: PermissionKind; + after_minutes: number; + phone?: string | null; +} + +export interface ScheduleConfig { + enabled: boolean; + repeat_every: number; + repeat_unit: 'day' | 'week' | 'month'; + on_days: number[]; + hour: number; + minute: number; + timezone: string; + on_missed: 'skip' | 'run_once' | 'run_all'; +} + +export interface ActionsConfig { + prevent_unused: boolean; + freeze: boolean; + configured_sets: string[]; +} + +export interface WorkflowStep { + id: string; + text: string; +} + +export interface Workflow { + id: string; + title: string; + description: string; + icon: string; + system_prompt: string | null; + use_synced_prompt: boolean; + steps: WorkflowStep[]; + actions: ActionsConfig; + schedule: ScheduleConfig; + permissions: PermissionTier[]; + source_session_id?: string | null; + dashboard_id?: string | null; + model: string; + mode: string; + provider: string; + created_at: string; + updated_at: string; + last_run_at: string | null; + last_run_status: 'success' | 'failure' | 'ran_late' | 'running' | null; + last_run_id: string | null; + next_run_at: string | null; +} + +export interface WorkflowRun { + id: string; + workflow_id: string; + status: 'running' | 'success' | 'failure' | 'ran_late' | 'skipped'; + scheduled_for: string | null; + started_at: string; + finished_at: string | null; + session_id: string | null; + error: string | null; + cost_usd: number; + triggered_by: 'schedule' | 'manual' | 'retry'; +} + +// Position lives in dashboardLayoutSlice.workflowCards now. This entry +// only carries transient view state — which tab is open, draft contents +// for unsaved cards, the currently inspected history run, etc. +export interface OpenCard { + workflowId: string; + sourceSessionId?: string | null; + draft?: Partial | null; + view: 'preview' | 'saved' | 'edit' | 'history' | 'history_detail'; + editFacet?: 'General' | 'Actions' | 'Schedule'; + historyRunId?: string | null; +} + +interface State { + items: Record; + runs: Record; + openCards: Record; + loaded: boolean; + loading: boolean; +} + +const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false }; + +export const fetchWorkflows = createAsyncThunk( + 'workflows/fetch', + async (dashboardId?: string) => { + const url = dashboardId ? `${API}/list?dashboard_id=${encodeURIComponent(dashboardId)}` : `${API}/list`; + const res = await fetch(url); + const data = await res.json(); + return data.workflows as Workflow[]; + }, + { condition: (_, { getState }) => !(getState() as { workflows: State }).workflows.loading }, +); + +export const createWorkflow = createAsyncThunk( + 'workflows/create', + async (body: Partial) => { + const res = await fetch(`${API}/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`create failed ${res.status}`); + return (await res.json()) as Workflow; + }, +); + +export const updateWorkflow = createAsyncThunk( + 'workflows/update', + async ({ id, patch }: { id: string; patch: Partial }) => { + const res = await fetch(`${API}/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + if (!res.ok) throw new Error(`update failed ${res.status}`); + return (await res.json()) as Workflow; + }, +); + +export const deleteWorkflow = createAsyncThunk('workflows/delete', async (id: string) => { + await fetch(`${API}/${id}`, { method: 'DELETE' }); + return id; +}); + +export const runWorkflowNow = createAsyncThunk('workflows/run', async (id: string) => { + const res = await fetch(`${API}/${id}/run`, { method: 'POST' }); + if (!res.ok) throw new Error(`run failed ${res.status}`); + const data = await res.json(); + return { id, run_id: data.run_id as string }; +}); + +export const fetchRuns = createAsyncThunk( + 'workflows/runs', + async (id: string) => { + const res = await fetch(`${API}/${id}/runs?limit=50`); + const data = await res.json(); + return { id, runs: data.runs as WorkflowRun[] }; + }, +); + +const slice = createSlice({ + name: 'workflows', + initialState, + reducers: { + openWorkflowCard(state, action: { payload: OpenCard }) { + state.openCards[action.payload.workflowId] = action.payload; + }, + updateWorkflowCard(state, action: { payload: { workflowId: string; patch: Partial } }) { + const existing = state.openCards[action.payload.workflowId]; + if (existing) state.openCards[action.payload.workflowId] = { ...existing, ...action.payload.patch }; + }, + closeWorkflowCard(state, action: { payload: string }) { + delete state.openCards[action.payload]; + }, + rekeyOpenCard(state, action: { payload: { oldId: string; newId: string } }) { + const entry = state.openCards[action.payload.oldId]; + if (!entry) return; + delete state.openCards[action.payload.oldId]; + state.openCards[action.payload.newId] = { ...entry, workflowId: action.payload.newId }; + }, + upsertRun(state, action: { payload: WorkflowRun }) { + const r = action.payload; + const arr = state.runs[r.workflow_id] || []; + const idx = arr.findIndex((x) => x.id === r.id); + if (idx >= 0) arr[idx] = r; else arr.unshift(r); + state.runs[r.workflow_id] = arr.slice(0, 100); + const wf = state.items[r.workflow_id]; + if (wf) { + wf.last_run_at = r.finished_at || r.started_at; + wf.last_run_status = r.status === 'skipped' ? wf.last_run_status : (r.status as Workflow['last_run_status']); + wf.last_run_id = r.id; + } + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchWorkflows.pending, (state) => { state.loading = true; }) + .addCase(fetchWorkflows.fulfilled, (state, action) => { + state.loading = false; + state.loaded = true; + state.items = {}; + for (const w of action.payload) state.items[w.id] = w; + }) + .addCase(fetchWorkflows.rejected, (state) => { state.loading = false; state.loaded = true; }) + .addCase(createWorkflow.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; }) + .addCase(updateWorkflow.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; }) + .addCase(deleteWorkflow.fulfilled, (state, action) => { + delete state.items[action.payload]; + delete state.runs[action.payload]; + }) + .addCase(fetchRuns.fulfilled, (state, action) => { + state.runs[action.payload.id] = action.payload.runs; + }); + }, +}); + +export const { upsertRun, openWorkflowCard, updateWorkflowCard, closeWorkflowCard, rekeyOpenCard } = slice.actions; +export default slice.reducer; diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index fad8d98d..e156a2a5 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -24,6 +24,7 @@ import { } from '../state/agentsSlice'; import { streamStart, streamDelta, streamEnd } from '../state/streamingSlice'; import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice'; +import { upsertRun } from '../state/workflowsSlice'; import { getAuthToken } from '../config'; import { notifyAgentCompletion } from '../notifications'; @@ -687,6 +688,22 @@ class WebSocketManager { } break; + case 'workflow:run': + if (data.run) { + store.dispatch(upsertRun(data.run)); + } + break; + + case 'workflow:notify': + try { + notifyAgentCompletion({ + sessionId: data.session_id || data.workflow_id, + sessionName: data.workflow_title || 'Workflow', + status: data.status === 'success' ? 'completed' : 'error', + }); + } catch { /* notifications are best-effort */ } + break; + case 'dashboard:browser_card_added': if (data.browser_card) { store.dispatch(addBrowserCardFromBackend(data.browser_card));