[eric] workflows: remove scheduled-tasks end-to-end (frontend pages+slice, backend app+routes, electron poller), keep dock chat; bump 1.1.64

This commit is contained in:
Eric
2026-05-26 11:12:29 -07:00
parent bf6d7a9235
commit 3509c08057
56 changed files with 89 additions and 11121 deletions
+10 -43
View File
@@ -1061,23 +1061,6 @@ class AgentManager:
mcp_registry_ctx = self._build_mcp_registry_summary(session.allowed_tools, session.active_mcps)
global_settings = load_settings()
# Nudge the agent to surface ScheduleWorkflow proactively when
# the user's ask looks recurring. The per-tool description
# carries the full protocol; this is just the "when to think
# about it" signal so the agent doesn't ignore the surface.
schedule_ctx = (
"<scheduling_guidance>\n"
"After completing a substantive task, if the work looks "
"repeatable (the user said 'every', 'each', 'daily', "
"'weekly', 'morning', 'before standup', or you just did "
"the same sequence twice in this session), offer to "
"schedule it. Use AskUserQuestion to confirm cadence, "
"then ScheduleWorkflow to create it. Never reach for "
"crontab, launchctl, or schtasks; always use the native "
"scheduler so the user can see, pause, and edit it. "
"Don't ask after trivial one-off requests.\n"
"</scheduling_guidance>"
)
composed_prompt = self._compose_system_prompt(
global_settings.default_system_prompt,
mode_sys_prompt,
@@ -1086,15 +1069,20 @@ class AgentManager:
browser_ctx,
mcp_registry_ctx,
)
composed_prompt = (composed_prompt + "\n\n" + schedule_ctx) if composed_prompt else schedule_ctx
# Pin the agent's notion of "now" to the host wall clock + zone
# so it can answer day-of-week questions and pick sensible
# cadences ("every Friday afternoon") without hallucinating.
# so it can answer day-of-week questions without hallucinating.
try:
from zoneinfo import ZoneInfo
from backend.apps.workflows.storage import _resolve_host_tz_name
tz_name = _resolve_host_tz_name()
# Best-effort IANA name for the host. Mirrors apps/service/client.py.
tz_name = os.environ.get("OPENSWARM_TIMEZONE", "").strip()
if not tz_name:
try:
from tzlocal import get_localzone_name # type: ignore
tz_name = get_localzone_name() or ""
except Exception:
tz_name = ""
tz_name = tz_name or "UTC"
now_local = datetime.now(ZoneInfo(tz_name))
tz_abbr = now_local.strftime("%Z") or tz_name
time_ctx = (
@@ -1192,27 +1180,6 @@ class AgentManager:
"type": "stdio",
}
# Always-on schedule server. Exposes ScheduleWorkflow + CRUD
# tools so the agent can offer to schedule recurring work via
# the native scheduler (visible, auditable) rather than reach
# for cron/launchctl. Tool descriptions tell the agent to
# AskUserQuestion FIRST to confirm cadence with the user.
schedule_server_path = os.path.join(
os.path.dirname(__file__), "schedule_mcp_server.py"
)
from backend.auth import get_auth_token as _get_auth_token_sched
mcp_servers["openswarm-schedule"] = {
"command": sys.executable,
"args": [schedule_server_path],
"env": {
"OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"),
"OPENSWARM_AUTH_TOKEN": _get_auth_token_sched(),
"OPENSWARM_PARENT_SESSION_ID": session.id,
"OPENSWARM_DASHBOARD_ID": session.dashboard_id or "",
},
"type": "stdio",
}
# Always-on meta-MCP server. Exposes MCPList / MCPSearch /
# MCPActivate so the model can discover and activate user MCPs at
# runtime. The activation gate (active_mcps filter in
-412
View File
@@ -1,412 +0,0 @@
#!/usr/bin/env python3
"""Stdio MCP server exposing scheduled-workflow tools to the agent.
Why this exists: the agent should be able to schedule recurring work on
the user's behalf, but ALWAYS through the native scheduler (visible,
auditable, cost-capped) rather than `crontab`. Each tool is a thin
wrapper around /api/workflows/*. The descriptions are written to nudge
the agent toward AskUserQuestion-first behavior (confirm cadence with
the user before calling ScheduleWorkflow).
"""
import json
import sys
import os
import urllib.request
import urllib.error
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
BACKEND_BASE = f"http://127.0.0.1:{BACKEND_PORT}/api/workflows"
PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "")
PRESETS = {
"daily_morning": {"enabled": True, "repeat_unit": "day", "repeat_every": 1, "hour": 9, "minute": 0, "on_days": []},
"weekdays_morning": {"enabled": True, "repeat_unit": "week", "repeat_every": 1, "hour": 9, "minute": 0, "on_days": [1, 2, 3, 4, 5]},
"weekly_monday": {"enabled": True, "repeat_unit": "week", "repeat_every": 1, "hour": 9, "minute": 0, "on_days": [1]},
"weekly_friday": {"enabled": True, "repeat_unit": "week", "repeat_every": 1, "hour": 17, "minute": 0, "on_days": [5]},
"monthly_first": {"enabled": True, "repeat_unit": "month", "repeat_every": 1, "hour": 9, "minute": 0, "on_days": []},
}
TOOLS = [
{
"name": "ScheduleWorkflow",
"description": (
"Create a recurring scheduled workflow for the user. Use this "
"ONLY after confirming cadence with the user via AskUserQuestion "
"(do not assume — the user must pick or accept the time). "
"The workflow runs the listed steps on the schedule and is "
"visible in the user's Workflows hub. Never use crontab, "
"launchctl, or schtasks to schedule recurring work; always use "
"this tool so the user can see, pause, edit, or delete it. "
"After creating, briefly confirm to the user what was scheduled."
),
"inputSchema": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Short workflow name shown in the hub and on the dashboard card."},
"steps": {
"type": "array",
"items": {"type": "string"},
"description": "Ordered list of instructions for the agent to execute on each fire. Each string is one step.",
},
"preset": {
"type": "string",
"enum": ["daily_morning", "weekdays_morning", "weekly_monday", "weekly_friday", "monthly_first", "custom"],
"description": "Cadence preset. Use 'custom' to specify your own hour/minute/days.",
},
"hour": {"type": "integer", "description": "Hour 0-23 in the user's local time. Required when preset='custom'."},
"minute": {"type": "integer", "description": "Minute 0/15/30/45. Required when preset='custom'."},
"repeat_unit": {"type": "string", "enum": ["day", "week", "month"], "description": "Required when preset='custom'."},
"on_days": {
"type": "array",
"items": {"type": "integer"},
"description": "Weekdays (Sun=0..Sat=6) when preset='custom' and repeat_unit='week'.",
},
"source_session_id": {"type": "string", "description": "Optional; the chat session this workflow was created from. Inherits its tool surface."},
},
"required": ["title", "steps", "preset"],
},
},
{
"name": "ListScheduledWorkflows",
"description": "List the user's scheduled workflows. Use this to find a workflow the user is referring to before editing or deleting it.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "UpdateScheduledWorkflow",
"description": "Modify an existing scheduled workflow. Only pass the fields you want to change. Always confirm with the user via AskUserQuestion before making changes that meaningfully alter behavior (cadence, steps, permissions).",
"inputSchema": {
"type": "object",
"properties": {
"workflow_id": {"type": "string"},
"title": {"type": "string"},
"steps": {"type": "array", "items": {"type": "string"}},
"schedule_enabled": {"type": "boolean", "description": "Quick on/off without changing other schedule fields."},
"hour": {"type": "integer"},
"minute": {"type": "integer"},
"repeat_unit": {"type": "string", "enum": ["day", "week", "month"]},
"on_days": {"type": "array", "items": {"type": "integer"}},
},
"required": ["workflow_id"],
},
},
{
"name": "DeleteScheduledWorkflow",
"description": "Permanently delete a scheduled workflow. Cannot be undone. ALWAYS confirm via AskUserQuestion before calling this — the user should pick from a list, not have you guess.",
"inputSchema": {
"type": "object",
"properties": {"workflow_id": {"type": "string"}},
"required": ["workflow_id"],
},
},
{
"name": "PauseAllWorkflows",
"description": "Globally pause every scheduled workflow. In-flight runs finish; future runs are blocked until resumed. Use when the user wants a temporary stop (vacation, debugging) without deleting workflows.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "ResumeAllWorkflows",
"description": "Resume scheduled workflows after a previous PauseAllWorkflows.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "RunWorkflowNow",
"description": "Trigger an immediate one-off run of a scheduled workflow. The schedule continues to fire on its normal cadence in addition.",
"inputSchema": {
"type": "object",
"properties": {"workflow_id": {"type": "string"}},
"required": ["workflow_id"],
},
},
{
"name": "EditWorkflowStep",
"description": (
"Edit a single step's prompt text on an existing workflow. Use "
"when the user has accepted a proposed change during an Edit "
"Agent conversation; the new prompt replaces the existing one "
"and persists immediately. The next scheduled run uses the new "
"version. Always confirm the change with the user before "
"calling this; AskUserQuestion FIRST if there is any ambiguity."
),
"inputSchema": {
"type": "object",
"properties": {
"workflow_id": {"type": "string", "description": "The workflow to edit."},
"step_idx": {"type": "integer", "description": "0-based index of the step to modify."},
"new_text": {"type": "string", "description": "Full replacement prompt text for the step."},
},
"required": ["workflow_id", "step_idx", "new_text"],
},
},
{
"name": "TestWorkflow",
"description": (
"Spawn a sibling Test Agent that runs the workflow end-to-end "
"(with the latest persisted steps) so the user can watch it "
"work. Use after editing a step to verify the change. The Test "
"Agent renders as a sibling card on the dashboard with a "
"'Testing' arrow chip linking back to this workflow."
),
"inputSchema": {
"type": "object",
"properties": {
"workflow_id": {"type": "string", "description": "The workflow to test."},
},
"required": ["workflow_id"],
},
},
]
def send_response(id_, result=None, error=None):
msg = {"jsonrpc": "2.0", "id": id_}
if error is not None:
msg["error"] = error
else:
msg["result"] = result
sys.stdout.write(json.dumps(msg) + "\n")
sys.stdout.flush()
def _call(method: str, path: str, body=None) -> dict:
url = BACKEND_BASE + path
data = json.dumps(body).encode() if body is not None else None
headers = {"Content-Type": "application/json"}
if BACKEND_AUTH:
headers["Authorization"] = f"Bearer {BACKEND_AUTH}"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode() or "null") or {}
except urllib.error.HTTPError as e:
body_err = e.read().decode() if e.fp else str(e)
return {"_error": f"HTTP {e.code}: {body_err}"}
except Exception as e:
return {"_error": str(e)}
def _build_schedule_from_preset(preset: str, args: dict) -> dict:
base = {"timezone": "local", "on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0}
if preset == "custom":
return {
**base,
"enabled": True,
"repeat_unit": args.get("repeat_unit", "day"),
"repeat_every": 1,
"hour": int(args.get("hour", 9)),
"minute": int(args.get("minute", 0)),
"on_days": list(args.get("on_days") or []),
}
preset_def = PRESETS.get(preset)
if not preset_def:
return {}
return {**base, **preset_def, "repeat_every": 1}
def handle_schedule_workflow(args: dict) -> dict:
title = args.get("title") or "Scheduled workflow"
steps_in = args.get("steps") or []
preset = args.get("preset") or "daily_morning"
schedule = _build_schedule_from_preset(preset, args)
if not schedule:
return _err(f"Unknown preset: {preset}. Use one of: {list(PRESETS.keys()) + ['custom']}.")
body = {
"title": title,
"steps": [{"id": f"s{i+1}", "text": s} for i, s in enumerate(steps_in) if s],
"schedule": schedule,
"source_session_id": args.get("source_session_id") or PARENT_SESSION_ID or None,
}
r = _call("POST", "/create", body)
if "_error" in r:
return _err(r["_error"])
wid = r.get("id", "")
nxt = r.get("next_run_at") or "soon"
return _ok(f"Scheduled \"{title}\" ({preset}). Workflow id: {wid}. Next run: {nxt}. The user can view, pause, or edit it in the Workflows hub.")
def handle_list(_args: dict) -> dict:
r = _call("GET", "/list")
if "_error" in r:
return _err(r["_error"])
ws = r.get("workflows", [])
if not ws:
return _ok("No scheduled workflows yet.")
lines = ["Scheduled workflows:"]
for w in ws:
s = w.get("schedule") or {}
enabled = s.get("enabled")
unit = s.get("repeat_unit", "?")
hour = s.get("hour")
title = w.get("title", "(untitled)")
wid = w.get("id", "")
state = "ON" if enabled else "off"
lines.append(f" - {title} [{state}] {unit} at {hour:02d}:00 (id: {wid})")
return _ok("\n".join(lines))
def handle_update(args: dict) -> dict:
wid = args.get("workflow_id") or ""
if not wid:
return _err("workflow_id is required.")
cur = _call("GET", f"/{wid}")
if "_error" in cur:
return _err(cur["_error"])
sched = cur.get("schedule") or {}
patch: dict = {}
if "title" in args: patch["title"] = args["title"]
if "steps" in args:
patch["steps"] = [{"id": f"s{i+1}", "text": s} for i, s in enumerate(args["steps"] or []) if s]
sched_patch = dict(sched)
sched_dirty = False
if "schedule_enabled" in args:
sched_patch["enabled"] = bool(args["schedule_enabled"])
sched_dirty = True
for k in ("hour", "minute", "repeat_unit", "on_days"):
if k in args:
sched_patch[k] = args[k]
sched_dirty = True
if sched_dirty:
patch["schedule"] = sched_patch
if not patch:
return _ok(f"No changes requested for workflow {wid}.")
r = _call("PATCH", f"/{wid}", patch)
if "_error" in r:
return _err(r["_error"])
return _ok(f"Updated \"{r.get('title', wid)}\". Next run: {r.get('next_run_at') or 'paused/unscheduled'}.")
def handle_delete(args: dict) -> dict:
wid = args.get("workflow_id") or ""
if not wid:
return _err("workflow_id is required.")
r = _call("DELETE", f"/{wid}")
if "_error" in r:
return _err(r["_error"])
return _ok(f"Deleted workflow {wid}.")
def handle_pause_all(_args: dict) -> dict:
r = _call("POST", "/pause-all")
if "_error" in r:
return _err(r["_error"])
return _ok("All scheduled workflows are paused. In-flight runs will finish; future fires are blocked. Resume with ResumeAllWorkflows.")
def handle_resume_all(_args: dict) -> dict:
r = _call("POST", "/resume-all")
if "_error" in r:
return _err(r["_error"])
return _ok("Scheduled workflows resumed.")
def handle_run_now(args: dict) -> dict:
wid = args.get("workflow_id") or ""
if not wid:
return _err("workflow_id is required.")
r = _call("POST", f"/{wid}/run")
if "_error" in r:
return _err(r["_error"])
if r.get("status") == "skipped":
return _ok(f"Run was skipped: {r.get('error', 'unknown reason')}.")
return _ok(f"Run started (run id: {r.get('run_id', '')}). Output will appear in the workflow's History.")
def _ok(text: str) -> dict:
return {"content": [{"type": "text", "text": text}]}
def _err(text: str) -> dict:
return {"content": [{"type": "text", "text": f"Error: {text}"}], "isError": True}
def handle_edit_step(args: dict) -> dict:
wid = args.get("workflow_id") or ""
if not wid:
return _err("workflow_id is required.")
try:
idx = int(args.get("step_idx"))
except (TypeError, ValueError):
return _err("step_idx must be an integer.")
new_text = (args.get("new_text") or "").strip()
if not new_text:
return _err("new_text is required.")
cur = _call("GET", f"/{wid}")
if "_error" in cur:
return _err(cur["_error"])
steps = cur.get("steps") or []
if idx < 0 or idx >= len(steps):
return _err(f"step_idx {idx} out of range (workflow has {len(steps)} steps).")
new_steps = list(steps)
new_steps[idx] = {**new_steps[idx], "text": new_text}
r = _call("PATCH", f"/{wid}", {"steps": new_steps})
if "_error" in r:
return _err(r["_error"])
return _ok(f"Step {idx + 1} updated. The next run uses the new prompt.")
def handle_test_workflow(args: dict) -> dict:
wid = args.get("workflow_id") or ""
if not wid:
return _err("workflow_id is required.")
r = _call("POST", f"/{wid}/test-run", {})
if "_error" in r:
return _err(r["_error"])
sid = r.get("session_id", "")
return _ok(f"Test Agent spawned (session {sid[:8]}...). It runs the latest workflow on the dashboard with a Testing arrow chip.")
HANDLERS = {
"ScheduleWorkflow": handle_schedule_workflow,
"ListScheduledWorkflows": handle_list,
"UpdateScheduledWorkflow": handle_update,
"DeleteScheduledWorkflow": handle_delete,
"PauseAllWorkflows": handle_pause_all,
"ResumeAllWorkflows": handle_resume_all,
"RunWorkflowNow": handle_run_now,
"EditWorkflowStep": handle_edit_step,
"TestWorkflow": handle_test_workflow,
}
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
method = msg.get("method")
id_ = msg.get("id")
params = msg.get("params", {})
if method == "initialize":
send_response(id_, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "openswarm-schedule", "version": "1.0.0"},
})
elif method == "notifications/initialized":
pass
elif method == "tools/list":
send_response(id_, {"tools": TOOLS})
elif method == "tools/call":
tool_name = params.get("name", "")
arguments = params.get("arguments", {})
handler = HANDLERS.get(tool_name)
if handler is None:
send_response(id_, _err(f"Unknown tool: {tool_name}"))
else:
send_response(id_, handler(arguments))
elif method == "ping":
send_response(id_, {})
elif id_ is not None:
send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"})
if __name__ == "__main__":
main()
+2 -7
View File
@@ -53,17 +53,12 @@ class NotePosition(BaseModel):
class DashboardLayout(BaseModel):
# Accept whatever the FE serialises (workflow_cards, configure_panels,
# workflows_hub etc). Pydantic was silently stripping these because
# they weren't declared, which made the dashboard re-render WITHOUT
# the workflow card the user just placed.
# extra="allow" so any keys the FE sends (or legacy on-disk layouts
# carry) round-trip without Pydantic stripping them.
model_config = ConfigDict(extra="allow")
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 = Field(default_factory=dict)
configure_panels: dict = Field(default_factory=dict)
workflows_hub: Optional[dict] = None
notes: dict[str, NotePosition] = Field(default_factory=dict)
expanded_session_ids: list[str] = Field(default_factory=list)
View File
-95
View File
@@ -1,95 +0,0 @@
"""Append-only audit log for workflow edits.
One JSONL file per workflow at <DATA_ROOT>/workflows/audit/<wid>.jsonl. We
diff before/after rather than snapshotting the full record so the file
stays small even after dozens of edits. Read path tails the file; we don't
keep this in memory because audits are inspected rarely.
"""
import json
import logging
import os
from datetime import datetime, timezone
from threading import Lock
from typing import Any
from backend.apps.workflows.storage import DATA_DIR
logger = logging.getLogger(__name__)
AUDIT_DIR = os.path.join(DATA_DIR, "audit")
_io_lock = Lock()
# Soft cap on bytes per audit file. When exceeded we truncate to the last
# CAP/2 bytes on next write so attackers (or a runaway PATCH loop) can't
# fill the disk. 256 KiB is ~2000 edits; we never expect to hit it.
SOFT_CAP_BYTES = 256 * 1024
def _audit_path(wid: str) -> str:
return os.path.join(AUDIT_DIR, f"{wid}.jsonl")
def _diff(before: dict, after: dict) -> dict[str, dict[str, Any]]:
"""Return only the keys whose value changed. Nested dicts are diffed
shallowly; the schedule/actions/permissions blocks are small so we just
record the whole sub-dict when any sub-key changes.
"""
changed: dict[str, dict[str, Any]] = {}
keys = set(before) | set(after)
for k in keys:
b = before.get(k)
a = after.get(k)
if b != a:
changed[k] = {"before": b, "after": a}
return changed
def log_change(wid: str, who: str, before: dict, after: dict) -> None:
diff = _diff(before, after)
if not diff:
return
entry = {
"ts": datetime.now(timezone.utc).isoformat(),
"who": who,
"diff": diff,
}
try:
with _io_lock:
os.makedirs(AUDIT_DIR, exist_ok=True)
path = _audit_path(wid)
if os.path.exists(path) and os.path.getsize(path) > SOFT_CAP_BYTES:
# Keep the tail half. Cheap, lossy, prevents pathological
# disk growth without crashing on a corrupt file.
with open(path, "rb") as f:
f.seek(-(SOFT_CAP_BYTES // 2), os.SEEK_END)
tail = f.read()
first_nl = tail.find(b"\n")
tail = tail[first_nl + 1:] if first_nl >= 0 else b""
with open(path, "wb") as f:
f.write(tail)
with open(path, "a") as f:
f.write(json.dumps(entry) + "\n")
except Exception:
logger.debug("audit log_change failed", exc_info=True)
def read_tail(wid: str, limit: int = 50) -> list[dict]:
path = _audit_path(wid)
if not os.path.exists(path):
return []
try:
with open(path) as f:
lines = f.readlines()
except Exception:
return []
out: list[dict] = []
for line in lines[-limit:]:
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except Exception:
continue
out.reverse()
return out
-90
View File
@@ -1,90 +0,0 @@
"""Server-side escalation timer.
The permission chain in the UI (notify -> text -> call) used to time out
client-side, which dies the moment the window closes. We move the timer
here so a run that finishes at 9am can escalate to a real text at 9:05am
whether or not the user has the app open. The text/call wire-up itself
still routes through notifier (cloud SMS bridge is wired separately); we
just own the *when*.
State lives in module-scoped dicts, not on disk. If the backend restarts
mid-escalation the chain is lost on purpose: the user is already in front
of an open app at that point (otherwise the backend wouldn't have started)
and they can ack manually. Persisting escalation state would mean
re-firing on a stale schedule after a multi-day downtime, which is worse.
"""
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
from backend.apps.workflows.models import PermissionTier, Workflow, WorkflowRun
logger = logging.getLogger(__name__)
_tasks: dict[str, asyncio.Task] = {} # run_id -> escalation task
_state: dict[str, dict] = {} # run_id -> {tier_idx, next_at, kind}
def _tier_delay_seconds(tier: PermissionTier) -> int:
"""Tier minutes/hours convention matches the FE: text uses minutes,
call uses hours (the UI label flips with tier.kind). We translate at
the boundary so the backend math is always in seconds."""
if tier.kind == "call":
return max(0, tier.after_minutes) * 3600
return max(0, tier.after_minutes) * 60
def schedule(wf: Workflow, run: WorkflowRun) -> None:
"""Kick off escalation for a finished run. No-op if the workflow has
only the default notify tier (i.e. nothing to escalate to)."""
tiers = wf.permissions or []
if len(tiers) <= 1:
return
# Cancel any prior task for this run (defense against a re-fire).
cancel(run.id)
task = asyncio.create_task(_runner(wf, run, tiers))
_tasks[run.id] = task
def cancel(run_id: str) -> bool:
task = _tasks.pop(run_id, None)
_state.pop(run_id, None)
if task is None:
return False
task.cancel()
return True
def status(run_id: str) -> Optional[dict]:
return _state.get(run_id)
async def _runner(wf: Workflow, run: WorkflowRun, tiers: list[PermissionTier]) -> None:
from backend.apps.workflows.notifier import send_tier
try:
# Tier 0 is the initial notify; we don't re-fire it here. Walk
# 1..N, sleeping the tier's delay before sending. If the user acks
# via /workflows/runs/{run_id}/ack, the task is cancelled.
for idx in range(1, len(tiers)):
tier = tiers[idx]
delay = _tier_delay_seconds(tier)
fire_at = datetime.now(timezone.utc) + timedelta(seconds=delay)
_state[run.id] = {
"tier_idx": idx,
"tier_kind": tier.kind,
"next_at": fire_at.isoformat(),
}
await asyncio.sleep(delay)
try:
await send_tier(wf, run, tier)
except Exception:
logger.exception("escalation send_tier failed run=%s tier=%s", run.id, tier.kind)
except asyncio.CancelledError:
pass
finally:
_state.pop(run.id, None)
_tasks.pop(run.id, None)
-334
View File
@@ -1,334 +0,0 @@
"""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, timedelta, timezone
from typing import Optional
from backend.apps.agents.core.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)
def _persist_run_fields(wf: Workflow, run_fields: dict, schedule_runs_count_delta: int = 0) -> None:
"""Merge run-side fields into the current on-disk workflow.
The executor holds the `wf` it was launched with; meanwhile the user
may have PATCHed unrelated fields (title, schedule, permissions...).
Saving our captured `wf` would clobber those edits. Re-read the
authoritative record from storage and only mutate the run-side fields
we own. If the workflow has been deleted while we ran, silently skip
the save so we don't resurrect a deleted record.
schedule_runs_count_delta is a small int (0 or 1) that we add to the
on-disk schedule.runs_count to avoid the same race overwriting an
in-flight bump on the user's PATCH path.
"""
fresh = storage.get_workflow(wf.id)
if fresh is None:
# Deleted while we ran. Don't resurrect.
return
for k, v in run_fields.items():
setattr(fresh, k, v)
if schedule_runs_count_delta:
fresh.schedule.runs_count = fresh.schedule.runs_count + schedule_runs_count_delta
storage.save_workflow(fresh)
def _monthly_spend_so_far(wf: Workflow) -> float:
"""Sum cost_usd across runs of `wf` started in the last 30 days.
Reads the bounded run log (200 rows max per workflow), so this is
O(history) and runs once per fire. Naive datetimes (legacy rows) are
treated as host-local then normalized to UTC by Python's astimezone.
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
total = 0.0
for r in storage.list_runs(wf.id, limit=200):
started = r.started_at
if started is None:
continue
if started.tzinfo is None:
started = started.astimezone(timezone.utc)
else:
started = started.astimezone(timezone.utc)
if started >= cutoff:
total += float(r.cost_usd or 0.0)
return total
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,
)
# Cost cap pre-check happens before claiming `_running` so a capped
# workflow doesn't block its own next fire. We still record the run so
# the user sees it in History with a clear reason.
if wf.cost_cap_usd_monthly is not None:
spent = _monthly_spend_so_far(wf)
if spent >= wf.cost_cap_usd_monthly:
run.status = "skipped"
run.error = f"Monthly cost cap reached (${spent:.2f} / ${wf.cost_cap_usd_monthly:.2f})"
run.finished_at = datetime.now()
storage.record_run(run)
_persist_run_fields(wf, {
"last_run_at": run.finished_at,
"last_run_status": "skipped",
"last_run_id": run.id,
})
return run
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
_persist_run_fields(wf, {
"last_run_at": run.started_at,
"last_run_status": "running",
"last_run_id": run.id,
})
session = None
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)
# Background poller: surface the latest tool-call name as a
# live "what's the agent doing" subtitle on the workflow:run
# ws event. Cheap enough to run at 1.5s cadence; nothing else
# is watching session.messages from here. Cancelled in the
# finally block alongside _running cleanup.
async def _watch_tool_calls() -> None:
last_seen = ""
while True:
try:
await asyncio.sleep(1.5)
sess = agent_manager.sessions.get(session.id)
if not sess:
return
msgs = getattr(sess, "messages", []) or []
label = ""
for m in reversed(msgs):
if getattr(m, "role", None) != "tool_call":
continue
content = getattr(m, "content", None)
# Content can be a string, a dict with "name", or
# a list of blocks. Pick the first tool_use name.
if isinstance(content, list):
for b in content:
if isinstance(b, dict) and b.get("type") == "tool_use":
label = str(b.get("name") or "")
break
elif isinstance(content, dict):
label = str(content.get("name") or "")
elif isinstance(content, str):
label = content[:60]
if label:
break
if label and label != last_seen:
last_seen = label
run.last_tool_label = label
try:
from backend.apps.agents.core.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
except asyncio.CancelledError:
return
except Exception:
return
watcher_task = asyncio.create_task(_watch_tool_calls())
# 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 idx, step in enumerate(steps):
# Broadcast the step bump before sending so RunningView flips
# the disc immediately, not after the agent finishes the step.
run.active_step_idx = idx
run.last_tool_label = None
try:
from backend.apps.agents.core.ws_manager import ws_manager as _wsm
await _wsm.broadcast_global("workflow:run", {
"workflow_id": wf.id,
"run": run.model_dump(mode="json"),
})
except Exception:
pass
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"
# Pin active step so FailedView can render the X on the
# right row. error_step_idx == active_step_idx at fail time.
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.replace(tzinfo=None) - scheduled_for.replace(tzinfo=None)).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".
# Strip tz before the subtraction so a UTC-aware scheduled_for
# (new code path) and a naive finished_at don't raise.
run.status = "ran_late"
wf.last_run_status = "ran_late"
else:
run.status = "success"
wf.last_run_status = "success"
# Bump runs_count for scheduled fires that reached a terminal state
# other than "skipped". Manual runs don't count against max_runs.
runs_delta = 1 if (triggered_by == "schedule" and run.status in ("success", "ran_late", "failure")) else 0
storage.record_run(run)
wf.last_run_at = run.finished_at
_persist_run_fields(wf, {
"last_run_at": run.finished_at,
"last_run_status": wf.last_run_status,
}, schedule_runs_count_delta=runs_delta)
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"
_persist_run_fields(wf, {
"last_run_status": "failure",
"last_run_at": run.finished_at,
})
finally:
# Cancel the tool-call watcher before we tear the session down so
# the next poll doesn't race close_session.
try:
watcher_task.cancel() # type: ignore[name-defined]
except Exception:
pass
# Close the workflow's agent session so closed_at is set and the
# run shows up in chat history (get_history sorts by closed_at;
# sessions with closed_at=None sort to the bottom and fall off
# the first page). close_session also drops in-memory state and
# persists the final snapshot to disk.
if session is not None:
try:
await agent_manager.close_session(session.id)
except Exception:
logger.exception("close_session failed for workflow run %s", run.id)
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.core.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)
-164
View File
@@ -1,164 +0,0 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator
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
# Bounds keep the scheduler from blowing up on malformed input. The
# FE clamps these too, but defense-in-depth: a misbehaving agent
# tool, an old JSON file, or a curl-wielding power user shouldn't
# be able to crash _next_fire_after by passing hour=99.
repeat_every: int = Field(default=1, ge=1, le=365)
repeat_unit: Literal["day", "week", "month"] = "week"
on_days: list[int] = Field(default_factory=list)
hour: int = Field(default=9, ge=0, le=23)
minute: int = Field(default=0, ge=0, le=59)
# IANA zone name (e.g. "America/Los_Angeles") or "local" for legacy
# records that predate explicit tz. storage._load_all_from_disk coerces
# "local" to the host zone in memory; we leave it on disk until the
# user's next save so backup/sync tools don't see spurious churn.
timezone: str = "local"
on_missed: Literal["skip", "run_once", "run_all"] = "skip"
# Optional end conditions. None = forever / unbounded. Schedule auto-
# disables once either is satisfied; scheduler._tick zeroes out
# next_run_at and flips enabled=False so the UI reflects reality.
ends_at: Optional[datetime] = None
max_runs: Optional[int] = Field(default=None, ge=1)
runs_count: int = Field(default=0, ge=0)
@field_validator("on_days")
@classmethod
def _clean_on_days(cls, v: list[int]) -> list[int]:
# Backend uses JS-style weekday (Sun=0..Sat=6). Drop entries
# outside that range so a malformed PATCH can't trip the
# scheduler later, and dedupe while preserving order.
seen: set[int] = set()
out: list[int] = []
for d in v or []:
if isinstance(d, int) and 0 <= d <= 6 and d not in seen:
seen.add(d)
out.append(d)
return out
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 = ""
# 3 to 6 word LLM-generated headline shown in the collapsed step row.
# The full prompt lives in `text`; this is the "at-a-glance" label.
label: Optional[str] = None
def _empty_str_default() -> str:
return ""
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", "skipped"]] = None
last_run_id: Optional[str] = None
next_run_at: Optional[datetime] = None
cost_cap_usd_monthly: Optional[float] = None
# Sticky session id for the Edit Agent embedded in the workflow card
# (Image #38, #48). Optional so older workflows don't fail validation
# on rehydrate.
edit_agent_session_id: Optional[str] = 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"
# Last tool-call label observed on the underlying agent session while
# the workflow is running. Surfaced under the active step in RunningView
# (Image #40) so the user can tell the run is still making progress.
last_tool_label: Optional[str] = None
# Currently-executing step index (0-based). Executor bumps this each
# time it dispatches a step prompt and broadcasts the run. RunningView
# uses this for the disc statuses; estimate fallback only when null.
active_step_idx: Optional[int] = None
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
cost_cap_usd_monthly: Optional[float] = 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
cost_cap_usd_monthly: Optional[float] = None
-55
View File
@@ -1,55 +0,0 @@
"""Permission/escalation chain notifier.
The notify tier broadcasts a ws event the renderer picks up. The text/call
tiers route through the cloud SMS bridge once enabled; until it's enabled
we fall back to an extra ws notify with a `fallback: true` marker so the
renderer can label it honestly ("Text-me fallback: cloud SMS not wired").
The *when* of escalation is owned by apps/workflows/escalation.py.
"""
import logging
from datetime import datetime
from backend.apps.workflows.models import PermissionTier, Workflow, WorkflowRun
logger = logging.getLogger(__name__)
def _base_payload(wf: Workflow, run: WorkflowRun) -> dict:
return {
"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,
}
async def notify_run_complete(wf: Workflow, run: WorkflowRun) -> None:
from backend.apps.agents.core.ws_manager import ws_manager
from backend.apps.workflows import escalation
payload = _base_payload(wf, run)
await ws_manager.broadcast_global("workflow:notify", payload)
# Kick off server-side escalation only if there are additional tiers
# beyond the default notify. The escalation runner will sleep + call
# send_tier per tier.
escalation.schedule(wf, run)
async def send_tier(wf: Workflow, run: WorkflowRun, tier: PermissionTier) -> None:
"""Send a single escalation tier. Today the text/call paths fall back
to an in-app notify with `fallback: true` and the tier kind set so the
renderer can show "Text-me fallback (cloud SMS not wired)."
"""
from backend.apps.agents.core.ws_manager import ws_manager
payload = _base_payload(wf, run)
payload["tier_kind"] = tier.kind
payload["tier_phone"] = (tier.phone or "")[-4:] if tier.phone else None
payload["fallback"] = True # flip to False once the cloud SMS bridge is wired
await ws_manager.broadcast_global("workflow:notify", payload)
logger.info("workflow tier=%s fallback fired wf=%s run=%s", tier.kind, wf.id, run.id)
-352
View File
@@ -1,352 +0,0 @@
"""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
Wall-clock math runs in the workflow's IANA timezone, then we convert to
UTC at the boundary. This is the only safe way to honor DST (a "9am
Monday" schedule must remain 9am local across spring-forward / fall-back).
Legacy records with timezone="local" are coerced to the host zone in
memory by storage._load_all_from_disk; the on-disk file is not rewritten
until the user's next save.
"""
import asyncio
import calendar
import logging
import os
from datetime import datetime, timedelta, timezone
from typing import Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
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()
_host_tz_cache: Optional[ZoneInfo] = None
def _host_tz() -> ZoneInfo:
global _host_tz_cache
if _host_tz_cache is not None:
return _host_tz_cache
name = os.environ.get("OPENSWARM_TIMEZONE", "").strip()
if not name:
try:
from tzlocal import get_localzone_name # type: ignore
name = get_localzone_name() or ""
except Exception:
name = ""
try:
_host_tz_cache = ZoneInfo(name) if name else ZoneInfo("UTC")
except ZoneInfoNotFoundError:
_host_tz_cache = ZoneInfo("UTC")
return _host_tz_cache
def _resolve_tz(tz: str) -> ZoneInfo:
if not tz or tz == "local":
return _host_tz()
try:
return ZoneInfo(tz)
except ZoneInfoNotFoundError:
return _host_tz()
def _as_utc(dt: Optional[datetime]) -> Optional[datetime]:
"""Normalize an arbitrary stored datetime to aware-UTC.
Pydantic deserializes naive ISO strings as naive datetimes. Treat such
values as host-local (matches the pre-tz codepath that wrote them) so
comparisons against datetime.now(timezone.utc) don't raise.
"""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=_host_tz()).astimezone(timezone.utc)
return dt.astimezone(timezone.utc)
def _add_months(dt: datetime, months: int) -> datetime:
"""Add months preserving day-of-month, clamping only if the target month
is shorter (e.g. Jan 31 + 1mo → Feb 28/29). Wall-clock arithmetic; the
caller is responsible for tz attachment.
"""
total = dt.month - 1 + months
year = dt.year + total // 12
month = total % 12 + 1
day = min(dt.day, calendar.monthrange(year, month)[1])
return dt.replace(year=year, month=month, day=day)
def _js_weekday(d: datetime) -> int:
"""Frontend uses JS getDay() convention (Sun=0..Sat=6). Python's
datetime.weekday() is Mon=0..Sun=6. Wire format stays JS-style so the
on_days array round-trips between FE and BE without translation in two
places."""
return (d.weekday() + 1) % 7
def _next_fire_after(sched: ScheduleConfig, ref_utc: datetime) -> Optional[datetime]:
if not sched.enabled:
return None
tz = _resolve_tz(sched.timezone)
ref_local = ref_utc.astimezone(tz)
base = ref_local.replace(second=0, microsecond=0)
candidate = base.replace(hour=sched.hour, minute=sched.minute)
if candidate <= ref_local:
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 slot strictly
# after `ref_local`. Cheap because step is small.
while candidate <= ref_local:
candidate = candidate + timedelta(days=step)
return candidate.astimezone(timezone.utc)
if sched.repeat_unit == "week":
allowed = sched.on_days or [_js_weekday(ref_local)]
for _ in range(0, 14):
if _js_weekday(candidate) in allowed and candidate > ref_local:
return candidate.astimezone(timezone.utc)
candidate = candidate + timedelta(days=1)
return candidate.astimezone(timezone.utc)
if sched.repeat_unit == "month":
target_day = ref_local.day
step = max(1, sched.repeat_every)
c = candidate.replace(day=min(target_day, calendar.monthrange(candidate.year, candidate.month)[1]))
while c <= ref_local:
c = _add_months(c, step)
return c.astimezone(timezone.utc)
return None
def compute_next_fire(wf: Workflow, ref: Optional[datetime] = None) -> Optional[datetime]:
ref_utc = _as_utc(ref) if ref is not None else datetime.now(timezone.utc)
return _next_fire_after(wf.schedule, ref_utc)
def fires_in_window(wf: Workflow, days: int = 30) -> int:
"""Count fires from now through `days` days from now. Used by the
cost-estimate response. Honors end conditions so the projection doesn't
over-count after ends_at or max_runs. Caps the walk at 1000 fires to
guard pathological sub-day schedules (none today, but cheap insurance).
"""
sched = wf.schedule
if not sched.enabled:
return 0
if sched.max_runs is not None and sched.runs_count >= sched.max_runs:
return 0
cursor_utc = datetime.now(timezone.utc)
end_utc = cursor_utc + timedelta(days=days)
ends_at_utc = _as_utc(sched.ends_at)
if ends_at_utc is not None and ends_at_utc < end_utc:
end_utc = ends_at_utc
remaining_budget = (
sched.max_runs - sched.runs_count if sched.max_runs is not None else 1000
)
count = 0
while count < min(1000, remaining_budget):
nxt = _next_fire_after(sched, cursor_utc)
if nxt is None or nxt > end_utc:
break
count += 1
cursor_utc = nxt
return count
def kick() -> None:
_wake.set()
def _end_condition_hit(wf: Workflow, now_utc: datetime) -> bool:
s = wf.schedule
ends_at = _as_utc(s.ends_at)
if ends_at is not None and now_utc >= ends_at:
return True
if s.max_runs is not None and s.runs_count >= s.max_runs:
return True
return False
def _disable_schedule(wf: Workflow) -> None:
wf.schedule.enabled = False
wf.next_run_at = None
storage.save_workflow(wf)
async def _tick() -> None:
now_utc = datetime.now(timezone.utc)
if storage.get_paused():
return
due: list[Workflow] = []
for wf in storage.list_workflows():
if not wf.schedule.enabled:
continue
if _end_condition_hit(wf, now_utc):
_disable_schedule(wf)
continue
nra = _as_utc(wf.next_run_at)
if nra and nra <= now_utc:
due.append(wf)
for wf in due:
scheduled_for = _as_utc(wf.next_run_at)
nxt = _next_fire_after(wf.schedule, now_utc)
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_utc = datetime.now(timezone.utc)
soonest: Optional[datetime] = None
for wf in storage.list_workflows():
if not wf.schedule.enabled:
continue
nra = _as_utc(wf.next_run_at)
if nra is None:
continue
if soonest is None or nra < soonest:
soonest = nra
if soonest is None:
return 60.0
delta = (soonest - now_utc).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="OpenSwarm closed before this run finished.",
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_utc = datetime.now(timezone.utc)
for wf in storage.list_workflows():
if not wf.schedule.enabled:
wf.next_run_at = None
storage.save_workflow(wf)
continue
if _end_condition_hit(wf, now_utc):
_disable_schedule(wf)
continue
nra = _as_utc(wf.next_run_at)
missed = bool(nra and nra <= now_utc)
if missed and wf.schedule.on_missed in ("run_once", "run_all"):
# Keep next_run_at <= now_utc so the very next tick fires it.
# Normalize to a UTC-aware value so future comparisons don't
# trip on naive legacy datetimes.
wf.next_run_at = nra
storage.save_workflow(wf)
else:
wf.next_run_at = _next_fire_after(wf.schedule, now_utc)
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
def list_active() -> list[dict]:
"""Snapshot of currently-running workflow runs.
Reads executor._running (workflow_id -> run_id) and joins against the
workflow cache for titles. Used by GET /workflows/active so the tray
and the auto-updater veto can both ask "are any runs in flight?"
without holding the executor lock.
"""
out: list[dict] = []
snapshot = dict(executor._running)
for wid, run_id in snapshot.items():
wf = storage.get_workflow(wid)
title = wf.title if wf else ""
started_at = None
if wf:
for r in storage.list_runs(wid, limit=10):
if r.id == run_id:
started_at = r.started_at.isoformat() if isinstance(r.started_at, datetime) else r.started_at
break
out.append({
"workflow_id": wid,
"run_id": run_id,
"title": title,
"started_at": started_at,
})
return out
-197
View File
@@ -1,197 +0,0 @@
"""On-disk store for workflows + workflow runs.
Layout under DATA_ROOT/workflows/:
<id>.json workflow record
runs/<workflow_id>.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")
PAUSED_FILE = os.path.join(DATA_DIR, "paused.json")
_io_lock = Lock()
_workflow_cache: dict[str, Workflow] = {}
_runs_cache: dict[str, list[WorkflowRun]] = {}
_cache_loaded = False
_paused = False
def _resolve_host_tz_name() -> str:
"""Best-effort IANA name for the host. Mirrors apps/service/client.py."""
name = os.environ.get("OPENSWARM_TIMEZONE", "").strip()
if not name:
try:
from tzlocal import get_localzone_name # type: ignore
name = get_localzone_name() or ""
except Exception:
name = ""
return name or "UTC"
# 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, _paused
_ensure_dirs()
_workflow_cache.clear()
_runs_cache.clear()
host_tz = _resolve_host_tz_name()
for fname in os.listdir(DATA_DIR):
if not fname.endswith(".json") or fname == "paused.json":
continue
try:
with open(os.path.join(DATA_DIR, fname)) as f:
wf = Workflow(**json.load(f))
# Coerce legacy timezone="local" to the host IANA zone in
# memory only. We don't rewrite the file here so backup/sync
# tooling doesn't see mtime churn on every startup; the next
# user-driven save migrates the on-disk record naturally.
if wf.schedule.timezone == "local":
wf.schedule.timezone = host_tz
_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] = []
# Load the global pause flag if it's been set previously.
if os.path.exists(PAUSED_FILE):
try:
with open(PAUSED_FILE) as f:
_paused = bool(json.load(f).get("paused", False))
except Exception:
_paused = False
_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 get_paused() -> bool:
if not _cache_loaded:
init()
return _paused
def set_paused(value: bool) -> bool:
global _paused
with _io_lock:
_ensure_dirs()
_paused = bool(value)
with open(PAUSED_FILE, "w") as f:
json.dump({"paused": _paused}, f)
return _paused
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
-807
View File
@@ -1,807 +0,0 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Optional
from fastapi import HTTPException, Header, Request
from backend.config.Apps import SubApp
from backend.apps.workflows.models import (
Workflow,
WorkflowCreate,
WorkflowUpdate,
WorkflowRun,
)
from backend.apps.workflows import storage, scheduler, executor, audit, escalation
logger = logging.getLogger(__name__)
def _scan_cron_for_openswarm() -> list[str]:
"""Surface OS-level scheduled-task entries that reference us.
macOS + Linux: read `crontab -l`. Windows: query `schtasks` for any
task whose command/path contains 'openswarm'. Best-effort across all
three; any failure (no tool installed, permission denied, parse
error) just returns []. Surfaced to the FE so the Workflows hub can
offer a one-click migration banner to convert into native workflows.
"""
import subprocess
import platform as _platform
findings: list[str] = []
if _platform.system() == "Windows":
try:
proc = subprocess.run(
["schtasks", "/query", "/fo", "CSV", "/v"],
capture_output=True, text=True, timeout=4,
)
if proc.returncode != 0:
return []
for line in (proc.stdout or "").splitlines():
if "openswarm" in line.lower() and not line.lstrip().startswith('"#'):
findings.append(line.strip())
except Exception:
return []
return findings
# macOS + Linux
try:
proc = subprocess.run(
["crontab", "-l"],
capture_output=True, text=True, timeout=2,
)
if proc.returncode != 0:
return []
out = proc.stdout or ""
return [line.strip() for line in out.splitlines() if "openswarm" in line.lower() and not line.strip().startswith("#")]
except Exception:
return []
_cron_findings: list[str] = []
@asynccontextmanager
async def workflows_lifespan():
storage.init()
await scheduler.start()
# Cheap one-shot scan for prior cron entries that reference us. We
# don't migrate automatically; the FE shows a banner with a "Convert
# to OpenSwarm scheduled tasks" button so the user is in control.
global _cron_findings
_cron_findings = _scan_cron_for_openswarm()
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)
# Enrich with cost_estimate so calendar tooltips and the WorkflowsHub
# list don't have to round-trip to GET /workflows/{id} per row. Cheap
# because fires_in_window walks at most ~30 fires per workflow.
return {"workflows": [_enriched(w) for w in items]}
@workflows.router.post("/create")
async def create_workflow(body: WorkflowCreate):
actions = body.actions
# Scheduled workflows default to freeze=on for safety. The user can
# flip "Full agent access" in the editor with an explicit confirm.
# Source-session creates inherit the chat's tool choices so we leave
# them alone there (the source session itself already vetted the
# blast radius).
if body.schedule.enabled and not actions.freeze and not body.source_session_id:
actions = actions.model_copy(update={"freeze": True})
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=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",
cost_cap_usd_monthly=body.cost_cap_usd_monthly,
)
if not wf.icon:
wf.icon = _derive_icon(wf)
if wf.schedule.enabled:
wf.next_run_at = scheduler.compute_next_fire(wf)
# Force-generate title + description + per-step labels from the steps
# in a single aux call. Previously we only filled missing description,
# leaving stale session names ("Inbox check") as titles. Step labels
# are the 3-6 word at-a-glance headlines surfaced in StepList; without
# them the UI falls back to truncated raw prompts.
try:
title, description, labels = await _generate_workflow_metadata(wf)
if title:
wf.title = title
if description:
wf.description = description
if labels and len(labels) == len(wf.steps):
for i, lab in enumerate(labels):
if lab:
wf.steps[i].label = lab
except Exception:
pass
storage.save_workflow(wf)
scheduler.kick()
return _enriched(wf)
async def _generate_workflow_metadata(wf: Workflow) -> tuple[str, str, list[str]]:
"""Single aux-model call returning (title, description, step_labels).
One round-trip for all three so we don't burn 3x aux cost. Returns
("", "", []) on any failure; caller writes back unconditionally.
"""
if not wf.steps:
return "", "", []
try:
from backend.apps.agents.providers.registry import resolve_aux_model
from backend.apps.settings.credentials import get_anthropic_client_for_model
from backend.apps.settings.settings import load_settings as _ls
except Exception:
return "", "", []
settings = _ls()
try:
aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
client = get_anthropic_client_for_model(settings, aux_model)
except Exception:
return "", "", []
steps_lines = "\n".join(f"{i+1}. {s.text}" for i, s in enumerate(wf.steps) if s.text)
n_steps = len(wf.steps)
prompt = (
"You name and describe a saved automation routine that the user "
"can re-run later, AND produce a short at-a-glance label for "
"each step. The routine is defined ONLY by the numbered steps "
"below; treat those as the user's instructions to the agent.\n\n"
"Return STRICT JSON, nothing else, no code fence:\n"
' {"title": string, "description": string, "step_labels": [string, ...]}\n\n'
"title rules:\n"
"- 2 to 5 words, Title Case\n"
"- Starts with a verb-noun pair when possible (e.g. \"Summarize "
"Daily Emails\")\n"
"- No emoji, no quotes, no trailing punctuation\n\n"
"description rules:\n"
"- 1 to 2 sentences, under 30 words total\n"
"- Describes the concrete WORK the routine performs for the user, "
"not metadata about itself. Examples of GOOD output:\n"
" \"Reads recent Gmail, ranks urgency, and emails you a PDF "
"digest each Sunday at 9am.\"\n"
" \"Pulls today's calendar plus inbox, writes a Notion brief, "
"and texts you the link.\"\n"
"- Start with a verb. Do NOT start with \"This\", \"A\", \"An\", "
"\"The workflow\", \"This routine\".\n\n"
f"step_labels rules:\n"
f"- EXACTLY {n_steps} entries, one per step, same order.\n"
"- Each label: 3 to 6 words, Sentence case.\n"
"- Imperative verb-led (\"Summarize emails & calendar\", \"Make "
"brief in notion\", \"Email brief link to me\").\n"
"- No trailing punctuation, no quotes, no emoji.\n"
"- Should read as the human-friendly NAME of the step, NOT a "
"restatement of the prompt.\n\n"
f"Steps:\n{steps_lines}"
)
import json
import re as _re
def _extract_json_object(s: str) -> Optional[dict]:
s = s.strip()
if s.startswith("```"):
s = _re.sub(r"^```(?:json)?\s*", "", s, flags=_re.IGNORECASE)
s = _re.sub(r"\s*```\s*$", "", s)
start = s.find("{")
end = s.rfind("}")
if start != -1 and end != -1 and end > start:
s = s[start : end + 1]
try:
return json.loads(s)
except Exception:
return None
try:
resp = await client.messages.create(
model=aux_model,
max_tokens=400 + n_steps * 30,
messages=[
{"role": "user", "content": prompt},
{"role": "assistant", "content": "{"},
],
)
text = ""
if isinstance(resp.content, list):
for block in resp.content:
if getattr(block, "type", None) == "text":
text += getattr(block, "text", "")
raw = "{" + text.strip() if not text.strip().startswith("{") else text.strip()
data = _extract_json_object(raw)
if not data:
logger.warning("workflow meta gen: failed to parse aux model output: %s", raw[:400])
return "", "", []
title = (data.get("title") or "").strip()[:80]
description = (data.get("description") or "").strip()[:500]
raw_labels = data.get("step_labels") or []
labels = [str(x or "").strip()[:60] for x in raw_labels] if isinstance(raw_labels, list) else []
return title, description, labels
except Exception as e:
logger.warning("workflow meta gen: aux model call failed: %s", e)
return "", "", []
def _last_run_cost(wid: str) -> float:
for r in storage.list_runs(wid, limit=10):
if r.status in ("success", "ran_late") and r.cost_usd:
return float(r.cost_usd)
return 0.0
def _enriched(wf: Workflow) -> dict:
"""Serialize a workflow with a cost_estimate block attached.
monthly_usd assumes future fires cost the same as the last successful
fire. Surfaces honestly as "at last run's cost" in the UI so users
understand it's a projection, not a quota.
"""
base = wf.model_dump(mode="json")
last = _last_run_cost(wf.id)
fires = scheduler.fires_in_window(wf, days=30)
base["cost_estimate"] = {
"monthly_usd": round(last * fires, 4),
"last_run_usd": round(last, 4),
"fires_per_month": fires,
}
return base
@workflows.router.get("/active")
async def list_active_runs():
"""Snapshot of currently-running workflow runs. Used by the tray and
the auto-updater veto."""
return {"active": scheduler.list_active()}
@workflows.router.post("/pause-all")
async def pause_all_schedules():
storage.set_paused(True)
scheduler.kick()
return {"paused": True}
@workflows.router.post("/resume-all")
async def resume_all_schedules():
storage.set_paused(False)
scheduler.kick()
return {"paused": False}
@workflows.router.get("/paused")
async def get_paused_state():
return {"paused": storage.get_paused()}
@workflows.router.get("/cron/findings")
async def cron_findings():
"""Cron entries we found at startup that reference OpenSwarm. The
FE renders a one-time banner inviting users to convert them; we
return the raw lines so the user can verify before migrating."""
return {"entries": list(_cron_findings)}
@workflows.router.get("/cloud/sms/status")
async def cloud_sms_status():
"""Probe used by the FE to decide whether to show the 'falls back to
in-app notify' acknowledgement on the text/call tiers. Returns
enabled=False until the cloud SMS bridge ships."""
return {"enabled": False}
@workflows.router.post("/runs/{run_id}/ack")
async def ack_run(run_id: str):
cancelled = escalation.cancel(run_id)
return {"acked": True, "had_pending_escalation": cancelled}
@workflows.router.get("/runs/{run_id}/escalation")
async def get_run_escalation(run_id: str):
state = escalation.status(run_id)
return {"state": state}
@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 _enriched(wf)
@workflows.router.get("/{workflow_id}/audit")
async def get_workflow_audit(workflow_id: str, limit: int = 50):
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
return {"entries": audit.read_tail(workflow_id, limit=limit)}
@workflows.router.patch("/{workflow_id}")
async def update_workflow(
workflow_id: str,
body: WorkflowUpdate,
if_match: Optional[str] = Header(default=None, alias="If-Match"),
):
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
# Optimistic concurrency: if the client passed If-Match, verify it
# matches the current updated_at. Stale writes (another window or a
# mid-edit background fire) get a 409 so the FE can prompt to reload
# instead of silently clobbering the other actor's changes. Missing
# header = legacy client, allow through (back-compat with the
# frontend's pre-409 code path; FE rolls out If-Match immediately).
if if_match:
current_stamp = wf.updated_at.isoformat() if hasattr(wf.updated_at, "isoformat") else str(wf.updated_at)
# Strip quotes a well-behaved HTTP client might add per RFC 7232.
if if_match.strip().strip('"') != current_stamp:
raise HTTPException(
status_code=409,
detail={
"error": "stale_update",
"message": "This workflow changed in another window or by a recent run. Reload and try again.",
"current_updated_at": current_stamp,
},
)
before = wf.model_dump(mode="json")
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)
audit.log_change(wf.id, "user", before, wf.model_dump(mode="json"))
scheduler.kick()
return _enriched(wf)
@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}/propose-edit")
async def propose_edit(workflow_id: str, body: dict):
"""Aux-LLM-propose a single-step edit from a natural-language request.
Powers the Edit Agent chat (Image #38). Frontend hands us the user's
message, the current draft steps, optional failure-context (Fix-with-
Agent), AND the prior turns so the model has multi-turn memory. We
respond with a reply string PLUS, optionally, a `step_idx` + `new_text`
that the FE shows as a proposal card.
"""
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
message = (body or {}).get("message", "").strip()
steps_in = (body or {}).get("steps") or []
context = (body or {}).get("context") or None
history = (body or {}).get("history") or []
if not message or not isinstance(steps_in, list):
raise HTTPException(status_code=400, detail="Missing message or steps")
try:
from backend.apps.agents.providers.registry import resolve_aux_model
from backend.apps.settings.credentials import get_anthropic_client_for_model
from backend.apps.settings.settings import load_settings as _ls
except Exception:
raise HTTPException(status_code=500, detail="Aux model unavailable")
settings = _ls()
try:
aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
client = get_anthropic_client_for_model(settings, aux_model)
except Exception:
raise HTTPException(status_code=500, detail="Aux model unavailable")
import json, re
steps_lines = "\n".join(
f"{i+1}. {(s.get('label') or '').strip() or (s.get('text') or '')[:60]}: {(s.get('text') or '')}"
for i, s in enumerate(steps_in)
)
fix_context = ""
if context and isinstance(context, dict):
fs = context.get("failed_step")
err = context.get("error")
if fs is not None and err:
fix_context = (
f"\n\nFAILURE CONTEXT: Step {int(fs) + 1} failed on the most recent run. "
f"The error was: {err}\n"
f"Your proposed edit should specifically address that failure if possible."
)
# Build history block so the model remembers prior turns. Each entry
# is {role, text}; we only carry assistant/user pairs (proposals get
# summarised inline so the assistant has context for follow-ups).
history_lines = []
if isinstance(history, list):
for h in history[-12:]:
if not isinstance(h, dict):
continue
role = str(h.get("role") or "").strip().lower()
text = str(h.get("text") or "").strip()
if role in ("user", "assistant") and text:
history_lines.append(f"{role.capitalize()}: {text}")
history_block = ("\n\nPrior conversation:\n" + "\n".join(history_lines)) if history_lines else ""
prompt = (
"You are an Edit Agent helping the user iterate on a saved automation "
"workflow. The workflow's current steps are listed below. The user has "
"asked for a modification.\n\n"
"Respond with STRICT JSON, no prose, no fence. Schema:\n"
' {"reply": string, '
'"step_idx": int | null, '
'"new_text": string | null, '
'"explanation": string | null}\n\n'
"Rules:\n"
"- `reply` is a short conversational acknowledgement (1-2 sentences).\n"
"- If the user is asking a question or for clarification, set step_idx=null and new_text=null.\n"
"- If the user is asking to change a specific step, set step_idx (0-based) and new_text to the FULL replacement prompt for that step.\n"
"- `explanation` describes the change in user-facing terms.\n"
"- Never invent new steps. Never remove steps. Only edit existing ones.\n"
"- Use prior conversation context to disambiguate follow-ups (e.g. \"yes do that\" should reference the last proposal).\n\n"
f"Workflow steps:\n{steps_lines}{fix_context}{history_block}\n\n"
f"User: {message}"
)
try:
resp = await client.messages.create(
model=aux_model,
max_tokens=400,
messages=[
{"role": "user", "content": prompt},
{"role": "assistant", "content": "{"},
],
)
out = ""
if isinstance(resp.content, list):
for block in resp.content:
if getattr(block, "type", None) == "text":
out += getattr(block, "text", "")
raw = "{" + out.strip() if not out.strip().startswith("{") else out.strip()
m = re.search(r"\{.*\}", raw, flags=re.DOTALL)
if m:
raw = m.group(0)
data = json.loads(raw)
except Exception as e:
logger.warning("propose-edit: aux LLM failed: %s", e)
raise HTTPException(status_code=400, detail="Couldn't generate a proposal")
reply = str(data.get("reply") or "").strip()[:600]
step_idx = data.get("step_idx")
new_text = data.get("new_text")
explanation = str(data.get("explanation") or "").strip()[:600]
out: dict = {"reply": reply}
if isinstance(step_idx, int) and 0 <= step_idx < len(steps_in) and isinstance(new_text, str) and new_text.strip():
out["step_idx"] = step_idx
out["new_text"] = new_text.strip()
if explanation:
out["explanation"] = explanation
return out
@workflows.router.post("/{workflow_id}/edit-agent-session")
async def edit_agent_session(workflow_id: str):
"""Create (or return existing) Edit Agent session for this workflow.
The Edit Agent is a real agent session that the user chats with to
iterate on the workflow (Image #38, #48). It has the workflow context
pre-loaded in its system prompt and the full default tool surface so
tool calls render as cards in the chat (Image #48: MCP Activation,
Gmail Query, etc.).
Singleton per workflow: re-entering edit mode reattaches to the same
session so the conversation persists. Frontend stores the returned
session_id in the workflow card's openCard state.
"""
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
# Track the edit-agent session id on the workflow record so the FE
# can find it after a reload. Persisted under a private namespace
# field added below; we attach it lazily so existing workflows don't
# need a migration.
existing_id = getattr(wf, "edit_agent_session_id", None) or None
if existing_id:
from backend.apps.agents.agent_manager import agent_manager
if existing_id in agent_manager.sessions:
return {"session_id": existing_id}
# In-memory miss but on disk it's still valid; fall through to
# rehydrate via launch_agent OR return id for the FE to fetch.
return {"session_id": existing_id}
from backend.apps.agents.core.models import AgentConfig
from backend.apps.agents.agent_manager import agent_manager
steps_lines = "\n".join(f"{i+1}. {(s.label or '').strip() or (s.text or '')[:60]}\n Prompt: {s.text}" for i, s in enumerate(wf.steps))
system_prompt = (
f"You are the Edit Agent for the user's saved workflow \"{wf.title}\" "
f"(id: {wf.id}). Help the user iterate on it. The workflow's purpose: "
f"{wf.description or '(unspecified)'}.\n\n"
f"Current steps:\n{steps_lines}\n\n"
"How to work:\n"
"1. When the user describes a change, briefly confirm what you'll do.\n"
"2. If you need to look at files / search / activate an MCP / etc. to "
"verify your idea, use your tools.\n"
"3. Call EditWorkflowStep(workflow_id, step_idx, new_text) to apply a "
"prompt change to a specific step. The change persists immediately. "
"Confirm with the user via AskUserQuestion FIRST if there's any "
"ambiguity about what they want.\n"
"4. Call TestWorkflow(workflow_id) to spawn a sibling Test Agent that "
"runs the latest version end-to-end. Use this after a change to verify "
"it works.\n\n"
"Be brief in your replies. Don't restate the whole workflow back; the "
"user can see it. Just confirm what changed and what you're doing."
)
config = AgentConfig(
name=f"Edit Agent: {wf.title}",
model=wf.model or "sonnet",
mode=wf.mode or "agent",
provider=wf.provider or "anthropic",
system_prompt=system_prompt,
allowed_tools=[],
dashboard_id=wf.dashboard_id,
)
session = await agent_manager.launch_agent(config)
try:
setattr(wf, "edit_agent_session_id", session.id)
storage.save_workflow(wf)
except Exception:
logger.debug("could not persist edit_agent_session_id (legacy schema)", exc_info=True)
return {"session_id": session.id}
@workflows.router.post("/{workflow_id}/test-run")
async def test_run_workflow(workflow_id: str, body: dict):
"""Spawn a Test Agent session running the (possibly-unsaved) draft.
Powers Image #39: EditAgentView's Test button. Takes an optional
draft `steps` array overriding the saved workflow's steps so the
user can validate edits before persisting. The spawned session is
a normal agent session; nothing is recorded as a WorkflowRun so
History stays clean. Returns the new session id; the FE wires it
to the workflow card via setCardSidecar(kind='testing') and the
dashboard draws the labeled arrow chip between the two cards.
"""
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
draft_steps = (body or {}).get("steps")
steps_texts: list[str]
if isinstance(draft_steps, list) and draft_steps:
steps_texts = [str(s.get("text") or "") for s in draft_steps if isinstance(s, dict) and s.get("text")]
else:
steps_texts = [s.text for s in wf.steps if s.text and s.text.strip()]
if not steps_texts:
raise HTTPException(status_code=400, detail="Workflow has no steps to test")
from backend.apps.agents.core.models import AgentConfig
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.workflows import executor
config = AgentConfig(
name=f"{wf.title or 'Workflow'} (test)",
model=wf.model or "sonnet",
mode=wf.mode or "agent",
provider=wf.provider or "anthropic",
system_prompt=executor._resolve_system_prompt(wf),
allowed_tools=executor._resolve_allowed_tools(wf) or [
"Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion",
],
dashboard_id=wf.dashboard_id,
)
session = await agent_manager.launch_agent(config)
async def _drive_test() -> None:
try:
for step in steps_texts:
await agent_manager.send_message(session.id, step)
await executor._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":
return
except Exception:
logger.exception("test-run drive loop failed")
asyncio.create_task(_drive_test())
return {"session_id": session.id}
@workflows.router.post("/{workflow_id}/parse-schedule")
async def parse_schedule(workflow_id: str, body: dict):
"""Aux-LLM-parse natural language into a ScheduleConfig.
Frontend SchedulingView (Image #49) hits this on submit; the parsed
config rides back to the user for explicit "Schedule it" confirmation
before any persistence. Returns the parsed config under {"schedule": ...}.
"""
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
text = (body or {}).get("text", "").strip()
if not text:
raise HTTPException(status_code=400, detail="Missing text")
try:
from backend.apps.agents.providers.registry import resolve_aux_model
from backend.apps.settings.credentials import get_anthropic_client_for_model
from backend.apps.settings.settings import load_settings as _ls
except Exception:
raise HTTPException(status_code=500, detail="Aux model unavailable")
settings = _ls()
try:
aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
client = get_anthropic_client_for_model(settings, aux_model)
except Exception:
raise HTTPException(status_code=500, detail="Aux model unavailable")
import json, re
prompt = (
"Parse the following natural-language schedule into STRICT JSON. "
"No prose, no fence, no comments. Schema:\n"
' {"repeat_unit": "day"|"week"|"month", '
'"repeat_every": int>=1, '
'"on_days": [int 0..6, Sunday=0], '
'"hour": int 0..23, "minute": int 0..59, '
'"timezone": IANA tz string (default to local)}\n\n'
"Rules:\n"
"- If user says weekdays, on_days=[1,2,3,4,5], repeat_unit=week.\n"
"- If user says weekends, on_days=[0,6], repeat_unit=week.\n"
"- If user names a single day (e.g. \"Mondays\"), on_days=[1], repeat_unit=week.\n"
"- If user says daily/everyday, repeat_unit=day, on_days=[].\n"
"- If no AM/PM, assume PM for 1-7 and AM for 8-12.\n"
"- timezone: assume system local if not given.\n\n"
f"Input: {text}"
)
try:
resp = await client.messages.create(
model=aux_model,
max_tokens=180,
messages=[
{"role": "user", "content": prompt},
{"role": "assistant", "content": "{"},
],
)
out = ""
if isinstance(resp.content, list):
for block in resp.content:
if getattr(block, "type", None) == "text":
out += getattr(block, "text", "")
raw = "{" + out.strip() if not out.strip().startswith("{") else out.strip()
m = re.search(r"\{[^{}]*\}", raw, flags=re.DOTALL)
if m:
raw = m.group(0)
data = json.loads(raw)
except Exception as e:
logger.warning("parse-schedule: aux LLM failed: %s", e)
raise HTTPException(status_code=400, detail="Couldn't parse schedule")
cfg = wf.schedule.model_copy(update={
"enabled": True,
"repeat_unit": str(data.get("repeat_unit") or "week"),
"repeat_every": int(data.get("repeat_every") or 1),
"on_days": [int(d) for d in (data.get("on_days") or [])],
"hour": int(data.get("hour") or 9),
"minute": int(data.get("minute") or 0),
"timezone": str(data.get("timezone") or wf.schedule.timezone or "UTC"),
})
return {"schedule": cfg.model_dump(mode="json")}
@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. We also surface the
# run's status + error string when it lands quickly (e.g. cost-cap
# short-circuit, _running collision) so the FE can render a toast
# instead of silently switching to History.
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,
"status": r.status,
"error": r.error,
}
await asyncio.sleep(0.01)
return {"run_id": "", "status": None, "error": None}
@workflows.router.post("/runs/{run_id}/stop")
async def stop_run(run_id: str):
"""Force-terminate a running workflow's underlying agent session.
Fired by RunningView's Stop button (Image #40). The run record gets
marked failure with a "stopped by user" error so it surfaces correctly
in History instead of looking like it succeeded.
"""
target_wf_id = None
target_run = None
for wf in storage.list_workflows():
for r in storage.list_runs(wf.id, limit=50):
if r.id == run_id and r.status == "running":
target_wf_id = wf.id
target_run = r
break
if target_run:
break
if not target_run or not target_wf_id:
raise HTTPException(status_code=404, detail="Run not found or not active")
if target_run.session_id:
try:
from backend.apps.agents.agent_manager import agent_manager
await agent_manager.close_session(target_run.session_id)
except Exception:
logger.exception("stop_run: close_session failed for %s", target_run.session_id)
target_run.status = "failure"
target_run.error = "Stopped by user"
target_run.finished_at = datetime.now()
storage.record_run(target_run)
wf = storage.get_workflow(target_wf_id)
if wf:
_persist_run_fields(wf, {
"last_run_status": "failure",
"last_run_at": target_run.finished_at,
"last_run_id": target_run.id,
})
try:
from backend.apps.agents.core.ws_manager import ws_manager
await ws_manager.broadcast_global("workflow:run", {
"workflow_id": target_wf_id,
"run": target_run.model_dump(mode="json"),
})
except Exception:
pass
return {"ok": True}
@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]}
+1 -2
View File
@@ -32,12 +32,11 @@ 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.proxy.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, workflows])
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, service, subscription, auth, web, anthropic_proxy])
app = main_app.app
# Generate per-install auth token BEFORE we bind the HTTP port. By the
-277
View File
@@ -1,277 +0,0 @@
"""End-to-end smoke: does a scheduled workflow actually fire when its
time hits, with the full scheduler loop running?
Runs the real scheduler.start() loop with the executor mocked so we
don't need a live agent_manager. Then arms a workflow whose
next_run_at is one second in the future, waits, and asserts the
mocked executor was called.
Run:
cd backend && .venv/bin/python -m pytest tests/test_schedule_e2e.py -v
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock
from zoneinfo import ZoneInfo
import pytest
pytestmark = pytest.mark.asyncio
@pytest.fixture(autouse=True)
def isolated_data_dir(monkeypatch, tmp_path):
from backend.apps.workflows import storage as _storage
from backend.apps.workflows import escalation as _escalation
from backend.apps.workflows import audit as _audit
from backend.apps.workflows import scheduler as _scheduler
monkeypatch.setattr(_storage, "DATA_DIR", str(tmp_path / "workflows"))
monkeypatch.setattr(_storage, "RUNS_DIR", str(tmp_path / "workflows" / "runs"))
monkeypatch.setattr(_storage, "PAUSED_FILE", str(tmp_path / "workflows" / "paused.json"))
monkeypatch.setattr(_storage, "_workflow_cache", {})
monkeypatch.setattr(_storage, "_runs_cache", {})
monkeypatch.setattr(_storage, "_cache_loaded", False)
monkeypatch.setattr(_storage, "_paused", False)
monkeypatch.setattr(_audit, "AUDIT_DIR", str(tmp_path / "workflows" / "audit"))
# Module-level scheduler state survives across tests; reset it so
# each test gets a fresh _wake Event bound to its own event loop.
_scheduler._loop_task = None
_scheduler._wake = asyncio.Event()
_escalation._tasks.clear()
_escalation._state.clear()
yield
def _make_wf(**overrides):
from backend.apps.workflows.models import Workflow, ScheduleConfig, WorkflowStep
base = dict(
title="smoke",
steps=[WorkflowStep(text="hi")],
schedule=ScheduleConfig(
enabled=True, repeat_unit="day", repeat_every=1,
hour=9, minute=0, timezone="America/Los_Angeles",
),
)
base.update(overrides)
return Workflow(**base)
async def test_loop_fires_due_workflow(monkeypatch):
"""Arm a workflow to fire ~now and assert the executor was actually
invoked by the scheduler loop within the test window. Note the save
happens AFTER scheduler.start() so reconcile_on_startup doesn't
clobber next_run_at."""
from backend.apps.workflows import storage, scheduler, executor
fired = asyncio.Event()
captured: dict = {}
async def fake_execute(wf, triggered_by="schedule", scheduled_for=None):
captured["wf_id"] = wf.id
captured["triggered_by"] = triggered_by
captured["scheduled_for"] = scheduled_for
from backend.apps.workflows.models import WorkflowRun
run = WorkflowRun(
workflow_id=wf.id,
status="success",
scheduled_for=scheduled_for,
started_at=datetime.now(timezone.utc),
finished_at=datetime.now(timezone.utc),
triggered_by=triggered_by,
)
storage.record_run(run)
fired.set()
return run
monkeypatch.setattr(executor, "execute", fake_execute)
await scheduler.start()
try:
wf = _make_wf()
wf.next_run_at = datetime.now(timezone.utc) + timedelta(seconds=1)
storage.save_workflow(wf)
scheduler.kick() # force immediate tick
# Wait up to 5s for the fire to land.
await asyncio.wait_for(fired.wait(), timeout=5.0)
finally:
await scheduler.stop()
assert captured.get("wf_id") == wf.id
assert captured.get("triggered_by") == "schedule"
runs = storage.list_runs(wf.id, limit=10)
assert len(runs) == 1
assert runs[0].status == "success"
# Scheduler should have rolled next_run_at forward to a future slot.
after = storage.get_workflow(wf.id)
assert after.next_run_at is not None
assert after.next_run_at > datetime.now(timezone.utc)
async def test_disabled_workflow_does_not_fire(monkeypatch):
"""Master switch off => loop never invokes the executor even if
next_run_at is in the past."""
from backend.apps.workflows import storage, scheduler, executor
fake = AsyncMock()
monkeypatch.setattr(executor, "execute", fake)
wf = _make_wf()
wf.schedule.enabled = False
wf.next_run_at = datetime.now(timezone.utc) - timedelta(seconds=10)
storage.save_workflow(wf)
await scheduler.start()
try:
scheduler.kick()
await asyncio.sleep(2.0)
finally:
await scheduler.stop()
fake.assert_not_called()
async def test_paused_state_blocks_all_fires(monkeypatch):
"""Global pause flag wins over per-workflow enabled state."""
from backend.apps.workflows import storage, scheduler, executor
fake = AsyncMock()
monkeypatch.setattr(executor, "execute", fake)
wf = _make_wf()
wf.next_run_at = datetime.now(timezone.utc) - timedelta(seconds=1)
storage.save_workflow(wf)
storage.set_paused(True)
await scheduler.start()
try:
scheduler.kick()
await asyncio.sleep(2.0)
finally:
await scheduler.stop()
fake.assert_not_called()
storage.set_paused(False)
async def test_reconcile_skip_rolls_past_missed(monkeypatch):
"""on_missed='skip' + a missed next_run_at => startup rolls forward
to the next future fire without queuing a catch-up."""
from backend.apps.workflows import storage, scheduler
wf = _make_wf()
wf.schedule.on_missed = "skip"
# Stash a missed fire 6 hours ago.
wf.next_run_at = datetime.now(timezone.utc) - timedelta(hours=6)
storage.save_workflow(wf)
scheduler.reconcile_on_startup()
after = storage.get_workflow(wf.id)
assert after.next_run_at is not None
assert after.next_run_at > datetime.now(timezone.utc)
async def test_reconcile_run_once_keeps_missed(monkeypatch):
"""on_missed='run_once' => startup leaves next_run_at in the past so
the first tick fires a catch-up."""
from backend.apps.workflows import storage, scheduler
wf = _make_wf()
wf.schedule.on_missed = "run_once"
missed = datetime.now(timezone.utc) - timedelta(hours=6)
wf.next_run_at = missed
storage.save_workflow(wf)
scheduler.reconcile_on_startup()
after = storage.get_workflow(wf.id)
assert after.next_run_at <= datetime.now(timezone.utc)
async def test_create_workflow_schedules_next_fire():
"""POST-like create path: enabled schedule => next_run_at populated
by compute_next_fire."""
from backend.apps.workflows.models import Workflow, ScheduleConfig, WorkflowStep
from backend.apps.workflows import scheduler
wf = Workflow(
title="t",
steps=[WorkflowStep(text="hi")],
schedule=ScheduleConfig(
enabled=True, repeat_unit="week", repeat_every=1, on_days=[0],
hour=9, minute=0, timezone="America/Los_Angeles",
),
)
nxt = scheduler.compute_next_fire(wf)
assert nxt is not None
assert nxt > datetime.now(timezone.utc)
tz = ZoneInfo("America/Los_Angeles")
local = nxt.astimezone(tz)
assert local.weekday() == 6 # Python: Sunday
assert (local.hour, local.minute) == (9, 0)
async def test_next_run_at_advances_after_fire(monkeypatch):
"""After a fire the loop should re-compute next_run_at into the
future and persist it, so the same fire can't repeat in the same
minute."""
from backend.apps.workflows import storage, scheduler, executor
fired = asyncio.Event()
async def fake_execute(wf, triggered_by="schedule", scheduled_for=None):
from backend.apps.workflows.models import WorkflowRun
run = WorkflowRun(
workflow_id=wf.id, status="success", scheduled_for=scheduled_for,
started_at=datetime.now(timezone.utc), finished_at=datetime.now(timezone.utc),
triggered_by=triggered_by,
)
storage.record_run(run)
fired.set()
return run
monkeypatch.setattr(executor, "execute", fake_execute)
await scheduler.start()
try:
wf = _make_wf()
armed_at = datetime.now(timezone.utc) + timedelta(seconds=1)
wf.next_run_at = armed_at
storage.save_workflow(wf)
scheduler.kick()
await asyncio.wait_for(fired.wait(), timeout=5.0)
# Give the loop one extra tick to persist next_run_at.
await asyncio.sleep(0.2)
finally:
await scheduler.stop()
after = storage.get_workflow(wf.id)
assert after.next_run_at is not None
assert after.next_run_at > armed_at, "scheduler did not advance next_run_at past the slot it just fired"
async def test_kick_wakes_loop_before_timeout(monkeypatch):
"""kick() should wake the loop early so manual schedule edits don't
have to wait a full minute for the next tick boundary."""
from backend.apps.workflows import storage, scheduler, executor
fired = asyncio.Event()
async def fake_execute(wf, triggered_by="schedule", scheduled_for=None):
from backend.apps.workflows.models import WorkflowRun
run = WorkflowRun(
workflow_id=wf.id, status="success",
started_at=datetime.now(timezone.utc),
finished_at=datetime.now(timezone.utc), triggered_by=triggered_by,
)
storage.record_run(run)
fired.set()
return run
monkeypatch.setattr(executor, "execute", fake_execute)
await scheduler.start()
try:
wf = _make_wf()
wf.next_run_at = datetime.now(timezone.utc) - timedelta(seconds=1)
storage.save_workflow(wf)
scheduler.kick()
# Without kick(), the loop would sleep up to 60s before checking
# the freshly-saved workflow. With kick, it should fire fast.
await asyncio.wait_for(fired.wait(), timeout=3.0)
finally:
await scheduler.stop()
-430
View File
@@ -1,430 +0,0 @@
"""Backend semantics tests for the scheduled-tasks fix.
Covers:
- DST-safe wall-clock math (spring forward + fall back) via zoneinfo
- End conditions (ends_at + max_runs) auto-disable the schedule
- Cost cap skips fires with a clear error
- Freeze-default on for new scheduled non-source-session creates
- Audit log captures field diffs
- /workflows/active surfaces in-process running runs
- Legacy timezone="local" coerced in memory at load
- Storage paused flag round-trips
- Month math no longer clamps to day 28
- Server-side escalation kicks tasks (and ack cancels them)
Run:
pip install -r backend/requirements.txt -r backend/requirements-dev.txt
cd backend && python -m pytest tests/test_workflows_semantics.py -v
"""
from __future__ import annotations
import asyncio
import json
import os
import shutil
import tempfile
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
import pytest
@pytest.fixture(autouse=True)
def isolated_data_dir(monkeypatch, tmp_path):
"""Point storage at a fresh tmpdir per test so we never touch a real
install's workflows data. Reloads in-process module state so each test
starts with empty caches."""
from backend.apps.workflows import storage as _storage
from backend.apps.workflows import escalation as _escalation
monkeypatch.setattr(_storage, "DATA_DIR", str(tmp_path / "workflows"))
monkeypatch.setattr(_storage, "RUNS_DIR", str(tmp_path / "workflows" / "runs"))
monkeypatch.setattr(_storage, "PAUSED_FILE", str(tmp_path / "workflows" / "paused.json"))
monkeypatch.setattr(_storage, "_workflow_cache", {})
monkeypatch.setattr(_storage, "_runs_cache", {})
monkeypatch.setattr(_storage, "_cache_loaded", False)
monkeypatch.setattr(_storage, "_paused", False)
# Reset escalation registry between tests.
_escalation._tasks.clear()
_escalation._state.clear()
# Also clear audit dir reference; audit.py reads DATA_DIR at import via
# module-level expression, so reach in and override the AUDIT_DIR too.
from backend.apps.workflows import audit as _audit
monkeypatch.setattr(_audit, "AUDIT_DIR", str(tmp_path / "workflows" / "audit"))
yield
def _make_wf(**overrides):
from backend.apps.workflows.models import Workflow, ScheduleConfig, WorkflowStep
base = dict(
title="t",
steps=[WorkflowStep(text="hi")],
schedule=ScheduleConfig(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0, timezone="America/Los_Angeles"),
)
base.update(overrides)
return Workflow(**base)
# --- DST tests ---------------------------------------------------------------
def test_dst_spring_forward_weekly():
"""A 2:30am LA weekly Sunday schedule lands on 3:30am LA on the spring-
forward Sunday (2025-03-09) because the wall clock skips 02:30."""
from backend.apps.workflows.scheduler import _next_fire_after
from backend.apps.workflows.models import ScheduleConfig
tz = ZoneInfo("America/Los_Angeles")
sched = ScheduleConfig(enabled=True, repeat_unit="week", repeat_every=1, on_days=[0], hour=2, minute=30, timezone="America/Los_Angeles")
# Saturday 2025-03-08 23:00 LA, asking "what's the next Sunday 2:30?"
ref_local = datetime(2025, 3, 8, 23, 0, tzinfo=tz)
nxt = _next_fire_after(sched, ref_local.astimezone(timezone.utc))
assert nxt is not None
nxt_local = nxt.astimezone(tz)
# 02:30 wall-clock on the spring-forward day doesn't exist; zoneinfo
# resolves it forward to 03:30. The point is the *date* lands on the
# 9th, not the 8th and not the 16th.
assert nxt_local.date() == datetime(2025, 3, 9).date()
assert nxt_local.hour in (2, 3)
def test_dst_fall_back_no_double_fire():
"""A 9am LA daily schedule should fire exactly once on the fall-back day
(2025-11-02) and the next fire is the 3rd, not the 2nd again."""
from backend.apps.workflows.scheduler import _next_fire_after
from backend.apps.workflows.models import ScheduleConfig
tz = ZoneInfo("America/Los_Angeles")
sched = ScheduleConfig(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0, timezone="America/Los_Angeles")
ref_local = datetime(2025, 11, 1, 23, 0, tzinfo=tz)
nxt = _next_fire_after(sched, ref_local.astimezone(timezone.utc))
assert nxt.astimezone(tz).date() == datetime(2025, 11, 2).date()
# After firing on the 2nd, the next fire should be the 3rd, not a
# second 2nd from the duplicated hour.
after = _next_fire_after(sched, nxt)
assert after.astimezone(tz).date() == datetime(2025, 11, 3).date()
# --- End condition tests -----------------------------------------------------
def test_max_runs_disables_schedule():
from backend.apps.workflows import storage, scheduler
wf = _make_wf()
wf.schedule.max_runs = 2
wf.schedule.runs_count = 2
wf.next_run_at = datetime.now(timezone.utc) - timedelta(minutes=1)
storage.save_workflow(wf)
asyncio.new_event_loop().run_until_complete(scheduler._tick())
after = storage.get_workflow(wf.id)
assert after.schedule.enabled is False
assert after.next_run_at is None
def test_ends_at_disables_schedule():
from backend.apps.workflows import storage, scheduler
wf = _make_wf()
wf.schedule.ends_at = datetime.now(timezone.utc) - timedelta(days=1)
wf.next_run_at = datetime.now(timezone.utc) - timedelta(minutes=1)
storage.save_workflow(wf)
asyncio.new_event_loop().run_until_complete(scheduler._tick())
after = storage.get_workflow(wf.id)
assert after.schedule.enabled is False
# --- Month-day-31 (formerly clamped to 28) -----------------------------------
def test_month_repeat_no_longer_clamps_to_28():
"""An every-month schedule starting on March 31 should next fire on
April 30 (last day of April), then May 31, then June 30."""
from backend.apps.workflows.scheduler import _next_fire_after
from backend.apps.workflows.models import ScheduleConfig
tz = ZoneInfo("America/Los_Angeles")
sched = ScheduleConfig(enabled=True, repeat_unit="month", repeat_every=1, hour=9, minute=0, timezone="America/Los_Angeles")
ref_local = datetime(2025, 3, 31, 10, 0, tzinfo=tz) # past 9am on the 31st
nxt = _next_fire_after(sched, ref_local.astimezone(timezone.utc))
assert nxt.astimezone(tz).date() == datetime(2025, 4, 30).date()
# --- Cost cap ----------------------------------------------------------------
def test_cost_cap_skips_with_clear_error(monkeypatch):
from backend.apps.workflows import storage, executor
from backend.apps.workflows.models import WorkflowRun
wf = _make_wf()
wf.cost_cap_usd_monthly = 1.0
storage.save_workflow(wf)
storage.record_run(WorkflowRun(workflow_id=wf.id, status="success", cost_usd=0.6, started_at=datetime.now(timezone.utc), finished_at=datetime.now(timezone.utc)))
storage.record_run(WorkflowRun(workflow_id=wf.id, status="success", cost_usd=0.6, started_at=datetime.now(timezone.utc), finished_at=datetime.now(timezone.utc)))
async def fake_launch(*a, **k):
raise AssertionError("agent_manager should not be reached when cost-capped")
# Patch agent_manager.launch_agent so we'd fail loudly if the cap
# didn't short-circuit before launch.
from backend.apps.agents import agent_manager
monkeypatch.setattr(agent_manager.agent_manager, "launch_agent", fake_launch)
run = asyncio.new_event_loop().run_until_complete(executor.execute(wf, triggered_by="schedule"))
assert run.status == "skipped"
assert "Monthly cost cap reached" in (run.error or "")
# --- Freeze-default for scheduled non-source-session creates ----------------
def test_freeze_defaults_on_for_scheduled_create():
"""POST /workflows/create with schedule.enabled=true and no source
session should flip actions.freeze=True to keep blast radius small."""
from backend.apps.workflows.workflows import create_workflow
from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, ActionsConfig
body = WorkflowCreate(
title="scheduled",
schedule=ScheduleConfig(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0),
actions=ActionsConfig(freeze=False, configured_sets=[]),
)
result = asyncio.new_event_loop().run_until_complete(create_workflow(body))
assert result["actions"]["freeze"] is True
def test_freeze_not_forced_when_source_session_present():
"""Source-session creates inherit the chat's choices; we don't override."""
from backend.apps.workflows.workflows import create_workflow
from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig, ActionsConfig
body = WorkflowCreate(
title="from chat",
source_session_id="sess-1",
schedule=ScheduleConfig(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0),
actions=ActionsConfig(freeze=False, configured_sets=[]),
)
result = asyncio.new_event_loop().run_until_complete(create_workflow(body))
assert result["actions"]["freeze"] is False
# --- Audit log ---------------------------------------------------------------
def test_audit_log_records_title_change():
from backend.apps.workflows import audit
audit.log_change("wf-1", "user", {"title": "old"}, {"title": "new"})
entries = audit.read_tail("wf-1", limit=10)
assert len(entries) == 1
diff = entries[0]["diff"]
assert diff["title"]["before"] == "old"
assert diff["title"]["after"] == "new"
def test_audit_log_no_op_when_unchanged():
from backend.apps.workflows import audit
audit.log_change("wf-2", "user", {"title": "same"}, {"title": "same"})
assert audit.read_tail("wf-2") == []
# --- /workflows/active -------------------------------------------------------
def test_list_active_reflects_running_map():
from backend.apps.workflows import storage, executor, scheduler
wf = _make_wf(title="active-test")
storage.save_workflow(wf)
from backend.apps.workflows.models import WorkflowRun
run = WorkflowRun(workflow_id=wf.id, status="running")
storage.record_run(run)
executor._running[wf.id] = run.id
try:
active = scheduler.list_active()
assert len(active) == 1
assert active[0]["workflow_id"] == wf.id
assert active[0]["title"] == "active-test"
finally:
executor._running.pop(wf.id, None)
# --- Legacy tz coercion ------------------------------------------------------
def test_legacy_timezone_coerced_on_load(monkeypatch):
from backend.apps.workflows import storage
storage._ensure_dirs()
wf_id = "legacy-wf"
legacy_blob = {
"id": wf_id,
"title": "legacy",
"schedule": {
"enabled": False, "repeat_every": 1, "repeat_unit": "week",
"on_days": [], "hour": 9, "minute": 0, "timezone": "local",
"on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0,
},
}
with open(os.path.join(storage.DATA_DIR, f"{wf_id}.json"), "w") as f:
json.dump(legacy_blob, f)
monkeypatch.setenv("OPENSWARM_TIMEZONE", "America/Los_Angeles")
monkeypatch.setattr(storage, "_cache_loaded", False)
loaded = storage.get_workflow(wf_id)
assert loaded is not None
# In-memory should be the host zone, not "local".
assert loaded.schedule.timezone == "America/Los_Angeles"
# On-disk file should be unchanged (still "local") so we don't churn
# mtime on every restart.
with open(os.path.join(storage.DATA_DIR, f"{wf_id}.json")) as f:
on_disk = json.load(f)
assert on_disk["schedule"]["timezone"] == "local"
# --- Paused flag -------------------------------------------------------------
def test_paused_flag_persists_and_blocks_tick():
from backend.apps.workflows import storage, scheduler
wf = _make_wf()
wf.next_run_at = datetime.now(timezone.utc) - timedelta(minutes=1)
storage.save_workflow(wf)
storage.set_paused(True)
# Reload simulates a backend restart.
storage._cache_loaded = False
assert storage.get_paused() is True
# Tick must not advance next_run_at when paused.
before = storage.get_workflow(wf.id).next_run_at
asyncio.new_event_loop().run_until_complete(scheduler._tick())
after = storage.get_workflow(wf.id).next_run_at
assert before == after
# --- Escalation --------------------------------------------------------------
def test_escalation_schedules_and_ack_cancels():
from backend.apps.workflows import escalation
from backend.apps.workflows.models import Workflow, PermissionTier, WorkflowRun, ScheduleConfig
async def runner():
wf = Workflow(title="t", permissions=[
PermissionTier(kind="notify"),
PermissionTier(kind="text", after_minutes=60, phone="+15551234567"),
])
run = WorkflowRun(workflow_id=wf.id, status="success")
escalation.schedule(wf, run)
# State should be present immediately.
await asyncio.sleep(0.01)
assert escalation.status(run.id) is not None
# Ack cancels.
assert escalation.cancel(run.id) is True
await asyncio.sleep(0.01)
assert escalation.status(run.id) is None
asyncio.new_event_loop().run_until_complete(runner())
def test_executor_merge_does_not_clobber_concurrent_patch():
"""Executor's final save must NOT overwrite unrelated fields that
were PATCHed while the run was in flight. We simulate this by
capturing a wf, mutating storage's record directly (acting as the
PATCH that landed mid-run), then asking the executor's persist
helper to flush its run-side bookkeeping. The patched fields must
survive.
"""
from backend.apps.workflows import storage, executor
from datetime import datetime
wf = _make_wf(title="t-orig")
storage.save_workflow(wf)
# Simulate a user PATCH mid-run.
storage._workflow_cache[wf.id].title = "t-patched"
storage._workflow_cache[wf.id].description = "patched while running"
storage.save_workflow(storage._workflow_cache[wf.id])
# Executor uses the stale `wf` it captured before the patch. With
# the merge helper, the patched fields must remain.
executor._persist_run_fields(wf, {
"last_run_at": datetime.now(),
"last_run_status": "success",
})
after = storage.get_workflow(wf.id)
assert after.title == "t-patched", "title clobbered by executor"
assert after.description == "patched while running", "description clobbered"
assert after.last_run_status == "success"
def test_executor_delete_during_run_does_not_resurrect():
"""If the workflow was deleted mid-run, executor's persist must
silently no-op so the deleted record isn't re-written."""
from backend.apps.workflows import storage, executor
from datetime import datetime
wf = _make_wf(title="doomed")
storage.save_workflow(wf)
storage.delete_workflow(wf.id)
executor._persist_run_fields(wf, {
"last_run_at": datetime.now(),
"last_run_status": "success",
}, schedule_runs_count_delta=1)
assert storage.get_workflow(wf.id) is None
def test_patch_if_match_rejects_stale_write():
"""A PATCH with a stale If-Match must return 409. Without If-Match,
the request still succeeds (legacy clients keep working until they
roll out the header)."""
from backend.apps.workflows.workflows import update_workflow
from backend.apps.workflows.models import WorkflowUpdate
from backend.apps.workflows import storage
from fastapi import HTTPException
wf = _make_wf(title="optimistic-test")
storage.save_workflow(wf)
stale = "1999-01-01T00:00:00"
async def runner():
# Stale If-Match → 409.
try:
await update_workflow(wf.id, WorkflowUpdate(title="x"), if_match=stale)
return "no exception"
except HTTPException as he:
return he.status_code
code = asyncio.new_event_loop().run_until_complete(runner())
assert code == 409, f"stale If-Match should 409, got {code}"
# Fresh If-Match → 200.
fresh = storage.get_workflow(wf.id)
fresh_stamp = fresh.updated_at.isoformat()
async def runner_ok():
return await update_workflow(wf.id, WorkflowUpdate(title="y"), if_match=fresh_stamp)
result = asyncio.new_event_loop().run_until_complete(runner_ok())
assert result["title"] == "y"
# Missing If-Match → legacy path still works.
async def runner_legacy():
return await update_workflow(wf.id, WorkflowUpdate(title="z"), if_match=None)
result = asyncio.new_event_loop().run_until_complete(runner_legacy())
assert result["title"] == "z"
def test_killed_by_restart_message_is_friendly():
"""stuck-run reaper writes a user-facing string, not internal jargon."""
from backend.apps.workflows import storage, scheduler
from backend.apps.workflows.models import WorkflowRun
wf = _make_wf()
storage.save_workflow(wf)
storage.record_run(WorkflowRun(workflow_id=wf.id, status="running"))
scheduler._mark_stuck_runs_failed()
runs = storage.list_runs(wf.id, limit=10)
assert any(r.status == "failure" and "OpenSwarm closed" in (r.error or "") for r in runs)
assert not any("Killed by restart" in (r.error or "") for r in runs)
def test_run_endpoint_surfaces_skipped_status():
"""POST /workflows/{id}/run returns the skipped status + error when
a cost-cap or in-flight collision short-circuits the run."""
from backend.apps.workflows.workflows import run_workflow_now
from backend.apps.workflows import storage
from backend.apps.workflows.models import WorkflowRun
from datetime import datetime, timezone
wf = _make_wf(title="cap-immediate")
wf.cost_cap_usd_monthly = 0.01
storage.save_workflow(wf)
# Burn the cap with a single $5 historical run.
storage.record_run(WorkflowRun(workflow_id=wf.id, status="success", cost_usd=5.0,
started_at=datetime.now(timezone.utc),
finished_at=datetime.now(timezone.utc)))
async def runner():
return await run_workflow_now(wf.id)
res = asyncio.new_event_loop().run_until_complete(runner())
assert res.get("status") == "skipped"
assert "cost cap" in (res.get("error") or "").lower()
def test_escalation_noop_for_single_tier():
from backend.apps.workflows import escalation
from backend.apps.workflows.models import Workflow, PermissionTier, WorkflowRun
wf = Workflow(title="t", permissions=[PermissionTier(kind="notify")])
run = WorkflowRun(workflow_id=wf.id, status="success")
escalation.schedule(wf, run)
assert escalation.status(run.id) is None
+2 -16
View File
@@ -34,7 +34,6 @@ const fs = require('fs');
const getPort = require('get-port');
const http = require('http');
const affiliateTracking = require('./affiliateTracking');
const workflowsLifecycle = require('./workflowsLifecycle');
// Defender warmup: NSIS runs us with --prewarm right after install so Windows scans the bundled binaries while the user is already watching the installer instead of staring at a slow first launch.
if (process.argv.includes('--prewarm') && process.platform === 'win32') {
@@ -694,10 +693,6 @@ function markBackendReady() {
if (backendReady) return;
backendReady = true;
_backendReadyResolve();
try {
workflowsLifecycle.setBackend({ port: backendPort, token: authToken });
workflowsLifecycle.startPolling();
} catch (_) {}
}
function getAuthTokenFilePath() {
@@ -1146,8 +1141,8 @@ app.whenReady().then(async () => {
backendPort = parseInt(process.env.OPENSWARM_PORT || '8324', 10);
console.log(`Dev mode: using existing backend on port ${backendPort}`);
emitSplashStatus('Connecting to dev backend…');
// Load the token before marking ready, same as prod, so the workflow
// poller's setBackend() gets a real token instead of '' (else it 401s).
// Load the token before marking ready, same as prod, so renderer
// fetches get a real token instead of '' (else they 401).
await loadAuthToken();
markBackendReady();
} else {
@@ -1530,10 +1525,6 @@ app.on('before-quit', async (event) => {
try {
await postShutdownAllApps(2000);
} catch (_) {}
// Give in-flight workflow runs up to 30s to land so we don't destroy paid LLM work.
try {
await workflowsLifecycle.drainOnQuit(30);
} catch (_) {}
app.quit();
});
@@ -1657,11 +1648,6 @@ ipcMain.handle('set-allow-prerelease', async (_e, value) => {
ipcMain.handle('install-update', async () => {
if (!autoUpdater) return;
// Veto while a workflow is in flight; lifecycle poller fires the deferred install once active drains.
try {
const vetoed = await workflowsLifecycle.maybeVetoInstall();
if (vetoed) return { vetoed: true };
} catch (_) {}
autoUpdater.quitAndInstall(false, true);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openswarm",
"version": "1.1.63",
"version": "1.1.64",
"description": "OpenSwarm — AI Agent Orchestrator",
"author": "openswarm-ai",
"main": "main.js",
-208
View File
@@ -1,208 +0,0 @@
// Lifecycle helpers that keep scheduled workflows surviving real-world
// app states (machine sleep, window closed, auto-update). All exports are
// safe to call before the backend is up; failed fetches return null and
// callers degrade to "no active runs known."
const { app, powerSaveBlocker, Notification, shell } = require('electron');
const http = require('http');
let backendPortRef = null;
let authTokenRef = null;
let blockerId = null;
let updaterVetoPending = false;
let pollTimer = null;
let lastActiveCount = 0;
let onActiveChange = () => {};
function setBackend({ port, token }) {
backendPortRef = port;
authTokenRef = token;
}
function setActiveChangeListener(cb) {
onActiveChange = cb || (() => {});
}
// Cheap GET to the localhost backend. Resolves null on any error.
function fetchJson(pathStr) {
return new Promise((resolve) => {
if (!backendPortRef) return resolve(null);
const req = http.request({
hostname: '127.0.0.1',
port: backendPortRef,
path: pathStr,
method: 'GET',
headers: authTokenRef ? { Authorization: `Bearer ${authTokenRef}` } : {},
timeout: 1500,
}, (res) => {
let data = '';
res.on('data', (c) => { data += c; });
res.on('end', () => {
try { resolve(JSON.parse(data)); } catch { resolve(null); }
});
});
req.on('error', () => resolve(null));
req.on('timeout', () => { req.destroy(); resolve(null); });
req.end();
});
}
async function getActive() {
// Must hit the /api prefix; the bare path 401s and would leave the
// powerSaveBlocker + updater-veto blind to in-flight runs.
const res = await fetchJson('/api/workflows/active');
if (!res || !Array.isArray(res.active)) return [];
return res.active;
}
// powerSaveBlocker holds the system awake while at least one workflow is
// active. Released as soon as the active list goes empty so we don't pin
// the user's laptop on idle.
function ensureBlocker(active) {
if (active && blockerId == null) {
try { blockerId = powerSaveBlocker.start('prevent-app-suspension'); } catch (_) {}
} else if (!active && blockerId != null) {
try { powerSaveBlocker.stop(blockerId); } catch (_) {}
blockerId = null;
}
}
function startPolling() {
if (pollTimer) return;
// 5s cadence is the sweet spot: fast enough to release the
// powerSaveBlocker promptly after a fire, slow enough that the localhost
// request is invisible in CPU traces.
pollTimer = setInterval(async () => {
const active = await getActive();
const count = active.length;
ensureBlocker(count > 0);
if (count !== lastActiveCount) {
lastActiveCount = count;
try { onActiveChange(active); } catch (_) {}
}
// If the updater queued an install while a run was in flight, fire it
// the moment the active list drains.
if (updaterVetoPending && count === 0) {
updaterVetoPending = false;
try {
const { autoUpdater } = require('electron-updater');
autoUpdater.quitAndInstall(false, true);
} catch (_) {}
}
}, 5000);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
// Updater veto: if a workflow is running and the user clicks "Install
// update," queue it instead of quitAndInstall'ing on top of an active
// run. Returns true if vetoed (caller should display a "queued" banner),
// false otherwise.
async function maybeVetoInstall() {
const active = await getActive();
if (active.length === 0) return false;
updaterVetoPending = true;
return true;
}
// Drain on quit: give in-flight runs up to QUIT_DRAIN_S to finish before
// killing the backend. The user-facing tradeoff is a slow quit when busy
// vs. losing the run; we lean toward "wait" because the run already
// committed real cost.
function drainOnQuit(maxSeconds = 30) {
return new Promise((resolve) => {
const deadline = Date.now() + maxSeconds * 1000;
const tick = async () => {
const active = await getActive();
if (active.length === 0 || Date.now() > deadline) return resolve();
setTimeout(tick, 500);
};
tick();
});
}
// Native OS notification. Falls back silently when Notification isn't
// supported (some Linux setups, headless test envs). When `actions` is
// provided AND we're on macOS, attaches button actions so the user can
// ack/re-run/open without the app taking focus. Routes the chosen
// outcome back to the renderer via an IPC channel that the renderer's
// WebSocketManager already listens for.
function showNativeNotification({ title, body, deepLink, runId, workflowId, actions }) {
if (!Notification || !Notification.isSupported()) return null;
try {
const opts = { title: title || 'OpenSwarm', body: body || '', silent: false };
const platformActions = Array.isArray(actions) && process.platform === 'darwin'
? actions.map((a) => ({ type: 'button', text: a.text }))
: undefined;
if (platformActions && platformActions.length) opts.actions = platformActions;
const n = new Notification(opts);
const route = (outcome) => {
try {
const { BrowserWindow } = require('electron');
const wins = BrowserWindow.getAllWindows();
const wc = wins[0]?.webContents;
if (wc) wc.send('workflow:notification-action', { outcome, runId, workflowId, deepLink });
} catch (_) {}
};
n.on('action', (_event, idx) => {
const a = (actions || [])[idx];
if (a) route(a.outcome);
});
n.on('click', () => {
if (deepLink) {
try { shell.openExternal(deepLink); } catch (_) {}
}
route('open');
});
n.show();
return n;
} catch (_) {
return null;
}
}
// Launch-at-login wrappers. macOS + Windows both honor this; Linux is a
// no-op in Electron's API.
function getLoginItem() {
try {
const { openAtLogin } = app.getLoginItemSettings();
return Boolean(openAtLogin);
} catch (_) { return false; }
}
function setLoginItem(value) {
try {
// openAsHidden is macOS-only; on Windows the equivalent is passing
// a --hidden arg and having main.js suppress the initial window
// when the arg is present. Linux uses a .desktop file in
// ~/.config/autostart/ which Electron writes for us via this same
// call (no extra plumbing needed).
const opts = {
openAtLogin: Boolean(value),
openAsHidden: true,
};
if (process.platform === 'win32') {
opts.args = ['--hidden'];
}
app.setLoginItemSettings(opts);
return Boolean(value);
} catch (_) { return false; }
}
module.exports = {
setBackend,
setActiveChangeListener,
startPolling,
stopPolling,
getActive,
maybeVetoInstall,
drainOnQuit,
showNativeNotification,
getLoginItem,
setLoginItem,
};
@@ -34,9 +34,6 @@ import SearchIcon from '@mui/icons-material/Search';
import { motion } from 'framer-motion';
import ChatInput from '@/app/pages/AgentChat/ChatInput';
import type { ContextPath } from '@/app/components/editor/DirectoryBrowser';
import SchedulePopover from '@/app/pages/Workflows/SchedulePopover';
import { openWorkflowCard } from '@/shared/state/workflowsSlice';
import { addWorkflowCard, openWorkflowsHub } from '@/shared/state/dashboardLayoutSlice';
import { useElementSelection } from '@/app/components/editor/ElementSelectionContext';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
@@ -176,7 +173,6 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
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);
@@ -449,7 +445,6 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
// the render branch below so the popover would be hidden
// behind it otherwise.
if (inputOpen) onCancel();
setPopoverMode('search');
setHistoryOpen(true);
}}
role="button"
@@ -484,7 +479,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
padding: isExpanded ? '6px' : '5px',
userSelect: 'none' as const,
overflow: inputOpen || newAgentBounce || historyOpen ? 'visible' : 'hidden',
// historyOpen: width owned by SchedulePopover; leave undefined so framer-motion measures intrinsic size.
// historyOpen: width owned by the inline history list; leave undefined so framer-motion measures intrinsic size.
width: viewPickerOpen ? 580 : historyOpen ? undefined : isExpanded ? 540 : undefined,
}}
>
@@ -508,34 +503,57 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
/>
</div>
) : historyOpen ? (
<div style={{ width: '100%' }}>
<SchedulePopover
mode={popoverMode}
onModeChange={setPopoverMode}
hideTopChrome
historyResults={historySearch.results.map((e) => ({ 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();
}}
onExpand={() => {
// Singleton per dashboard, second Expand brings the existing card forward.
dispatch(openWorkflowsHub({ expandedSessionIds: [] }));
handleCloseHistory();
}}
historyScrollRef={historyListRef as React.RefObject<HTMLDivElement>}
onHistoryScroll={handleHistoryScroll}
/>
</div>
// Past-chat search list. Fixed-size bordered surface (matches the
// toolbar popover footprint) with a search input + scrollable
// results; clicking a row resumes that chat.
<Box sx={{ display: 'flex', flexDirection: 'column', width: 620, maxWidth: 620, flexShrink: 0 }}>
<Box sx={{
width: '100%',
height: 420,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: `${c.radius.lg}px`,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, flexShrink: 0 }}>
<SearchIcon sx={{ fontSize: 18, color: c.text.muted }} />
<InputBase
inputRef={historyInputRef}
value={historyQuery}
onChange={(e) => 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 } }}
/>
</Box>
<Box
ref={historyListRef}
onScroll={handleHistoryScroll}
sx={{ flex: 1, overflowY: 'auto', borderTop: `1px solid ${c.border.subtle}` }}
>
{historySearch.results.length === 0 && !historySearch.loading && (
<Typography sx={{ px: 1.5, py: 2.5, fontSize: '0.82rem', color: c.text.muted, textAlign: 'center' }}>
{historyQuery ? 'No matching chats' : 'No chat history yet'}
</Typography>
)}
{historySearch.results.map((entry) => (
<Box
key={entry.id}
onClick={() => handleHistorySelect(entry.id)}
sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.9, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}
>
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{entry.name}
</Typography>
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, flexShrink: 0, whiteSpace: 'nowrap' }}>
{formatRelativeTime(entry.closed_at)}
</Typography>
</Box>
))}
</Box>
</Box>
</Box>
) : viewPickerOpen ? (
<div style={{ width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1 }}>
@@ -12,9 +12,6 @@ import type {
ViewCardPosition,
BrowserCardPosition,
NotePosition,
WorkflowCardPosition,
WorkflowsHubPosition,
ConfigurePanelPosition,
} from '@/shared/state/dashboardLayoutSlice';
import type { Output } from '@/shared/state/outputsSlice';
import type { CardType, useDashboardSelection } from '../hooks/state/useDashboardSelection';
@@ -40,9 +37,6 @@ interface DashboardCanvasProps {
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
notes: Record<string, NotePosition>;
workflowCards: Record<string, WorkflowCardPosition>;
workflowsHub: WorkflowsHubPosition | null;
configurePanels: Record<string, ConfigurePanelPosition>;
outputs: Record<string, Output>;
glowingAgentCards: Record<string, GlowingAgentCard>;
expandedSessionIds: string[];
@@ -100,9 +94,6 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
viewCards,
browserCards,
notes,
workflowCards,
workflowsHub,
configurePanels,
outputs,
glowingAgentCards,
expandedSessionIds,
@@ -220,7 +211,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
}}
/>
{sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 && Object.keys(workflowCards).length === 0 && !workflowsHub ? (
{sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 ? (
<DashboardEmptyState c={c} />
) : (
<div
@@ -239,9 +230,6 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
viewCards={viewCards}
browserCards={browserCards}
notes={notes}
workflowCards={workflowCards}
workflowsHub={workflowsHub}
configurePanels={configurePanels}
outputs={outputs}
glowingAgentCards={glowingAgentCards}
expandedSessionIds={expandedSessionIds}
@@ -4,9 +4,6 @@ import AgentCard from '../cards/AgentCard';
import DashboardViewCard from '../cards/DashboardViewCard';
import BrowserCard from '../cards/BrowserCard';
import NoteCard from '../cards/NoteCard';
import WorkflowCard from '@/app/pages/Workflows/WorkflowCard';
import WorkflowsHubCard from '@/app/pages/Workflows/WorkflowsHubCard';
import ConfigurePanelCard from '@/app/pages/Workflows/ConfigurePanelCard';
import {
EXPANDED_CARD_MIN_H,
DEFAULT_CARD_W,
@@ -15,9 +12,6 @@ import {
type ViewCardPosition,
type BrowserCardPosition,
type NotePosition,
type WorkflowCardPosition,
type WorkflowsHubPosition,
type ConfigurePanelPosition,
} from '@/shared/state/dashboardLayoutSlice';
import type { Output } from '@/shared/state/outputsSlice';
import type { CardType, useDashboardSelection } from '../hooks/state/useDashboardSelection';
@@ -32,9 +26,6 @@ interface DashboardCardLayerProps {
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
notes: Record<string, NotePosition>;
workflowCards: Record<string, WorkflowCardPosition>;
workflowsHub: WorkflowsHubPosition | null;
configurePanels: Record<string, ConfigurePanelPosition>;
outputs: Record<string, Output>;
glowingAgentCards: Record<string, GlowingAgentCard>;
expandedSessionIds: string[];
@@ -68,9 +59,6 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
viewCards,
browserCards,
notes,
workflowCards,
workflowsHub,
configurePanels,
outputs,
glowingAgentCards,
expandedSessionIds,
@@ -263,48 +251,6 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
onBringToFront={onBringToFront}
/>
))}
{workflowsHub && (
<WorkflowsHubCard
cardX={workflowsHub.x}
cardY={workflowsHub.y}
cardWidth={workflowsHub.width}
cardHeight={workflowsHub.height}
cardZOrder={workflowsHub.zOrder ?? 0}
zoom={zoom}
panX={panX}
panY={panY}
/>
)}
{Object.values(workflowCards).map((wc) => (
<WorkflowCard
key={`workflow-${wc.workflow_id}`}
workflowId={wc.workflow_id}
cardX={wc.x}
cardY={wc.y}
cardWidth={wc.width}
cardHeight={wc.height}
cardZOrder={wc.zOrder ?? 0}
zoom={zoom}
panX={panX}
panY={panY}
isSelected={selection.isSelected(wc.workflow_id)}
isHighlighted={highlightedCardId === wc.workflow_id}
multiDragDelta={multiDragDelta}
onCardSelect={onCardSelect}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onDoubleClick={onDoubleClick}
onBringToFront={onBringToFront}
/>
))}
{Object.values(configurePanels).map((p) => (
<ConfigurePanelCard
key={`configure-${p.workflow_id}`}
panel={p}
zOrder={1}
/>
))}
{/* Marquee selection rectangle */}
{selection.marquee && (
<div
@@ -34,32 +34,8 @@ import { useDashboardActive } from '@/shared/hooks/useDashboardActive';
import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough';
import { useStreamingMessage } from '@/shared/state/streamingSlice';
import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canvasInteractionState';
import { openWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice';
import { addWorkflowCard, setWorkflowCardPosition, setWorkflowCardSize } from '@/shared/state/dashboardLayoutSlice';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesomeOutlined';
import { getAgentWorkTime, fmtSeconds } from '@/shared/agentWorkTime';
/** Extract up to 3 substantive user-prompt steps to seed a workflow. */
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;
}
const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ service, size = 16 }) => {
if (service === 'gmail') {
return (
@@ -245,25 +221,6 @@ const AgentCard: React.FC<Props> = ({
const hasApiKey = !!useAppSelector((s) => s.settings.data.anthropic_api_key);
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds);
// Hide the "Convert to workflow" button when this chat is already
// entangled with a workflow (Image #44 note). Two cases:
// (a) The session is one of a workflow's runner sessions, OR
// (b) The session is the source the workflow was originally derived
// from. Either way a fresh convert would just clone the workflow,
// which is confusing identity collapse.
const workflowRunsMap = useAppSelector((s) => s.workflows.runs);
const workflowItems = useAppSelector((s) => s.workflows.items);
const isWorkflowRunnerSession = useMemo(() => {
for (const arr of Object.values(workflowRunsMap || {})) {
for (const r of arr || []) {
if (r.session_id === session.id) return true;
}
}
for (const wf of Object.values(workflowItems || {})) {
if (wf.source_session_id === session.id) return true;
}
return false;
}, [workflowRunsMap, workflowItems, session.id]);
// Curated picker label with a tidy fallback for unknowns.
const friendlyModelLabel = useMemo(() => {
const value = session.model;
@@ -898,67 +855,6 @@ const AgentCard: React.FC<Props> = ({
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 && !isWorkflowRunnerSession && (
<Tooltip title="Turn this chat into a reusable, schedulable workflow">
<Box
role="button"
onClick={(e) => {
e.stopPropagation();
const steps = extractStepsFromSession(session);
if (steps.length === 0) return;
const draft: Partial<Workflow> = {
title: session.name || 'New workflow',
description: '',
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}`;
// The OG chat card BECOMES the workflow card: capture
// its position + size, remove the chat card, and drop
// the workflow card in the same physical slot. The
// chat session itself stays accessible via History.
// Per Image #61 / #62: no tether arrow, no second
// card alongside.
// Capture this card's current position/size, drop the
// workflow card in the same slot, then remove the
// source chat card.
dispatch(addWorkflowCard({
workflowId: tempId,
sourceSessionId: null,
expandedSessionIds,
}));
dispatch(setWorkflowCardPosition({ workflowId: tempId, x: cardX, y: cardY }));
dispatch(setWorkflowCardSize({ workflowId: tempId, width: cardWidth, height: cardHeight }));
dispatch(removeCard(session.id));
dispatch(openWorkflowCard({
workflowId: tempId,
sourceSessionId: null,
view: 'preview',
draft,
}));
}}
onMouseDown={(e) => e.stopPropagation()}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
color: '#fff',
bgcolor: c.accent.primary,
border: `1px solid ${c.accent.primary}`,
fontSize: '0.78rem', fontWeight: 700,
px: 1.1, py: 0.5,
borderRadius: `${c.radius.md}px`,
cursor: 'pointer',
'&:hover': { filter: 'brightness(1.05)' },
}}
>
<AutoAwesomeIcon sx={{ fontSize: 14 }} />
Convert to workflow
</Box>
</Tooltip>
)}
<Tooltip title={isDraft ? 'Remove' : 'Close chat'}>
<IconButton
size="small"
@@ -2,8 +2,6 @@ import type {
CardPosition,
ViewCardPosition,
BrowserCardPosition,
WorkflowCardPosition,
WorkflowsHubPosition,
} from '@/shared/state/dashboardLayoutSlice';
export interface ContentBounds {
@@ -19,15 +17,11 @@ export function computeContentBounds(
cards: Record<string, CardPosition>,
viewCards: Record<string, ViewCardPosition>,
browserCards: Record<string, BrowserCardPosition>,
workflowCards: Record<string, WorkflowCardPosition> = {},
workflowsHub: WorkflowsHubPosition | null = null,
): ContentBounds | undefined {
const allRects = [
...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;
@@ -1,7 +1,6 @@
import { useMemo, type RefObject } from 'react';
import type { CardPosition, BrowserCardPosition, WorkflowCardPosition, ConfigurePanelPosition } from '@/shared/state/dashboardLayoutSlice';
import type { Workflow, OpenCard } from '@/shared/state/workflowsSlice';
import { EXPANDED_CARD_MIN_H, GRID_GAP } from '@/shared/state/dashboardLayoutSlice';
import type { CardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice';
import { EXPANDED_CARD_MIN_H } from '@/shared/state/dashboardLayoutSlice';
import type { AgentSession } from '@/shared/state/agentsSlice';
const ELBOW_RADIUS = 16;
@@ -61,10 +60,6 @@ interface UseTethersArgs {
glowingBrowserCards: Record<string, GlowingBrowserCard>;
cards: Record<string, CardPosition>;
browserCards: Record<string, BrowserCardPosition>;
workflowCards: Record<string, WorkflowCardPosition>;
workflowItems: Record<string, Workflow>;
workflowOpenCards: Record<string, OpenCard>;
configurePanels: Record<string, ConfigurePanelPosition>;
expandedSessionIds: string[];
liveDragInfo: LiveDragInfo | null;
measuredHeightsRef: RefObject<Record<string, number>>;
@@ -77,10 +72,6 @@ export function useTethers({
glowingBrowserCards,
cards,
browserCards,
workflowCards,
workflowItems,
workflowOpenCards,
configurePanels,
expandedSessionIds,
liveDragInfo,
measuredHeightsRef,
@@ -235,199 +226,9 @@ export function useTethers({
const browserTethers = Array.from(glowTethers.values()).filter(Boolean) as Tether[];
// Workflow tethers reuse the browser-tether anchor/elbow math; skip deleted workflows to avoid dangling arrows.
const workflowTethers: Tether[] = [];
for (const wc of Object.values(workflowCards)) {
const sourceId = wc.source_session_id;
if (!sourceId) continue;
const src = cards[sourceId];
if (!src) continue;
// Layout entry can outlive its workflow when deleted from the hub.
const hasReal = wc.workflow_id in workflowItems;
const hasDraft = wc.workflow_id in workflowOpenCards;
if (!hasReal && !hasDraft) continue;
// "Make workflow" is a draft-time affordance; once saved (openCard leaves 'preview') the link retires.
const openCard = workflowOpenCards[wc.workflow_id];
if (openCard && openCard.view !== 'preview') 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,
});
}
// Sidecar tethers: workflow card to its sibling agent session (View Agent / Watch Live / Test Agent).
for (const wc of Object.values(workflowCards)) {
const openCard = workflowOpenCards[wc.workflow_id];
if (!openCard?.sidecarSessionId || !openCard.sidecarKind) continue;
const sidecarId = openCard.sidecarSessionId;
const sidecar = cards[sidecarId];
if (!sidecar) continue;
let srcX = wc.x, srcY = wc.y;
let dstX = sidecar.x, dstY = sidecar.y;
if (liveDragInfo) {
if (liveDragInfo.cardId === wc.workflow_id) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; }
if (liveDragInfo.cardId === sidecarId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; }
}
const dstMeasured = measuredHeightsRef.current![sidecarId];
const dstH = dstMeasured ?? (expandedSessionIds.includes(sidecarId)
? Math.max(EXPANDED_CARD_MIN_H, sidecar.height)
: sidecar.height);
const srcCx = srcX + wc.width / 2;
const dstCx = dstX + sidecar.width / 2;
const srcAnchors: Anchor[] = [
{ x: srcX + wc.width, y: srcY + wc.height * 0.54, side: 'right' },
{ x: srcX, y: srcY + wc.height * 0.54, side: 'left' },
{ x: srcCx, y: srcY, side: 'top' },
{ x: srcCx, y: srcY + wc.height, side: 'bottom' },
];
const dstAnchors: Anchor[] = [
{ x: dstX, y: dstY + dstH * 0.54, side: 'left' },
{ x: dstX + sidecar.width, y: dstY + dstH * 0.54, side: 'right' },
{ x: dstCx, y: dstY, side: 'top' },
{ x: dstCx, y: dstY + dstH, 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 pathD = elbowPath(x1, y1, x2, y2);
const midX = x1 + (x2 - x1) / 2;
const midY = y1 + (y2 - y1) / 2;
const sidecarLabel = openCard.sidecarKind === 'testing' ? 'Testing' : 'Watching';
workflowTethers.push({
key: `sidecar-${wc.workflow_id}`,
path: pathD,
labelX: midX,
labelY: midY,
label: sidecarLabel,
fading: false,
});
}
// Configure-panel tethers: anchor each open configure panel to its workflow card.
const configureTethers: Tether[] = [];
for (const p of Object.values(configurePanels)) {
const wc = workflowCards[p.workflow_id];
if (!wc) continue;
let srcX = wc.x, srcY = wc.y;
let dstX = p.x, dstY = p.y;
if (liveDragInfo) {
if (liveDragInfo.cardId === p.workflow_id) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; }
}
const srcCx = srcX + wc.width / 2;
const dstCx = dstX + p.width / 2;
const srcAnchors: Anchor[] = [
{ x: srcX + wc.width, y: srcY + wc.height * 0.5, side: 'right' },
{ x: srcX, y: srcY + wc.height * 0.5, side: 'left' },
{ x: srcCx, y: srcY, side: 'top' },
{ x: srcCx, y: srcY + wc.height, side: 'bottom' },
];
const dstAnchors: Anchor[] = [
{ x: dstX, y: dstY + p.height * 0.5, side: 'left' },
{ x: dstX + p.width, y: dstY + p.height * 0.5, side: 'right' },
{ x: dstCx, y: dstY, side: 'top' },
{ x: dstCx, y: dstY + p.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 pathD = elbowPath(x1, y1, x2, y2);
const midX = x1 + (x2 - x1) / 2;
const midY = y1 + (y2 - y1) / 2;
configureTethers.push({
key: `configure-${p.workflow_id}`,
path: pathD,
labelX: midX,
labelY: midY,
label: 'Configure',
fading: false,
});
}
return [...agentTethers, ...browserTethers, ...workflowTethers, ...configureTethers];
return [...agentTethers, ...browserTethers];
// measuredHeightsTick re-runs the memo once ResizeObserver reports a new
// height after a collapse (the ref read is invisible to the dep checker).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, configurePanels, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]);
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]);
}
@@ -23,10 +23,6 @@ export function getCardRect(id: string, type: CardType):
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;
}
@@ -3,7 +3,7 @@ import { report } from '@/shared/serviceClient';
import { useAppDispatch } from '@/shared/hooks';
import { expandSession } from '@/shared/state/agentsSlice';
import { bringToFront } from '@/shared/state/dashboardLayoutSlice';
import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition } from '@/shared/state/dashboardLayoutSlice';
import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice';
import type { CardType } from '../state/useDashboardSelection';
import type { CanvasActions } from './useCanvasControls';
@@ -13,7 +13,6 @@ interface UseArrowNavArgs {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
workflowCards: Record<string, WorkflowCardPosition>;
zoom: number;
isActive: boolean;
focusedCardId: string | null;
@@ -26,7 +25,6 @@ export function useArrowNav({
cards,
viewCards,
browserCards,
workflowCards,
zoom,
isActive,
focusedCardId,
@@ -50,9 +48,6 @@ export function useArrowNav({
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;
@@ -85,7 +80,7 @@ export function useArrowNav({
}
return best ? { id: best.id, type: best.type } : null;
}, [cards, viewCards, browserCards, workflowCards]);
}, [cards, viewCards, browserCards]);
// Compute which directions have neighbors from the focused card
const neighborDirections = useMemo(() => {
@@ -2,8 +2,7 @@ import { useEffect, type Dispatch, type SetStateAction } from 'react';
import { report } from '@/shared/serviceClient';
import { useAppDispatch } from '@/shared/hooks';
import { closeSession, toggleExpandSession } from '@/shared/state/agentsSlice';
import { removeViewCard, removeBrowserCard, removeNote, removeWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
import { closeWorkflowCard } from '@/shared/state/workflowsSlice';
import { removeViewCard, removeBrowserCard, removeNote } from '@/shared/state/dashboardLayoutSlice';
import type { useDashboardSelection } from '../state/useDashboardSelection';
type Selection = ReturnType<typeof useDashboardSelection>;
@@ -80,9 +79,6 @@ export function useDashboardShortcuts({
dispatch(removeBrowserCard(id));
} else if (type === 'note') {
dispatch(removeNote(id));
} else if (type === 'workflow') {
dispatch(removeWorkflowCard(id));
dispatch(closeWorkflowCard(id));
}
}
selection.deselectAll();
@@ -15,12 +15,9 @@ import {
resetLayout,
removeViewCard,
clearPendingFocusBrowserId,
clearPendingFocusWorkflowId,
clearPendingFocusWorkflowsHub,
type ViewCardPosition,
} from '@/shared/state/dashboardLayoutSlice';
import { fetchOutputs, type Output } from '@/shared/state/outputsSlice';
import { fetchWorkflows } from '@/shared/state/workflowsSlice';
import { dashboardWs } from '@/shared/ws/WebSocketManager';
import { initBrowserCommandHandler } from '@/shared/browserCommandHandler';
import { clearPendingBrowserUrl, clearPendingFocusAgentId } from '@/shared/state/tempStateSlice';
@@ -62,8 +59,6 @@ export function useDashboardLifecycle({
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);
// Track dashboard engagement time
useEffect(() => {
@@ -98,13 +93,11 @@ export function useDashboardLifecycle({
? (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);
@@ -221,44 +214,6 @@ export function useDashboardLifecycle({
}, 200);
}, [isActive, pendingFocusBrowserId, layoutInitialized, dispatch, canvasActions, handleHighlightCard]);
// Same pan/highlight choreography for newly-spawned workflow cards.
useEffect(() => {
if (!isActive) return;
if (!pendingFocusWorkflowId || !layoutInitialized) return;
const workflowId = pendingFocusWorkflowId;
dispatch(clearPendingFocusWorkflowId());
setTimeout(() => {
const card = store.getState().dashboardLayout.workflowCards[workflowId];
if (card) {
canvasActions.fitToCards(
[{ x: card.x, y: card.y, width: card.width, height: card.height }],
1.15,
true,
);
handleHighlightCard(workflowId);
}
}, 200);
}, [isActive, pendingFocusWorkflowId, layoutInitialized, dispatch, canvasActions, handleHighlightCard]);
// Pan/zoom to Workflows Hub on Expand; chained rAFs ensure fit runs after the hub div lands at its new coords.
useEffect(() => {
if (!isActive) return;
if (!pendingFocusWorkflowsHub || !layoutInitialized) return;
dispatch(clearPendingFocusWorkflowsHub());
const fit = () => {
const hub = store.getState().dashboardLayout.workflowsHub;
if (!hub) return;
canvasActions.fitToCards(
[{ x: hub.x, y: hub.y, width: hub.width, height: hub.height }],
1.1,
true,
);
};
requestAnimationFrame(() => requestAnimationFrame(fit));
const fallback = setTimeout(fit, 300);
return () => clearTimeout(fallback);
}, [isActive, pendingFocusWorkflowsHub, layoutInitialized, dispatch, canvasActions]);
useEffect(() => {
if (!layoutInitialized || restoredExpandedRef.current) return;
restoredExpandedRef.current = true;
@@ -30,8 +30,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
const isElementSelectMode = elementSelectionCtx?.selectMode ?? false;
const {
dashboardName, sessions, expandedSessionIds, cards, viewCards, browserCards,
workflowCards, workflowItems, workflowOpenCards, configurePanels, workflowsHub,
pendingFocusWorkflowId, pendingFocusWorkflowsHub,
notes, pendingFocusNoteId, layoutInitialized, persistedExpandedSessionIds,
zoomSensitivity, newAgentShortcut, browserHomepage, expandNewChats,
autoRevealSubAgents, outputs, outputsLoaded, glowingAgentCards, glowingBrowserCards,
@@ -42,8 +40,8 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
const sessionList = useMemo(() => Object.values(sessions), [sessions]);
const contentBounds = useMemo(
() => computeContentBounds(cards, viewCards, browserCards, workflowCards, workflowsHub),
[cards, viewCards, browserCards, workflowCards, workflowsHub],
() => computeContentBounds(cards, viewCards, browserCards),
[cards, viewCards, browserCards],
);
const canvas = useCanvasControls(zoomSensitivity, contentBounds, isActive);
@@ -53,7 +51,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
viewCards,
browserCards,
notes,
workflowCards,
);
const {
toolbarRef, toolbarOpen, setToolbarOpen, searchPaletteOpen, setSearchPaletteOpen,
@@ -141,9 +138,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
cards,
viewCards,
browserCards,
workflowCards,
configurePanels,
workflowsHub,
notes,
expandedSessionIds,
captureNow,
@@ -174,7 +168,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
cards,
viewCards,
browserCards,
workflowCards,
zoom: canvas.zoom,
isActive,
focusedCardId,
@@ -238,10 +231,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
glowingBrowserCards,
cards,
browserCards,
workflowCards,
workflowItems,
workflowOpenCards,
configurePanels,
expandedSessionIds,
liveDragInfo,
measuredHeightsRef,
@@ -252,7 +241,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
return {
c, dashboardId, dashboardName, canvas, selection, sessions, sessionList,
cards, viewCards, browserCards, notes, outputs, glowingAgentCards,
workflowCards, workflowsHub, configurePanels,
expandedSessionIds, tethers, highlightedCardId, autoFocusSessionId,
focusedCardId, pendingFocusNoteId, multiDragDelta, shakeDirection,
neighborDirections, toolbarOpen, searchPaletteOpen, newAgentBounce,
@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect, RefObject } from 'react';
import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition, WorkflowCardPosition } from '@/shared/state/dashboardLayoutSlice';
import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition } from '@/shared/state/dashboardLayoutSlice';
export type { CardType } from '@/shared/state/dashboardLayoutSlice';
import type { CardType } from '@/shared/state/dashboardLayoutSlice';
@@ -43,7 +43,6 @@ export function useDashboardSelection(
viewCards: Record<string, ViewCardPosition>,
browserCards: Record<string, BrowserCardPosition> = {},
notes: Record<string, NotePosition> = {},
workflowCards: Record<string, WorkflowCardPosition> = {},
) {
const [selectedIds, setSelectedIds] = useState<Map<string, CardType>>(new Map());
const [marquee, setMarquee] = useState<MarqueeRect | null>(null);
@@ -78,9 +77,8 @@ export function useDashboardSelection(
for (const vc of Object.values(viewCards)) next.set(vc.output_id, 'view');
for (const bc of Object.values(browserCards)) next.set(bc.browser_id, 'browser');
for (const n of Object.values(notes)) next.set(n.note_id, 'note');
for (const wc of Object.values(workflowCards)) next.set(wc.workflow_id, 'workflow');
setSelectedIds(next);
}, [cards, viewCards, browserCards, notes, workflowCards]);
}, [cards, viewCards, browserCards, notes]);
const selectCard = useCallback(
(id: string, type: CardType, shiftKey: boolean) => {
@@ -163,19 +161,6 @@ 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);
@@ -191,7 +176,7 @@ export function useDashboardSelection(
return intersecting;
},
[cards, viewCards, browserCards, notes, workflowCards],
[cards, viewCards, browserCards, notes],
);
const handleCanvasMouseDown = useCallback(
@@ -11,13 +11,6 @@ export function useDashboardSelectors(dashboardId: string) {
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 configurePanels = useAppSelector((state) => state.dashboardLayout.configurePanels);
const workflowsHub = useAppSelector((state) => state.dashboardLayout.workflowsHub);
const pendingFocusWorkflowId = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowId);
const pendingFocusWorkflowsHub = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowsHub);
const workflowItems = useAppSelector((state) => state.workflows.items);
const workflowOpenCards = useAppSelector((state) => state.workflows.openCards);
const notes = useAppSelector((state) => state.dashboardLayout.notes);
const pendingFocusNoteId = useAppSelector((state) => state.dashboardLayout.pendingFocusNoteId);
const layoutInitialized = useAppSelector((state) => state.dashboardLayout.initialized);
@@ -39,13 +32,6 @@ export function useDashboardSelectors(dashboardId: string) {
cards,
viewCards,
browserCards,
workflowCards,
workflowItems,
workflowOpenCards,
configurePanels,
workflowsHub,
pendingFocusWorkflowId,
pendingFocusWorkflowsHub,
notes,
pendingFocusNoteId,
layoutInitialized,
@@ -6,9 +6,6 @@ import {
type ViewCardPosition,
type BrowserCardPosition,
type NotePosition,
type WorkflowCardPosition,
type ConfigurePanelPosition,
type WorkflowsHubPosition,
} from '@/shared/state/dashboardLayoutSlice';
interface UseLayoutSaveArgs {
@@ -18,9 +15,6 @@ interface UseLayoutSaveArgs {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
workflowCards: Record<string, WorkflowCardPosition>;
configurePanels: Record<string, ConfigurePanelPosition>;
workflowsHub: WorkflowsHubPosition | null;
notes: Record<string, NotePosition>;
expandedSessionIds: string[];
captureNow: () => void;
@@ -37,9 +31,6 @@ export function useLayoutSave({
cards,
viewCards,
browserCards,
workflowCards,
configurePanels,
workflowsHub,
notes,
expandedSessionIds,
captureNow,
@@ -56,7 +47,7 @@ export function useLayoutSave({
skipInitialSave.current = false;
return;
}
const payload = { dashboardId, cards, viewCards, browserCards, workflowCards, configurePanels, workflowsHub, notes, expandedSessionIds };
const payload = { dashboardId, cards, viewCards, browserCards, notes, expandedSessionIds };
pendingSaveRef.current = payload;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
@@ -65,7 +56,7 @@ export function useLayoutSave({
saveTimerRef.current = null;
captureNow();
}, 500);
}, [isActive, cards, viewCards, browserCards, workflowCards, configurePanels, workflowsHub, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]);
}, [isActive, cards, viewCards, browserCards, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]);
useEffect(() => {
return () => {
@@ -1,80 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { openConfigurePanel, closeConfigurePanel } from '@/shared/state/dashboardLayoutSlice';
import type { Workflow } from '@/shared/state/workflowsSlice';
import { BODY_FS, LABEL_FS } from './workflowEditCommon';
export default function ActionsFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
// Configure pops the Action Library out as a separate dashboard card
// tethered to this workflow (image #120). Lives in
// dashboardLayout.configurePanels keyed by workflow id; user can drag,
// resize, and X-close from there.
const configuring = useAppSelector((s) => Boolean(s.dashboardLayout.configurePanels[draft.id]));
const toggleConfigure = () => {
if (configuring) dispatch(closeConfigurePanel(draft.id));
else dispatch(openConfigurePanel({ workflowId: draft.id }));
};
// If the user flips Freeze off while the popout is open, close it so
// the orphaned card doesn't keep listening to a workflow that no
// longer wants a frozen action set.
React.useEffect(() => {
if (!draft.actions.freeze && configuring) {
dispatch(closeConfigurePanel(draft.id));
}
}, [draft.actions.freeze, draft.id, configuring, dispatch]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, color: c.text.secondary }}>
<Typography sx={{ fontSize: BODY_FS, color: c.text.secondary, lineHeight: 1.5 }}>
Do you want to prevent the agent from taking actions that weren&apos;t used in the original workflow?
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Select
size="small"
value={draft.actions.prevent_unused ? 'prevent' : 'allow'}
onChange={(e) => setDraft({ ...draft, actions: { ...draft.actions, prevent_unused: e.target.value === 'prevent' } })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="prevent">Prevent all unwanted actions</MenuItem>
<MenuItem value="allow">Allow all actions</MenuItem>
</Select>
</Box>
<Typography sx={{ fontSize: BODY_FS, color: c.text.secondary, lineHeight: 1.5, mt: 0.5 }}>
Do you want to freeze the actions available to the Agent so this flow always works even if you change your settings?
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Select
size="small"
value={draft.actions.freeze ? 'freeze' : 'dont'}
onChange={(e) => setDraft({ ...draft, actions: { ...draft.actions, freeze: e.target.value === 'freeze' } })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="freeze">Freeze actions</MenuItem>
<MenuItem value="dont">Don&apos;t freeze</MenuItem>
</Select>
</Box>
{/* Configure only makes sense when actions are frozen: the user
is explicitly picking a curated subset. With "Don't freeze",
the agent inherits global settings, so there's nothing to
configure here. Auto-close the panel on un-freeze so a stale
popout doesn't outlive the toggle. */}
{draft.actions.freeze && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 0.5 }}>
<Box
onClick={toggleConfigure}
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'}
</Box>
</Box>
)}
</Box>
);
}
@@ -1,158 +0,0 @@
import React, { useCallback, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch } from '@/shared/hooks';
import {
closeConfigurePanel,
setConfigurePanelPosition,
setConfigurePanelSize,
type ConfigurePanelPosition,
} from '@/shared/state/dashboardLayoutSlice';
import Tools from '@/app/pages/Tools/Tools';
const MIN_W = 420;
const MIN_H = 320;
const EDGE = 6;
export default function ConfigurePanelCard({ panel, zOrder }: { panel: ConfigurePanelPosition; zOrder: number }) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
const [localPos, setLocalPos] = useState<{ x: number; y: number } | null>(null);
const [localSize, setLocalSize] = useState<{ w: number; h: number } | null>(null);
const onDragStart = useCallback((e: React.PointerEvent) => {
e.stopPropagation();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: panel.x, origY: panel.y };
setLocalPos({ x: panel.x, y: panel.y });
}, [panel.x, panel.y]);
const onDragMove = useCallback((e: React.PointerEvent) => {
if (!dragRef.current) return;
const dx = e.clientX - dragRef.current.startX;
const dy = e.clientY - dragRef.current.startY;
const nx = dragRef.current.origX + dx;
const ny = dragRef.current.origY + dy;
setLocalPos({ x: nx, y: ny });
// Push the live position into Redux so the dashboard tether stays
// glued to the panel during the drag instead of lagging until pointer
// up. setLocalPos is kept for sub-frame smoothness, but Redux is the
// tether's source of truth.
dispatch(setConfigurePanelPosition({ workflowId: panel.workflow_id, x: nx, y: ny }));
}, [dispatch, panel.workflow_id]);
const onDragEnd = useCallback((e: React.PointerEvent) => {
if (!dragRef.current) return;
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
dragRef.current = null;
setLocalPos(null);
}, []);
const onResizeStart = useCallback((e: React.PointerEvent) => {
e.stopPropagation();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: panel.width, origH: panel.height };
setLocalSize({ w: panel.width, h: panel.height });
}, [panel.width, panel.height]);
const onResizeMove = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return;
const dw = e.clientX - resizeRef.current.startX;
const dh = e.clientY - resizeRef.current.startY;
setLocalSize({
w: Math.max(MIN_W, resizeRef.current.origW + dw),
h: Math.max(MIN_H, resizeRef.current.origH + dh),
});
}, []);
const onResizeEnd = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return;
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
if (localSize) {
dispatch(setConfigurePanelSize({ workflowId: panel.workflow_id, width: localSize.w, height: localSize.h }));
}
resizeRef.current = null;
setLocalSize(null);
}, [dispatch, localSize, panel.workflow_id]);
const displayX = localPos?.x ?? panel.x;
const displayY = localPos?.y ?? panel.y;
const displayW = localSize?.w ?? panel.width;
const displayH = localSize?.h ?? panel.height;
return (
<Box
data-select-type="configure-panel"
data-select-id={panel.workflow_id}
sx={{
position: 'absolute',
left: displayX,
top: displayY,
width: displayW,
height: displayH,
bgcolor: c.bg.surface,
border: `1px solid ${c.accent.primary}80`,
borderRadius: `${c.radius.lg}px`,
boxShadow: c.shadow.lg,
zIndex: zOrder,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Drag handle + close X strip across the top. Stays slim so the
full Action Library underneath gets the vertical space. */}
<Box
onPointerDown={onDragStart}
onPointerMove={onDragMove}
onPointerUp={onDragEnd}
onPointerCancel={onDragEnd}
sx={{
display: 'flex', alignItems: 'center', gap: 0.5,
px: 1, py: 0.5,
borderBottom: `1px solid ${c.border.subtle}`,
bgcolor: c.bg.surface,
cursor: 'grab',
'&:active': { cursor: 'grabbing' },
flexShrink: 0,
userSelect: 'none',
}}>
<DragIndicatorIcon sx={{ fontSize: 14, color: c.text.muted }} />
<Box sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.text.secondary, flex: 1 }}>Action Library</Box>
<IconButton
size="small"
onClick={() => dispatch(closeConfigurePanel(panel.workflow_id))}
onPointerDown={(e) => e.stopPropagation()}
sx={{ p: 0.25, color: c.text.muted, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
{/* Body: the real Action Library, exact same component as /actions. */}
<Box sx={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
<Tools />
</Box>
{/* SE resize handle. */}
<Box
onPointerDown={onResizeStart}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd}
sx={{
position: 'absolute',
right: 0, bottom: 0,
width: 14, height: 14,
cursor: 'nwse-resize',
opacity: 0.6,
'&:hover': { opacity: 1 },
// Diagonal stripes for the universal "drag-resize" hint.
background: `linear-gradient(135deg, transparent 50%, ${c.border.medium} 50%, ${c.border.medium} 60%, transparent 60%, transparent 75%, ${c.border.medium} 75%, ${c.border.medium} 85%, transparent 85%)`,
borderBottomRightRadius: `${EDGE}px`,
}}
/>
</Box>
);
}
@@ -1,323 +0,0 @@
// Image #38, #48: Edit Agent embedded in the workflow card.
// Creates a real, sticky-per-workflow agent session via /workflows/{id}/
// edit-agent-session and embeds AgentChat so tool calls render as their
// normal cards (MCP Activation, Gmail Query, etc.). Header keeps the
// subtitle on the left and Settings + Discard + Save on the right. In
// fix mode (Image #48) the very first message in the session is a
// failure-context prompt, and a red prefix card renders above the chat
// so the user sees Why we're here at a glance.
import React, { useCallback, useEffect, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Dialog from '@mui/material/Dialog';
import Tooltip from '@mui/material/Tooltip';
import DeleteOutlineRounded from '@mui/icons-material/DeleteOutlineRounded';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import BuildRounded from '@mui/icons-material/BuildRounded';
import TuneRounded from '@mui/icons-material/TuneRounded';
import KeyboardArrowDownRounded from '@mui/icons-material/KeyboardArrowDownRounded';
import ScienceOutlined from '@mui/icons-material/ScienceOutlined';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { clearFixSeed, setCardSidecar, updateWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice';
import { DEFAULT_CARD_W, DEFAULT_CARD_H, placeCard } from '@/shared/state/dashboardLayoutSlice';
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
import { fetchSession } from '@/shared/state/agentsSlice';
import StepList from './StepList';
import { API_BASE, getAuthToken } from '@/shared/config';
import AgentChat from '@/app/pages/AgentChat/AgentChat';
interface Props {
workflow: Workflow;
steps: Workflow['steps'];
isFixMode?: boolean;
}
function InlineSubtitle({ workflow }: { workflow: Workflow }) {
const c = useClaudeTokens();
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
const runs = useAppSelector((s) => s.workflows.runs[workflow.id]);
const modelLabel = React.useMemo(() => {
if (!workflow?.model) return '';
for (const list of Object.values(modelsByProvider || {})) {
for (const m of (list as Array<{ value: string; label?: string }>) || []) {
if (m.value === workflow.model) return m.label || workflow.model;
}
}
return workflow.model;
}, [workflow?.model, modelsByProvider]);
const duration = React.useMemo(() => {
if (!runs || runs.length === 0) return '';
const last = runs.find((r) => r.finished_at);
if (!last || !last.finished_at) return '';
const ms = new Date(last.finished_at).getTime() - new Date(last.started_at).getTime();
if (ms <= 0) return '';
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
return `${Math.floor(ms / 60_000)}m`;
}, [runs]);
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1.25, fontSize: '0.82rem', color: c.text.muted, minWidth: 0, overflow: 'hidden' }}>
{modelLabel && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{modelLabel}</Box>}
{workflow.mode && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{workflow.mode}</Box>}
{duration && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{duration}</Box>}
</Box>
);
}
export default function EditAgentView({ workflow, steps, isFixMode = false }: Props) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const card = useAppSelector((s) => s.workflows.openCards[workflow.id]);
const wfCardPos = useAppSelector((s) => s.dashboardLayout.workflowCards[workflow.id]);
const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds);
const fixSeed = card?.fixSeed || null;
const [busy, setBusy] = useState(false);
const [showSaveBeforeTest, setShowSaveBeforeTest] = useState(false);
const [fixPrefixExpanded, setFixPrefixExpanded] = useState(false);
const [editSessionId, setEditSessionId] = useState<string | null>(workflow.edit_agent_session_id || null);
const [seedSent, setSeedSent] = useState(false);
// Clear the fix seed after the view unmounts so re-entering edit_agent
// (without going through Fix-with-Agent) doesn't re-show the prefix.
useEffect(() => () => { dispatch(clearFixSeed(workflow.id)); }, [dispatch, workflow.id]);
// Spawn (or reattach to) the sticky Edit Agent session on mount.
useEffect(() => {
if (editSessionId) return;
let alive = true;
(async () => {
try {
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflow.id)}/edit-agent-session`, {
method: 'POST',
headers: tok ? { Authorization: `Bearer ${tok}` } : {},
});
if (!res.ok) return;
const data = await res.json();
const sid = data?.session_id as string | undefined;
if (!sid || !alive) return;
try { await dispatch(fetchSession(sid)).unwrap(); } catch { /* may not be hydrated yet */ }
if (alive) setEditSessionId(sid);
} catch { /* best-effort */ }
})();
return () => { alive = false; };
}, [editSessionId, workflow.id, dispatch]);
// First-turn seed: post the hidden opener so the agent's first reply
// is the friendly "How would you like to modify the workflow..." prompt
// (or, in fix mode, an analysis of the failure context).
const editSession = useAppSelector((s) => editSessionId ? s.agents.sessions[editSessionId] : undefined);
useEffect(() => {
if (!editSessionId || !editSession || seedSent) return;
const msgs = editSession.messages || [];
if (msgs.length > 0) {
setSeedSent(true);
return;
}
const seed = isFixMode && fixSeed
? `The most recent run failed on Step ${fixSeed.stepIdx + 1} (${fixSeed.stepLabel}). Error: ${fixSeed.error}\n\nWalk me through what likely went wrong and propose a concrete prompt change for that step.`
: 'Greet me briefly, then ask: "How would you like to modify the workflow (e.g. filter out spam emails before summarizing)?"';
setSeedSent(true);
(async () => {
try {
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
await fetch(`${API_BASE}/agents/sessions/${encodeURIComponent(editSessionId)}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) },
body: JSON.stringify({ prompt: seed, hidden: true }),
});
} catch { /* best-effort */ }
})();
}, [editSessionId, editSession, seedSent, isFixMode, fixSeed]);
const onClose = useCallback(() => {
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } }));
}, [dispatch, workflow.id]);
const onTest = useCallback(async () => {
if (busy) return;
setBusy(true);
try {
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflow.id)}/test-run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) },
body: JSON.stringify({ steps: steps.map((s) => ({ id: s.id, text: s.text, label: s.label || null })) }),
});
if (!res.ok) return;
const data = await res.json();
const sessionId = data?.session_id as string | undefined;
if (!sessionId) return;
try {
const { store } = await import('@/shared/state/store');
if (!store.getState().agents.sessions[sessionId]) {
try { await dispatch(fetchSession(sessionId)).unwrap(); } catch { /* not fatal */ }
}
if (!store.getState().dashboardLayout.cards[sessionId] && wfCardPos) {
dispatch(placeCard({
sessionId,
x: wfCardPos.x + wfCardPos.width + 60,
y: wfCardPos.y,
width: DEFAULT_CARD_W,
height: DEFAULT_CARD_H,
expandedSessionIds,
}));
}
dispatch(setPendingFocusAgentId(sessionId));
} catch { /* best-effort */ }
dispatch(setCardSidecar({ workflowId: workflow.id, sessionId, kind: 'testing' }));
} finally {
setBusy(false);
}
}, [busy, workflow.id, steps, dispatch, wfCardPos, expandedSessionIds]);
const onTestClick = useCallback(() => {
// No local draft to warn about anymore (the Edit Agent's tool will
// mutate workflow.steps directly when wired). Skip the modal for now.
void onTest();
}, [onTest]);
void showSaveBeforeTest; void setShowSaveBeforeTest;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<InlineSubtitle workflow={workflow} />
<Box sx={{ flex: 1 }} />
<Tooltip title="Permissions, actions, cost cap">
<Box
onClick={() => dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'edit', editFacet: 'Actions' } }))}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 28, height: 28, borderRadius: 999,
color: c.text.secondary, cursor: 'pointer',
'&:hover': { color: c.text.primary, bgcolor: c.bg.elevated },
}}>
<TuneRounded sx={{ fontSize: 16 }} />
</Box>
</Tooltip>
<Tooltip title="Spawn a Test Agent that runs the latest workflow next to this card with a Testing arrow chip.">
<Box
onClick={onTestClick}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.3,
fontSize: '0.78rem', fontWeight: 700,
color: c.accent.primary, bgcolor: 'transparent',
px: 1, py: 0.4, borderRadius: 999,
border: `1px solid ${c.accent.primary}55`,
cursor: busy ? 'not-allowed' : 'pointer',
opacity: busy ? 0.5 : 1,
'&:hover': { bgcolor: c.accent.primary + '14' },
}}>
<ScienceOutlined sx={{ fontSize: 14 }} />
Test
</Box>
</Tooltip>
<HeaderBtn
label="Discard"
icon={<DeleteOutlineRounded sx={{ fontSize: 16 }} />}
onClick={onClose}
tone="muted"
/>
<HeaderBtn
label="Save"
icon={<SaveOutlinedIcon sx={{ fontSize: 16 }} />}
onClick={onClose}
tone="filled"
/>
</Box>
<StepList steps={steps} />
{isFixMode && fixSeed && <FixPrefixCard seed={fixSeed} expanded={fixPrefixExpanded} onToggle={() => setFixPrefixExpanded((x) => !x)} />}
{/* Embedded real Edit Agent chat. AgentChat owns the composer +
message list + tool-call card rendering, matching Image #48
(MCP Activation, Gmail Query, etc.). embedded=true tells it to
skip its own dashboard chrome since we own the surrounding card. */}
<Box sx={{ flex: 1, minHeight: 280, display: 'flex', flexDirection: 'column', mx: -1, mb: -1 }}>
{editSessionId ? (
<AgentChat sessionId={editSessionId} embedded autoFocus />
) : (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: c.text.muted, fontSize: '0.85rem' }}>
Starting the Edit Agent...
</Box>
)}
</Box>
<Dialog open={false} onClose={() => {}} maxWidth="sm" fullWidth>
<Box />
</Dialog>
</Box>
);
}
function FixPrefixCard({ seed, expanded, onToggle }: { seed: { stepIdx: number; stepLabel: string; error: string }; expanded: boolean; onToggle: () => void }) {
const c = useClaudeTokens();
const PREVIEW_MAX = 110;
const needsExpand = (seed.error || '').length > PREVIEW_MAX;
const shown = !needsExpand || expanded
? seed.error
: (seed.error || '').slice(0, PREVIEW_MAX).trimEnd() + '...';
return (
<Box
onClick={needsExpand ? onToggle : undefined}
sx={{
display: 'flex', alignItems: 'flex-start', gap: 1.25,
p: 1.25, borderRadius: `${c.radius.lg}px`,
bgcolor: c.status.errorBg,
border: `1px solid ${c.status.error}30`,
cursor: needsExpand ? 'pointer' : 'default',
'&:hover': needsExpand ? { bgcolor: c.status.error + '14' } : {},
}}>
<Box sx={{
width: 32, height: 32, borderRadius: `${c.radius.md}px`,
bgcolor: c.status.error + '22', color: c.status.error,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<BuildRounded sx={{ fontSize: 16 }} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography sx={{ flex: 1, fontSize: '0.92rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.3 }}>
Fixing Step {seed.stepIdx + 1}: {seed.stepLabel}
</Typography>
{needsExpand && (
<KeyboardArrowDownRounded sx={{
fontSize: 18,
color: c.text.muted,
transform: expanded ? 'rotate(180deg)' : 'none',
transition: 'transform 0.18s ease',
flexShrink: 0,
}} />
)}
</Box>
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, mt: 0.25, lineHeight: 1.45, whiteSpace: 'pre-wrap' }}>
{shown}
</Typography>
</Box>
</Box>
);
}
function HeaderBtn({ label, icon, onClick, tone, disabled }: { label: string; icon: React.ReactNode; onClick: () => void; tone: 'muted' | 'filled'; disabled?: boolean }) {
const c = useClaudeTokens();
const filled = tone === 'filled';
return (
<Box
onClick={disabled ? undefined : onClick}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
fontSize: '0.82rem', fontWeight: 700,
px: 1.1, py: 0.45, borderRadius: 999,
color: filled ? '#fff' : c.text.secondary,
bgcolor: filled ? c.text.primary : 'transparent',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
'&:hover': filled ? { filter: 'brightness(1.05)' } : { color: c.text.primary, bgcolor: c.bg.elevated },
}}>
{icon}
{label}
</Box>
);
}
@@ -1,132 +0,0 @@
import React 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 EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch } from '@/shared/hooks';
import { fetchSession, resumeSession } from '@/shared/state/agentsSlice';
import {
DEFAULT_CARD_H,
DEFAULT_CARD_W,
placeCard,
} from '@/shared/state/dashboardLayoutSlice';
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
import { store } from '@/shared/state/store';
import type { Workflow } from '@/shared/state/workflowsSlice';
import { FieldRow, BODY_FS, LABEL_FS, HINT_FS, INPUT_FS } from './workflowEditCommon';
export default function GeneralFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const sourceSessionId = draft.source_session_id || null;
// Open the source chat: fetch if missing, fall through to resume if
// it was closed, place a card if there isn't one. That's it. No pan
// animation, no focus pin, no dashboard_id patching, no auto-clear
// timers. Match the way any other chat opens on the canvas; let the
// user scroll to it.
const openSourceChat = React.useCallback(async () => {
if (!sourceSessionId) return;
const sid = sourceSessionId;
if (!store.getState().agents.sessions[sid]) {
try {
await dispatch(fetchSession(sid)).unwrap();
} catch {
try {
await dispatch(resumeSession({ sessionId: sid })).unwrap();
} catch {
return;
}
}
}
if (!store.getState().dashboardLayout.cards[sid]) {
dispatch(placeCard({
sessionId: sid,
x: 400, y: 200,
width: DEFAULT_CARD_W,
height: DEFAULT_CARD_H,
}));
}
// Pan the canvas to the chat card so the user can see it. Safe to
// do here because the active element is the Edit button, not a
// textarea: handleCardSelect's input-aware blur guard prevents the
// focus animation from killing typing focus in a separate flow.
dispatch(setPendingFocusAgentId(sid));
}, [sourceSessionId, dispatch]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
<FieldRow label="Title">
<InputBase
value={draft.title}
onChange={(e) => 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 }}
/>
</FieldRow>
<FieldRow label="Description" align="top">
<InputBase
multiline
minRows={2}
value={draft.description}
onChange={(e) => 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 }}
/>
</FieldRow>
<FieldRow label="System prompt">
<Select
size="small"
value={draft.use_synced_prompt ? 'synced' : 'custom'}
onChange={(e) => setDraft({ ...draft, use_synced_prompt: e.target.value === 'synced' })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="synced">Synced to settings</MenuItem>
<MenuItem value="custom">Custom</MenuItem>
</Select>
</FieldRow>
{!draft.use_synced_prompt && (
<InputBase
multiline
minRows={4}
placeholder="Custom system prompt..."
value={draft.system_prompt || ''}
onChange={(e) => 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 }}
/>
)}
<Box sx={{ display: 'flex', alignItems: 'center', mt: 0.5 }}>
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, flex: 1 }}>Workflow</Typography>
{sourceSessionId && (
<Box
role="button"
onClick={openSourceChat}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
fontSize: LABEL_FS, fontWeight: 600,
color: c.text.muted, cursor: 'pointer',
'&:hover': { color: c.accent.primary },
}}>
<EditOutlinedIcon sx={{ fontSize: 14 }} />
Edit
</Box>
)}
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{draft.steps.map((s, idx) => (
<Box key={s.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}>
<Box sx={{ width: 24, height: 24, borderRadius: '50%', border: `1px solid ${c.border.medium}`, fontSize: HINT_FS, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', color: c.text.secondary, flexShrink: 0, mt: 0.4 }}>{idx + 1}</Box>
<InputBase
multiline
value={s.text}
onChange={(e) => {
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 }}
/>
</Box>
))}
</Box>
</Box>
);
}
@@ -1,431 +0,0 @@
import React, { useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import Popover from '@mui/material/Popover';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import type { Workflow } from '@/shared/state/workflowsSlice';
import { runWorkflowNow, deleteWorkflow, updateWorkflow, openWorkflowCard } from '@/shared/state/workflowsSlice';
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
import { WEEKDAY_FULL, 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 dispatch = useAppDispatch();
const workflows = useAppSelector((s) => Object.values(s.workflows.items));
// Right-click menu: pinned position + the workflow whose pill was
// clicked. Same anchor pattern as MUI's menu examples.
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; workflow: Workflow } | null>(null);
const closeMenu = () => setCtxMenu(null);
const onRunNow = () => {
if (!ctxMenu) return;
dispatch(runWorkflowNow(ctxMenu.workflow.id));
closeMenu();
};
const onPauseToggle = () => {
if (!ctxMenu) return;
const wf = ctxMenu.workflow;
dispatch(updateWorkflow({
id: wf.id,
patch: { schedule: { ...wf.schedule, enabled: !wf.schedule.enabled } as any },
ifMatch: wf.updated_at || null,
}));
closeMenu();
};
const onEdit = () => {
if (!ctxMenu) return;
dispatch(addWorkflowCard({ workflowId: ctxMenu.workflow.id }));
// Right-click "Edit" on a calendar entry opens the new Edit Agent
// chat view, matching the post-revamp design (Image #38).
dispatch(openWorkflowCard({ workflowId: ctxMenu.workflow.id, view: 'edit_agent' }));
closeMenu();
};
const onDelete = () => {
if (!ctxMenu) return;
const ok = window.confirm(`Delete "${ctxMenu.workflow.title}"? Scheduled runs will stop.`);
if (!ok) { closeMenu(); return; }
dispatch(deleteWorkflow(ctxMenu.workflow.id));
closeMenu();
};
const ctxMenuEl = (
<Menu
open={Boolean(ctxMenu)}
onClose={closeMenu}
anchorReference="anchorPosition"
anchorPosition={ctxMenu ? { top: ctxMenu.y, left: ctxMenu.x } : undefined}>
<MenuItem onClick={onRunNow}>Run now</MenuItem>
<MenuItem onClick={onPauseToggle}>{ctxMenu?.workflow.schedule.enabled ? 'Pause schedule' : 'Resume schedule'}</MenuItem>
<MenuItem onClick={onEdit}>Edit</MenuItem>
<MenuItem onClick={onDelete} sx={{ color: c.status.error }}>Delete</MenuItem>
</Menu>
);
// 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<string, { workflow: Workflow; date: Date }[]>();
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 ? 32 : 44;
const ROW_LABEL = compact ? '0.7rem' : '0.74rem';
const DAY_NUM = compact ? '0.95rem' : '1.15rem';
const DAY_LABEL = compact ? '0.66rem' : '0.72rem';
const EVENT_FS = compact ? '0.7rem' : '0.78rem';
if (view === 'Week') {
const start = startOfWeek(today);
const days = Array.from({ length: 7 }, (_, i) => addDays(start, i));
const HOURS = HOURS_24;
// Prefer the short zone name ("PDT", "EST", "JST") so the label
// reads in plain English instead of "GMT-7". formatToParts is wide-
// supported; if it ever fails we degrade silently rather than show
// a confusing fallback.
const TZ_LABEL = (() => {
try {
const parts = new Intl.DateTimeFormat('en', { timeZoneName: 'short' }).formatToParts(new Date());
return parts.find((p) => p.type === 'timeZoneName')?.value || '';
} catch { return ''; }
})();
return (
<Box sx={{ display: 'flex', flexDirection: 'column', color: c.text.secondary }}>
{/* Day headers: muted weekday caps; today's date gets the filled circle */}
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', gap: 0, position: 'sticky', top: 0, bgcolor: c.bg.surface, zIndex: 2, pb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'flex-end', pr: 1, pb: 0.5 }}>
{!compact && (
<Typography sx={{ fontSize: '0.62rem', color: c.text.ghost, fontWeight: 500 }}>{TZ_LABEL}</Typography>
)}
</Box>
{days.map((d) => {
const isToday = sameDay(d, today);
return (
<Box key={d.toISOString()} sx={{ textAlign: 'center', pb: 0.5 }}>
<Typography sx={{ fontSize: DAY_LABEL, color: c.text.muted, fontWeight: 600, letterSpacing: '0.08em', lineHeight: 1.3, textTransform: 'uppercase' }}>
{WEEKDAY_LABEL_SHORT[d.getDay()]}
</Typography>
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: compact ? 30 : 38, height: compact ? 30 : 38, borderRadius: '50%', bgcolor: isToday ? c.accent.primary : 'transparent', color: isToday ? '#fff' : c.text.primary, fontWeight: isToday ? 700 : 500, fontSize: DAY_NUM, mt: 0.25 }}>{d.getDate()}</Box>
</Box>
);
})}
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', borderTop: `1px solid ${c.border.subtle}` }}>
{HOURS.map((hour, hourIdx) => (
<React.Fragment key={hour}>
{/* Hour label sits inside its row (top-aligned) rather than
straddling the line above it; that way the first row
doesn't clip "12 AM" and the labels never drift when the
body scrolls. Apple Calendar does the same. */}
<Box sx={{
height: SLOT_H, fontSize: ROW_LABEL,
color: c.text.ghost, fontWeight: 500,
textAlign: 'right', pr: 1, pt: 0.25,
borderTop: hourIdx === 0 ? 'none' : `1px solid ${c.border.subtle}`,
}}>
{formatHourLabel(hour)}
</Box>
{days.map((d) => {
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const evs = (eventsByDay.map.get(key) || []).filter((e) => e.date.getHours() === hour);
const targetWeekday = d.getDay();
return (
<Box
key={`${d.toISOString()}-${hour}`}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
onDrop={(e) => {
e.preventDefault();
const wid = e.dataTransfer.getData('application/x-workflow-id');
if (!wid) return;
const wf = workflows.find((w) => w.id === wid);
if (!wf) return;
// Build the patched schedule: new hour, and for
// weekly schedules swap on_days to just the target
// weekday. Daily/monthly only get the new hour.
const sched = { ...wf.schedule, hour } as typeof wf.schedule;
if (sched.repeat_unit === 'week') sched.on_days = [targetWeekday];
dispatch(updateWorkflow({
id: wf.id,
patch: { schedule: sched as any },
ifMatch: wf.updated_at || null,
}));
}}
sx={{ height: SLOT_H, borderLeft: `1px solid ${c.border.subtle}`, borderTop: hourIdx === 0 ? 'none' : `1px solid ${c.border.subtle}`, position: 'relative' }}>
<EventStack
events={evs}
onSelectWorkflow={onSelectWorkflow}
eventFontSize={EVENT_FS}
onContextWorkflow={(wf, ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: wf }); }}
/>
</Box>
);
})}
</React.Fragment>
))}
</Box>
{ctxMenuEl}
</Box>
);
}
if (view === 'Month') {
const start = startOfMonthGrid(today);
const cells = Array.from({ length: 35 }, (_, i) => addDays(start, i));
const accent = c.accent.primary;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Sticky weekday header so it stays visible even when the
calendar body scrolls. Slightly bigger + tinted bg so it
reads cleanly in both light and dark themes. */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', position: 'sticky', top: 0, bgcolor: c.bg.surface, zIndex: 2, borderBottom: `1px solid ${c.border.subtle}`, py: 0.6 }}>
{WEEKDAY_LABEL_SHORT.map((l, i) => (
<Typography key={`${l}-${i}`} sx={{ textAlign: 'center', fontSize: '0.74rem', color: c.text.secondary, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase' }}>{l}</Typography>
))}
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 0, borderLeft: `1px solid ${c.border.subtle}` }}>
{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 (
<Box key={d.toISOString()} sx={{ minHeight: compact ? 70 : 96, borderRight: `1px solid ${c.border.subtle}`, borderBottom: `1px solid ${c.border.subtle}`, p: 0.5, position: 'relative', overflow: 'hidden', bgcolor: inMonth ? 'transparent' : c.bg.elevated }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
{/* Out-of-month dates still need to be legible (Apple
Calendar shows them in a muted shade, not invisible).
Color tweak instead of opacity so dark themes stay
readable. */}
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', minWidth: 22, height: 22, borderRadius: '50%', bgcolor: isToday ? accent : 'transparent', color: isToday ? '#fff' : inMonth ? c.text.primary : c.text.ghost, fontWeight: isToday ? 700 : 500, fontSize: '0.82rem', px: 0.5 }}>{d.getDate()}</Box>
</Box>
{evs.slice(0, compact ? 3 : 4).map((e, idx) => (
<Box
key={`${e.workflow.id}-${idx}`}
onClick={() => onSelectWorkflow?.(e.workflow.id)}
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
sx={{ mt: 0.3, display: 'flex', alignItems: 'center', gap: 0.5, fontSize: EVENT_FS, color: c.text.primary, cursor: 'pointer', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', '&:hover': { color: accent } }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: accent, flexShrink: 0 }} />
<span style={{ color: c.text.muted, flexShrink: 0 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, fontWeight: 500 }}>{e.workflow.title}</span>
</Box>
))}
{evs.length > (compact ? 3 : 4) && (
<Typography sx={{ fontSize: EVENT_FS, color: c.text.muted, mt: 0.3, pl: 1.4 }}>+{evs.length - (compact ? 3 : 4)} more</Typography>
)}
</Box>
);
})}
</Box>
{ctxMenuEl}
</Box>
);
}
// Apple-Calendar-style list: big day number + weekday on the left, a
// vertical colored bar separating it from events on the right. Today
// renders even with no events (shows a "No events today" placeholder)
// so the list doesn't feel empty for new users.
const upcoming: { date: Date; events: { workflow: Workflow; date: Date }[]; isToday: boolean }[] = [];
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) || [];
const isToday = sameDay(day, today);
if (arr.length || isToday) upcoming.push({ date: day, events: arr, isToday });
}
const accent = c.accent.primary;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.lg}px`, overflow: 'hidden', bgcolor: c.bg.surface }}>
{upcoming.length === 0 && (
<Typography sx={{ fontSize: '0.85rem', color: c.text.muted, textAlign: 'center', py: 3 }}>No scheduled workflows</Typography>
)}
{upcoming.map(({ date, events, isToday }, rowIdx) => (
<Box
key={date.toISOString()}
sx={{
display: 'flex', alignItems: 'stretch',
borderTop: rowIdx === 0 ? 'none' : `1px dashed ${c.border.subtle}`,
minHeight: 64,
}}>
<Box sx={{ width: 96, flexShrink: 0, display: 'flex', alignItems: 'center', gap: 0.75, pl: 2, pr: 1.25 }}>
<Typography sx={{ fontSize: '1.55rem', fontWeight: 600, color: isToday ? accent : c.text.primary, lineHeight: 1, letterSpacing: '-0.01em' }}>
{date.getDate()}
</Typography>
<Box>
<Typography sx={{ fontSize: '0.78rem', color: isToday ? accent : c.text.secondary, fontWeight: 500, lineHeight: 1.2 }}>
{date.toLocaleString('en', { month: 'short' })}
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.2 }}>{WEEKDAY_FULL[date.getDay()]}</Typography>
</Box>
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', py: 1, pr: 2 }}>
{events.length === 0 && (
<Typography sx={{ fontSize: '0.85rem', color: c.text.ghost }}>No events today</Typography>
)}
{events.map((e, idx) => (
<Tooltip key={`${e.workflow.id}-${idx}`} title={<EventTooltipBody event={e} />} placement="right" arrow>
<Box
onClick={() => onSelectWorkflow?.(e.workflow.id)}
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
sx={{
display: 'flex', alignItems: 'center', gap: 1.25,
py: 0.4,
fontSize: '0.88rem', color: c.text.secondary, cursor: 'pointer',
'&:hover .ev-title': { color: accent },
}}>
<Box sx={{ width: 3, alignSelf: 'stretch', minHeight: 22, bgcolor: accent, borderRadius: 1, flexShrink: 0 }} />
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography className="ev-title" sx={{ fontSize: '0.9rem', fontWeight: 500, color: c.text.primary, lineHeight: 1.3 }}>{e.workflow.title}</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.3 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
</Box>
</Box>
</Tooltip>
))}
</Box>
</Box>
))}
{ctxMenuEl}
</Box>
);
}
// Apple Calendar style event chip: 3px colored left-bar + faintly-tinted
// background + readable text. One chip per cell with a "+N" badge for
// overflow; clicking it opens a popover listing all events that hour.
function EventStack({ events, onSelectWorkflow, eventFontSize, onContextWorkflow }: {
events: { workflow: Workflow; date: Date }[];
onSelectWorkflow?: (id: string) => void;
eventFontSize: string;
onContextWorkflow?: (workflow: Workflow, e: React.MouseEvent) => void;
}) {
const c = useClaudeTokens();
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
if (events.length === 0) return null;
const first = events[0];
const rest = events.slice(1);
const accent = c.accent.primary;
// Time string is part of the chip so a glance tells you both what and
// when, matching Apple's "Title, 1pm" pattern. Chip is slim (height ~22)
// not slot-stretching, since OpenSwarm events fire at a single instant.
const timeLabel = formatTime(first.date.getHours(), first.date.getMinutes());
return (
<>
<Tooltip title={<EventTooltipBody event={first} />} placement="top" arrow>
<Box
draggable
onDragStart={(e) => {
e.dataTransfer.setData('application/x-workflow-id', first.workflow.id);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={() => onSelectWorkflow?.(first.workflow.id)}
onContextMenu={(e) => onContextWorkflow?.(first.workflow, e)}
sx={{
position: 'absolute',
left: 2, right: rest.length > 0 ? 24 : 2, top: 2,
height: 22,
bgcolor: accent + '14',
color: c.text.primary,
borderLeft: `3px solid ${accent}`,
borderRadius: '4px',
px: 0.65, py: 0,
fontSize: eventFontSize, fontWeight: 500,
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 0.5,
'&:hover': { bgcolor: accent + '22' },
}}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{first.workflow.title}</span>
<span style={{ color: 'inherit', opacity: 0.7, flexShrink: 0 }}>{timeLabel}</span>
</Box>
</Tooltip>
{rest.length > 0 && (
<Box
onClick={(e) => setAnchor(e.currentTarget)}
role="button"
sx={{
position: 'absolute',
right: 2, top: 2,
height: 22,
minWidth: 20, px: 0.4,
bgcolor: accent + '22',
color: accent,
borderRadius: '4px',
fontSize: eventFontSize, fontWeight: 700,
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
'&:hover': { bgcolor: accent + '33' },
}}>
+{rest.length}
</Box>
)}
<Popover
open={Boolean(anchor)}
anchorEl={anchor}
onClose={() => setAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}>
<Box sx={{ minWidth: 220, p: 1 }}>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.5 }}>
{events.length} runs at this hour
</Typography>
{events.map((e, idx) => (
<Box
key={`${e.workflow.id}-${idx}`}
onClick={() => { setAnchor(null); onSelectWorkflow?.(e.workflow.id); }}
sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 0.5, py: 0.5, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: c.accent.primary }} />
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, fontWeight: 600 }}>{e.workflow.title}</Typography>
<Typography sx={{ fontSize: '0.74rem', color: c.text.muted }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
</Box>
))}
</Box>
</Popover>
</>
);
}
function EventTooltipBody({ event }: { event: { workflow: Workflow; date: Date } }) {
const wf = event.workflow;
const status = wf.last_run_status;
const cost = wf.cost_estimate?.last_run_usd;
const monthly = wf.cost_estimate?.monthly_usd;
return (
<Box sx={{ fontSize: '0.72rem', lineHeight: 1.5 }}>
<div style={{ fontWeight: 700 }}>{wf.title}</div>
<div>{`Fires at ${formatTime(event.date.getHours(), event.date.getMinutes())}`}</div>
{status && <div>{`Last run: ${status}`}</div>}
{typeof cost === 'number' && cost > 0 && <div>{`Last run cost: $${cost.toFixed(4)}`}</div>}
{typeof monthly === 'number' && monthly > 0 && <div>{`Est. monthly: $${monthly.toFixed(2)}`}</div>}
</Box>
);
}
@@ -1,476 +0,0 @@
import React, { useCallback, useEffect, 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 Tooltip from '@mui/material/Tooltip';
import RepeatIcon from '@mui/icons-material/RepeatRounded';
import HourglassEmptyIcon from '@mui/icons-material/HourglassEmptyRounded';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import BedtimeIcon from '@mui/icons-material/BedtimeOutlined';
import NotificationsIcon from '@mui/icons-material/NotificationsNoneRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchCloudSmsStatus, type Workflow, type ScheduleConfig, type PermissionTier } from '@/shared/state/workflowsSlice';
import { WEEKDAY_LABEL, formatTime } from './scheduleUtils';
import { nextTierAfter } from './permissionsUtils';
import { BODY_FS, LABEL_FS, HINT_FS, INPUT_FS } from './workflowEditCommon';
function jsWeekday(d: Date): number { return d.getDay(); }
// Turn an IANA zone string into something a non-dev can parse. "local"
// (legacy) or the host's own zone collapse to "your time"; otherwise
// show "Pacific Time" / "Eastern Time" / etc. when we can resolve a
// short name via Intl, falling back to the raw IANA name if not.
function friendlyTzLabel(tz: string): string {
if (!tz || tz === 'local') return 'your time';
try {
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (tz === host) {
const parts = new Intl.DateTimeFormat('en', { timeZone: tz, timeZoneName: 'long' }).formatToParts(new Date());
const name = parts.find((p) => p.type === 'timeZoneName')?.value || '';
return name ? `your time (${name.replace(' Standard Time', '').replace(' Daylight Time', '')})` : 'your time';
}
const parts = new Intl.DateTimeFormat('en', { timeZone: tz, timeZoneName: 'long' }).formatToParts(new Date());
const name = parts.find((p) => p.type === 'timeZoneName')?.value || '';
return name || tz;
} catch {
return tz;
}
}
function lastDayOfMonthFE(year: number, monthZeroBased: number): number {
return new Date(year, monthZeroBased + 1, 0).getDate();
}
// Compute the next fire time from a ScheduleConfig. Mirrors the backend
// math in scheduler.py:_next_fire_after using browser-local time so the
// preview lines up with what the user will actually see on their system
// clock. Honors ends_at + max_runs so the "Next run" line doesn't lie
// after the schedule has expired.
function previewNextRun(sched: ScheduleConfig): Date | null {
if (!sched.enabled) return null;
const now = new Date();
if (sched.ends_at) {
const ends = new Date(sched.ends_at);
if (!Number.isNaN(ends.getTime()) && ends.getTime() <= now.getTime()) return null;
}
if (sched.max_runs != null && sched.runs_count >= sched.max_runs) return null;
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);
const startDay = now.getDate();
let year = now.getFullYear();
let month = now.getMonth();
let guard = 0;
while (guard < 60) {
const day = Math.min(startDay, lastDayOfMonthFE(year, month));
const c = new Date(year, month, day, sched.hour, sched.minute, 0, 0);
if (c > now) return c;
month += step;
year += Math.floor(month / 12);
month = ((month % 12) + 12) % 12;
guard += 1;
}
return null;
}
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())}`;
}
type EndKind = 'forever' | 'on_date' | 'after_n';
function endKindFromSched(s: ScheduleConfig): EndKind {
if (s.ends_at) return 'on_date';
if (s.max_runs != null) return 'after_n';
return 'forever';
}
interface AppOpenInfo {
alwaysOn: boolean; // tray + login both configured
loginAtLaunch: boolean;
trayEnabled: boolean;
}
function useAppOpenInfo(): { info: AppOpenInfo; fix: () => Promise<void> } {
const [info, setInfo] = useState<AppOpenInfo>({ alwaysOn: false, loginAtLaunch: false, trayEnabled: false });
useEffect(() => {
let alive = true;
const w: any = (window as any).openswarm;
if (!w?.getAppOpenInfo) return;
w.getAppOpenInfo().then((res: AppOpenInfo) => { if (alive) setInfo(res); }).catch(() => {});
return () => { alive = false; };
}, []);
const fix = useCallback(async () => {
const w: any = (window as any).openswarm;
if (!w?.setLoginItem || !w?.enableTray) return;
await w.setLoginItem(true);
await w.enableTray(true);
if (w.getAppOpenInfo) {
const next = await w.getAppOpenInfo();
setInfo(next);
}
}, []);
return { info, fix };
}
export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const s = draft.schedule;
const cloudSms = useAppSelector((st) => (st as any).workflows?.cloudSmsEnabled);
useEffect(() => { dispatch(fetchCloudSmsStatus()); }, [dispatch]);
// No silent enable-on-edit. The master Switch is now the single source
// of truth for whether this schedule is armed.
const setSched = useCallback((patch: Partial<ScheduleConfig>) => {
setDraft({ ...draft, schedule: { ...s, ...patch } });
}, [draft, s, setDraft]);
const addBackup = useCallback(() => {
const tiers = [...(draft.permissions || [])];
const next = nextTierAfter(tiers);
if (!next) return;
tiers.push(next);
setDraft({ ...draft, permissions: tiers });
}, [draft, setDraft]);
const removeTier = useCallback((idx: number) => {
// Drop the removed tier AND all following tiers 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 setTier = useCallback((idx: number, patch: Partial<PermissionTier>) => {
const tiers = [...(draft.permissions || [])];
tiers[idx] = { ...tiers[idx], ...patch };
setDraft({ ...draft, permissions: tiers });
}, [draft, setDraft]);
const canAddBackup = ((draft.permissions || [])[ (draft.permissions || []).length - 1 ]?.kind || 'notify') !== 'call';
const endKind = endKindFromSched(s);
const nextPreview = useMemo(() => previewNextRun(s), [s]);
const { info: appOpen, fix: fixAppOpen } = useAppOpenInfo();
const setEndKind = (k: EndKind) => {
if (k === 'forever') setSched({ ends_at: null, max_runs: null });
else if (k === 'on_date') setSched({ ends_at: new Date(Date.now() + 7 * 86400000).toISOString(), max_runs: null });
else setSched({ ends_at: null, max_runs: 10 });
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Master on/off. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Switch size="small" checked={s.enabled} onChange={(e) => setSched({ enabled: e.target.checked })} />
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary }}>
{s.enabled ? 'Schedule is on' : 'Schedule is off'}
</Typography>
</Box>
{s.enabled && (
<AppOpenStatusBadge info={appOpen} hour={s.hour} minute={s.minute} onFix={fixAppOpen} />
)}
{/* Section: When should this workflow run? */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={{ fontSize: BODY_FS, fontWeight: 600, color: c.text.primary }}>
When should this workflow run?
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>Repeat every</Typography>
<InputBase
type="number"
value={s.repeat_every}
onChange={(e) => setSched({ repeat_every: Math.max(1, Number(e.target.value) || 1) })}
sx={{ width: 56, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }}
/>
<Select
size="small"
value={s.repeat_unit}
onChange={(e) => setSched({ repeat_unit: e.target.value as ScheduleConfig['repeat_unit'] })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="day">day</MenuItem>
<MenuItem value="week">week</MenuItem>
<MenuItem value="month">month</MenuItem>
</Select>
</Box>
{s.repeat_unit === 'week' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pl: 12, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.muted }}> on</Typography>
{WEEKDAY_LABEL.map((label, idx) => {
const active = s.on_days.includes(idx);
return (
<Box
key={idx}
onClick={() => setSched({ on_days: active ? s.on_days.filter((d) => d !== idx) : [...s.on_days, idx] })}
role="button"
sx={{ width: 28, height: 28, 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}</Box>
);
})}
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>At</Typography>
<Select
size="small"
value={((s.hour + 11) % 12) + 1}
onChange={(e) => {
const h12 = Number(e.target.value);
const isPm = s.hour >= 12;
const next = (h12 % 12) + (isPm ? 12 : 0);
setSched({ hour: next });
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
{Array.from({ length: 12 }, (_, i) => i + 1).map((h) => (
<MenuItem key={h} value={h}>{h}</MenuItem>
))}
</Select>
<Typography sx={{ fontSize: INPUT_FS, color: c.text.muted }}>:</Typography>
<Select
size="small"
value={s.minute}
onChange={(e) => setSched({ minute: Number(e.target.value) })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
{[0, 15, 30, 45].map((m) => (
<MenuItem key={m} value={m}>{String(m).padStart(2, '0')}</MenuItem>
))}
</Select>
<Select
size="small"
value={s.hour < 12 ? 'AM' : 'PM'}
onChange={(e) => {
const wasPm = s.hour >= 12;
const willBePm = e.target.value === 'PM';
if (wasPm === willBePm) return;
setSched({ hour: willBePm ? s.hour + 12 : s.hour - 12 });
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="AM">AM</MenuItem>
<MenuItem value="PM">PM</MenuItem>
</Select>
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost, ml: 0.5 }}>{friendlyTzLabel(s.timezone)}</Typography>
</Box>
{nextPreview && s.enabled && (
<Typography sx={{ fontSize: HINT_FS, color: c.accent.primary, pl: 12, fontWeight: 500 }}>
Next run: {formatNextRun(nextPreview)}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mt: 0.5 }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>Runs</Typography>
<Select
size="small"
value={endKind}
onChange={(e) => setEndKind(e.target.value as EndKind)}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="forever">Until I turn it off</MenuItem>
<MenuItem value="on_date">Until a date</MenuItem>
<MenuItem value="after_n">After a number of runs</MenuItem>
</Select>
{endKind === 'on_date' && (
<InputBase
type="date"
value={s.ends_at ? s.ends_at.slice(0, 10) : ''}
onChange={(e) => {
const v = e.target.value;
setSched({ ends_at: v ? new Date(v + 'T23:59:59').toISOString() : null });
}}
sx={{ fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }}
/>
)}
{endKind === 'after_n' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<InputBase
type="number"
value={s.max_runs ?? 10}
onChange={(e) => setSched({ max_runs: Math.max(1, Number(e.target.value) || 1) })}
sx={{ width: 56, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }}
/>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>runs ({s.runs_count} so far)</Typography>
</Box>
)}
</Box>
{(() => {
if (endKind === 'on_date' && s.ends_at) {
const ends = new Date(s.ends_at).getTime();
if (!Number.isNaN(ends) && ends <= Date.now()) {
return (
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, pl: 12 }}>
This date is in the past. The schedule will turn itself off.
</Typography>
);
}
}
if (endKind === 'after_n' && s.max_runs != null && s.runs_count >= s.max_runs) {
return (
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, pl: 12 }}>
This workflow has already run {s.runs_count}× (limit {s.max_runs}). Raise the number or reset the counter to re-arm.
</Typography>
);
}
return null;
})()}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>If missed</Typography>
<Select
size="small"
value={s.on_missed === 'run_all' ? 'run_once' : s.on_missed}
onChange={(e) => setSched({ on_missed: e.target.value as ScheduleConfig['on_missed'] })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="skip">Skip the missed run</MenuItem>
<MenuItem value="run_once">Run once after I wake the app</MenuItem>
</Select>
</Box>
</Box>
{/* Section: What can the agent do? */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={{ fontSize: BODY_FS, fontWeight: 600, color: c.text.primary }}>
What can the agent do?
</Typography>
<Select
size="small"
value={draft.actions.freeze ? 'scoped' : 'full'}
onChange={(e) => {
const scoped = e.target.value === 'scoped';
if (!scoped) {
const ok = window.confirm('Full access lets this scheduled run do anything an agent normally can: run commands, edit files, browse the web, send messages. Continue?');
if (!ok) return;
}
setDraft({ ...draft, actions: { ...draft.actions, freeze: scoped } });
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="scoped">Only what the original chat used (recommended)</MenuItem>
<MenuItem value="full">Anything an agent can do (run commands, edit files, browse)</MenuItem>
</Select>
</Box>
{/* Section: How should the agent ask for your permission? */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={{ fontSize: BODY_FS, fontWeight: 600, color: c.text.primary }}>
How should the agent ask for your permission?
</Typography>
{(draft.permissions || []).map((tier, idx) => (
<PermissionRow
key={idx}
idx={idx}
tier={tier}
cloudSmsEnabled={Boolean(cloudSms)}
onChange={(patch) => setTier(idx, patch)}
onRemove={idx === 0 ? undefined : () => removeTier(idx)}
/>
))}
{canAddBackup && (
<Box onClick={addBackup} role="button" sx={{ fontSize: LABEL_FS, color: c.text.muted, cursor: 'pointer', mt: 0.5, fontWeight: 500, '&:hover': { color: c.accent.primary } }}>+ Escalate if I don&apos;t respond</Box>
)}
</Box>
</Box>
);
}
function AppOpenStatusBadge({ info, hour, minute, onFix }: { info: AppOpenInfo; hour: number; minute: number; onFix: () => void }) {
const c = useClaudeTokens();
const good = info.alwaysOn;
const fmt = formatTime(hour, minute);
return (
<Box sx={{
display: 'flex', alignItems: 'center', gap: 1, pl: 0.25,
bgcolor: good ? c.status.successBg : (c.status.warningBg || c.bg.elevated),
border: `1px solid ${good ? c.status.success + '60' : (c.status.warning || c.text.muted) + '60'}`,
borderRadius: `${c.radius.md}px`, px: 1, py: 0.5,
}}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: good ? c.status.success : (c.status.warning || c.text.muted) }} />
<Typography sx={{ flex: 1, fontSize: HINT_FS, color: c.text.primary }}>
{good ? 'Will run even if you close OpenSwarm.' : `OpenSwarm must be open at ${fmt} for this to run.`}
</Typography>
{!good && (
<Tooltip title="One click: start OpenSwarm automatically when you log in, and keep a small icon in your menubar so it stays running when you close the window. You can undo both later in Settings.">
<Box onClick={onFix} role="button" sx={{ fontSize: HINT_FS, color: c.accent.primary, cursor: 'pointer', fontWeight: 700, whiteSpace: 'nowrap' }}>Always-on</Box>
</Tooltip>
)}
</Box>
);
}
function PermissionRow({ idx, tier, cloudSmsEnabled, onChange, onRemove }: {
idx: number;
tier: PermissionTier;
cloudSmsEnabled: boolean;
onChange: (p: Partial<PermissionTier>) => void;
onRemove?: () => void;
}) {
const c = useClaudeTokens();
if (idx === 0) {
return (
<Select
size="small"
value="notify"
sx={{ alignSelf: 'flex-start', fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="notify">Notify me in Open Swarm</MenuItem>
</Select>
);
}
const unitLabel = tier.kind === 'call' ? 'hour' : 'minutes';
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, pl: 2, position: 'relative' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>after</Typography>
<InputBase
type="number"
value={tier.after_minutes}
onChange={(e) => 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 }}
/>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>{unitLabel}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Select
size="small"
value={tier.kind}
onChange={(e) => onChange({ kind: e.target.value as PermissionTier['kind'] })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
{tier.kind !== 'call' && <MenuItem value="text">Text me</MenuItem>}
{tier.kind === 'call' && <MenuItem value="call">Call me</MenuItem>}
</Select>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>at</Typography>
<InputBase
value={tier.phone || ''}
placeholder="+1 (000) 123 4567"
onChange={(e) => 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 && (
<Box onClick={onRemove} role="button" sx={{ fontSize: HINT_FS, color: c.text.ghost, cursor: 'pointer', px: 0.5, '&:hover': { color: c.status.error } }}>×</Box>
)}
</Box>
{!cloudSmsEnabled && (
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, fontStyle: 'italic' }}>
Coming soon. Until cloud SMS ships, this tier falls back to an in-app notify with a "fallback" badge.
</Typography>
)}
</Box>
);
}
@@ -1,241 +0,0 @@
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 IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import BookmarkIcon from '@mui/icons-material/BookmarkBorderRounded';
import SearchIcon from '@mui/icons-material/Search';
import CalendarMonthIcon from '@mui/icons-material/CalendarMonthRounded';
import OpenInFullIcon from '@mui/icons-material/OpenInFullRounded';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
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';
import { addDays, startOfWeek } from './scheduleUtils';
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<HTMLDivElement>;
onHistoryScroll?: () => void;
/** When true, hides the internal Search/Schedule chips + redundant "+ New"
* pill. The new DashboardToolbar pills above the popover replace them. */
hideTopChrome?: boolean;
}
export default function SchedulePopover({
mode, onModeChange, historyResults, historyLoading, historyQuery, onHistoryQueryChange,
onHistorySelect, onNewChat, onWorkflowSelect, onExpand, historyScrollRef, onHistoryScroll,
hideTopChrome = false,
}: Props) {
const c = useClaudeTokens();
const [calendarView, setCalendarView] = useState<'Week' | 'Month' | 'List'>('Week');
const [refDate, setRefDate] = useState<Date>(() => new Date());
const workflows = useAppSelector((s) => s.workflows.items);
const periodLabel = useMemo(() => {
if (calendarView === 'Month') {
return refDate.toLocaleString('en', { month: 'long', year: 'numeric' });
}
if (calendarView === 'Week') {
const start = startOfWeek(refDate);
const end = addDays(start, 6);
const sameMonth = start.getMonth() === end.getMonth();
const startStr = start.toLocaleString('en', { month: 'short', day: 'numeric' });
const endStr = sameMonth
? String(end.getDate())
: end.toLocaleString('en', { month: 'short', day: 'numeric' });
return `${startStr} ${endStr}, ${end.getFullYear()}`;
}
return refDate.toLocaleString('en', { month: 'long', day: 'numeric', year: 'numeric' });
}, [refDate, calendarView]);
const onPrev = useCallback(() => {
setRefDate((d) => addDays(d, calendarView === 'Month' ? -28 : calendarView === 'Week' ? -7 : -1));
}, [calendarView]);
const onNext = useCallback(() => {
setRefDate((d) => addDays(d, calendarView === 'Month' ? 28 : calendarView === 'Week' ? 7 : 1));
}, [calendarView]);
const workflowIconMap = useMemo(() => {
const m: Record<string, string> = {};
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', width: POPOVER_W, maxWidth: POPOVER_W, gap: 0.75, flexShrink: 0 }}>
{/* Floating mode chips. Hidden when the parent toolbar supplies its
own pill row (Image #32 / #54); kept around so the legacy callers
that surface Schedule mode still have a way in. */}
{!hideTopChrome && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 0.5 }}>
<ModeChip label="Search" icon={<SearchIcon sx={{ fontSize: 14 }} />} active={mode === 'search'} onClick={() => onModeChange('search')} />
<ModeChip label="Schedule" icon={<CalendarMonthIcon sx={{ fontSize: 14 }} />} active={mode === 'schedule'} onClick={() => onModeChange('schedule')} />
</Box>
)}
{/* Content card separately bordered/rounded, like image #30.
Inner content crossfades on tab switch so searchschedule isn't
a jarring jump. Outer card stays fixed-size (W×H) so the toolbar
doesn't reflow. */}
<Box sx={{
width: '100%',
height: CONTENT_H,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: `${c.radius.lg}px`,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
position: 'relative',
}}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={mode}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.12, ease: 'easeOut' }}
style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column' }}>
{mode === 'search' && (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, flexShrink: 0 }}>
<SearchIcon sx={{ fontSize: 18, color: c.text.muted }} />
<InputBase
value={historyQuery}
onChange={(e) => 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 } }}
/>
{!hideTopChrome && (
<Box onClick={onNewChat} role="button" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: '0.78rem', fontWeight: 500, color: c.text.secondary, px: 1, py: 0.45, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated } }}>
<AddIcon sx={{ fontSize: 12 }} />
New
</Box>
)}
</Box>
<Box ref={historyScrollRef} onScroll={onHistoryScroll} sx={{ flex: 1, overflowY: 'auto', borderTop: `1px solid ${c.border.subtle}` }}>
{historyResults.length === 0 && !historyLoading && (
<Typography sx={{ px: 1.5, py: 2.5, fontSize: '0.82rem', color: c.text.muted, textAlign: 'center' }}>{historyQuery ? 'No matching chats' : 'No chat history yet'}</Typography>
)}
{historyResults.map((entry) => {
const hasWorkflow = Boolean(workflowIconMap[entry.id]);
return (
<Box key={entry.id} onClick={() => onHistorySelect(entry.id)} sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.9, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}>
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{entry.name}</Typography>
{/* Only annotate chats that became saved workflows.
A small workflow glyph reads as a tag, where the
old single-letter chip read as a random initial. */}
{hasWorkflow && (
<Tooltip title="This chat is saved as a workflow">
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 18, height: 18, borderRadius: '4px', color: c.text.muted }}>
<BookmarkIcon sx={{ fontSize: 13 }} />
</Box>
</Tooltip>
)}
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, flexShrink: 0, whiteSpace: 'nowrap' }}>{relTime(entry.closed_at)}</Typography>
</Box>
);
})}
</Box>
</Box>
)}
{mode === 'schedule' && (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, pt: 1, pb: 0.5, flexShrink: 0 }}>
{(['Week', 'Month', 'List'] as const).map((v) => (
<Box key={v} onClick={() => 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}</Box>
))}
<Box sx={{ flex: 1 }} />
<Box onClick={onExpand} role="button" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: '0.78rem', fontWeight: 500, color: c.text.secondary, px: 1, py: 0.35, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated } }}>
<OpenInFullIcon sx={{ fontSize: 12 }} />
Expand
</Box>
</Box>
{/* Period nav: Today pill, prev/next chevrons, range label.
Apple Calendar pattern. Keeps the popover usable without
forcing a full Expand for date browsing. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 1.5, pb: 0.75, flexShrink: 0 }}>
<Box
onClick={() => setRefDate(new Date())}
role="button"
sx={{
fontSize: '0.78rem', fontWeight: 600, color: c.text.secondary,
border: `1px solid ${c.border.subtle}`, px: 0.95, py: 0.3,
borderRadius: `${c.radius.md}px`, cursor: 'pointer',
'&:hover': { color: c.text.primary, borderColor: c.border.medium },
}}>Today</Box>
<IconButton size="small" onClick={onPrev} sx={{ p: 0.3, color: c.text.muted, '&:hover': { color: c.text.primary } }}><ChevronLeftIcon sx={{ fontSize: 17 }} /></IconButton>
<IconButton size="small" onClick={onNext} sx={{ p: 0.3, color: c.text.muted, '&:hover': { color: c.text.primary } }}><ChevronRightIcon sx={{ fontSize: 17 }} /></IconButton>
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.primary, ml: 0.25 }}>{periodLabel}</Typography>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', px: 1.5, py: 1, borderTop: `1px solid ${c.border.subtle}`, minHeight: 0 }}>
<ScheduleCalendar view={calendarView} density="roomy" onSelectWorkflow={onWorkflowSelect} refDate={refDate} />
</Box>
</Box>
)}
</motion.div>
</AnimatePresence>
</Box>
</Box>
);
}
// 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 (
<Box
onClick={onClick}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
fontSize: '0.82rem', fontWeight: active ? 700 : 500,
px: 1.1, py: 0.45,
cursor: 'pointer',
color: active ? c.text.primary : c.text.muted,
bgcolor: active ? c.bg.surface : 'transparent',
border: `1px solid ${active ? c.border.subtle : 'transparent'}`,
borderRadius: `${c.radius.md}px`,
boxShadow: active ? c.shadow.sm : 'none',
'&:hover': { color: c.text.primary, bgcolor: active ? c.bg.surface : c.bg.elevated },
}}>
{icon}
{label}
</Box>
);
}
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`;
}
@@ -1,240 +0,0 @@
// Minimum-steps-to-value entry point: from any open chat, hit "Schedule"
// in the header, pick one of four presets, and we materialize a workflow
// seeded with source_session_id (so it inherits the chat's tool surface
// + steps via the existing /workflows/create path). "Custom..." opens a
// LOCAL draft card instead of immediately POSTing /workflows/create, so
// users who change their mind don't leave behind an orphan workflow.
import React, { useCallback, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Popover from '@mui/material/Popover';
import InputBase from '@mui/material/InputBase';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { createWorkflow, openWorkflowCard, type ScheduleConfig, type Workflow } from '@/shared/state/workflowsSlice';
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
import { defaultSchedule } from './scheduleUtils';
type Preset = {
label: string;
hint: string;
build: () => Partial<ScheduleConfig>;
};
const PRESETS: Preset[] = [
{ label: 'Every day at 9am', hint: 'Daily standup, morning report', build: () => ({ enabled: true, repeat_unit: 'day', repeat_every: 1, hour: 9, minute: 0 }) },
{ label: 'Weekdays at 9am', hint: 'Mon to Fri', build: () => ({ enabled: true, repeat_unit: 'week', repeat_every: 1, on_days: [1, 2, 3, 4, 5], hour: 9, minute: 0 }) },
{ label: 'Every Monday at 9am', hint: 'Weekly check-in', build: () => ({ enabled: true, repeat_unit: 'week', repeat_every: 1, on_days: [1], hour: 9, minute: 0 }) },
{ label: 'Every month on the 1st', hint: 'Monthly summary, billing report', build: () => ({ enabled: true, repeat_unit: 'month', repeat_every: 1, hour: 9, minute: 0 }) },
];
interface Props {
anchorEl: HTMLElement | null;
onClose: () => void;
sessionId: string;
sessionName: string;
// Hook so the caller can show "Workflow created" feedback inline.
onCreated?: (workflowId: string) => void;
// Auto-suggest path: when the caller detected time-words and wants to
// pre-fill the popover with that exact schedule, the first preset
// shown becomes "Use suggestion: <label>" and is set as the default.
prefillSchedule?: ScheduleConfig | null;
prefillLabel?: string | null;
}
export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sessionName, onCreated, prefillSchedule, prefillLabel }: Props) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const location = useLocation();
const [title, setTitle] = useState<string>(sessionName || 'Untitled');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const workflows = useAppSelector((s) => s.workflows.items);
// Workflow cards only render inside the Dashboard canvas. When this
// popover is opened from somewhere else (Apps editor, etc.), Custom...
// would silently drop the user on a non-canvas page with no visible
// editor — see this session's chat history. Look up the session's
// dashboard so we can navigate there before opening the draft.
const sessionDashboardId = useAppSelector(
(s) => sessionId ? s.agents.sessions[sessionId]?.dashboard_id : null,
);
// Dup-detect: a chat session can only sanely have one schedule attached.
// If we find one already, offer "Open existing" instead of silently
// creating a duplicate that fires twice.
const existing = useMemo<Workflow | null>(() => {
if (!sessionId) return null;
for (const w of Object.values(workflows)) {
if (w.source_session_id === sessionId) return w;
}
return null;
}, [workflows, sessionId]);
const submit = useCallback(async (preset: Preset) => {
if (busy) return;
setBusy(true);
setError(null);
try {
const schedule: ScheduleConfig = { ...defaultSchedule(), ...preset.build() };
const result = await dispatch(createWorkflow({
title,
source_session_id: sessionId,
schedule,
} as Partial<Workflow>));
if (createWorkflow.fulfilled.match(result)) {
const wf = result.payload as Workflow;
dispatch(addWorkflowCard({ workflowId: wf.id, sourceSessionId: sessionId }));
dispatch(openWorkflowCard({ workflowId: wf.id, view: 'saved' }));
onCreated?.(wf.id);
onClose();
} else {
setError('Create failed. Try again.');
}
} catch (e) {
setError((e as Error)?.message || 'Create failed.');
} finally {
setBusy(false);
}
}, [busy, dispatch, sessionId, title, onClose, onCreated]);
const openCustom = useCallback(() => {
// Open a local draft. NO backend create yet — the workflow only
// exists on disk once the user clicks Save in the editor. Closing
// the draft card from here leaves nothing behind (the "orphan"
// bug from the previous create-then-edit flow).
const tempId = `draft-${sessionId}-${Date.now()}`;
dispatch(addWorkflowCard({ workflowId: tempId, sourceSessionId: sessionId }));
dispatch(openWorkflowCard({
workflowId: tempId,
sourceSessionId: sessionId,
view: 'preview',
draft: {
title,
description: 'Scheduled from chat. Edit anytime.',
steps: [{ id: 'step-1', text: '' }],
schedule: { ...defaultSchedule() },
} as Partial<Workflow>,
}));
// If the user opened this popover from somewhere other than the
// dashboard canvas (e.g. the Apps editor), the draft card we just
// created is invisible because <WorkflowCard /> is only rendered on
// /dashboard/<id>. Navigate there so the user lands on the editable
// card. No-op when already on a dashboard route.
if (sessionDashboardId && !location.pathname.startsWith('/dashboard/')) {
navigate(`/dashboard/${sessionDashboardId}`);
}
onClose();
}, [dispatch, sessionId, title, onClose, sessionDashboardId, navigate, location.pathname]);
const openExisting = useCallback(() => {
if (!existing) return;
dispatch(addWorkflowCard({ workflowId: existing.id, sourceSessionId: sessionId }));
dispatch(openWorkflowCard({ workflowId: existing.id, view: 'saved' }));
onClose();
}, [dispatch, existing, sessionId, onClose]);
return (
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={onClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
slotProps={{ paper: { sx: { width: 320, p: 1.25 } } }}
>
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.75 }}>
SCHEDULE THIS CHAT
</Typography>
{existing && (
<Box sx={{
display: 'flex', flexDirection: 'column', gap: 0.4,
px: 1, py: 0.75, mb: 0.75,
borderRadius: `${c.radius.md}px`,
bgcolor: c.status.warningBg || c.bg.elevated,
border: `1px solid ${(c.status.warning || c.text.muted) + '60'}`,
}}>
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.text.primary }}>
This chat is already scheduled.
</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>
&quot;{existing.title}&quot; was made from this conversation. Adding another would fire twice.
</Typography>
<Box sx={{ display: 'flex', gap: 0.5, mt: 0.5 }}>
<Box onClick={openExisting} role="button" sx={{
fontSize: '0.74rem', fontWeight: 600, color: c.accent.primary,
cursor: 'pointer', px: 0.75, py: 0.3, borderRadius: `${c.radius.md}px`,
bgcolor: c.accent.primary + '14', border: `1px solid ${c.accent.primary}40`,
'&:hover': { bgcolor: c.accent.primary + '22' },
}}>Open existing </Box>
</Box>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary }}>Name:</Typography>
<InputBase
value={title}
onChange={(e) => setTitle(e.target.value)}
sx={{ flex: 1, fontSize: '0.85rem', color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.3 }}
/>
</Box>
{prefillSchedule && prefillLabel && (
<Box
role="button"
onClick={() => submit({
label: prefillLabel,
hint: 'Detected from your conversation',
build: () => prefillSchedule as Partial<ScheduleConfig>,
})}
sx={{
display: 'flex', flexDirection: 'column', alignItems: 'flex-start',
px: 1, py: 0.7, borderRadius: `${c.radius.md}px`,
mb: 0.5,
border: `1px solid ${c.accent.primary}55`,
bgcolor: c.accent.primary + '14',
cursor: busy ? 'wait' : 'pointer',
opacity: busy ? 0.5 : 1,
'&:hover': { bgcolor: c.accent.primary + '22' },
}}>
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.accent.primary, letterSpacing: '0.04em' }}>SUGGESTED</Typography>
<Typography sx={{ fontSize: '0.86rem', fontWeight: 600, color: c.text.primary }}>{prefillLabel}</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>Detected from your last reply</Typography>
</Box>
)}
{PRESETS.map((p) => (
<Box
key={p.label}
role="button"
onClick={() => submit(p)}
sx={{
display: 'flex', flexDirection: 'column', alignItems: 'flex-start',
px: 1, py: 0.6, borderRadius: `${c.radius.md}px`,
cursor: busy ? 'wait' : 'pointer',
opacity: busy ? 0.5 : 1,
'&:hover': { bgcolor: c.bg.elevated },
}}>
<Typography sx={{ fontSize: '0.86rem', fontWeight: 600, color: c.text.primary }}>{p.label}</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>{p.hint}</Typography>
</Box>
))}
<Box
role="button"
onClick={openCustom}
sx={{
mt: 0.5, borderTop: `1px solid ${c.border.subtle}`,
px: 1, py: 0.7, borderRadius: `${c.radius.md}px`,
cursor: busy ? 'wait' : 'pointer',
opacity: busy ? 0.5 : 1,
'&:hover': { bgcolor: c.bg.elevated },
}}>
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.accent.primary }}>Custom</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>Open the editor without saving yet</Typography>
</Box>
{error && (
<Typography sx={{ mt: 0.5, fontSize: '0.74rem', color: c.status.error }}>{error}</Typography>
)}
</Popover>
);
}
@@ -1,230 +0,0 @@
// Image #49: natural-language schedule composer.
// Header morphs into `[trash] Cancel task scheduling`. Body shows a soft
// frame around the read-only step list, an agent reply bubble asking for
// the cadence, and a chat-style composer at the bottom. On submit we
// hit /workflows/{id}/parse-schedule (aux LLM), surface the parsed
// ScheduleConfig in a confirmation modal (the "always ask permission"
// stand-in for the schedule_workflow tool call), and PATCH on confirm.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Dialog from '@mui/material/Dialog';
import DeleteOutlineRounded from '@mui/icons-material/DeleteOutlineRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch } from '@/shared/hooks';
import { updateWorkflow, updateWorkflowCard, type ScheduleConfig, type Workflow } from '@/shared/state/workflowsSlice';
import StepList from './StepList';
import { API_BASE, getAuthToken } from '@/shared/config';
import { useAppSelector as _useAppSelector } from '@/shared/hooks';
import ChatInput from '@/app/pages/AgentChat/ChatInput';
interface Props {
workflow: Workflow;
steps: Workflow['steps'];
}
function InlineSubtitle({ workflow }: { workflow: Workflow }) {
const c = useClaudeTokens();
const modelsByProvider = _useAppSelector((s) => s.models.byProvider);
const runs = _useAppSelector((s) => s.workflows.runs[workflow.id]);
const modelLabel = React.useMemo(() => {
if (!workflow?.model) return '';
for (const list of Object.values(modelsByProvider || {})) {
for (const m of (list as Array<{ value: string; label?: string }>) || []) {
if (m.value === workflow.model) return m.label || workflow.model;
}
}
return workflow.model;
}, [workflow?.model, modelsByProvider]);
const duration = React.useMemo(() => {
if (!runs || runs.length === 0) return '';
const last = runs.find((r) => r.finished_at);
if (!last || !last.finished_at) return '';
const ms = new Date(last.finished_at).getTime() - new Date(last.started_at).getTime();
if (ms <= 0) return '';
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
return `${Math.floor(ms / 60_000)}m`;
}, [runs]);
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1.25, fontSize: '0.82rem', color: c.text.muted, minWidth: 0, overflow: 'hidden' }}>
{modelLabel && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{modelLabel}</Box>}
{workflow.mode && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{workflow.mode}</Box>}
{duration && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{duration}</Box>}
</Box>
);
}
export default function SchedulingView({ workflow, steps }: Props) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState<ScheduleConfig | null>(null);
// The composer behaves like any normal chat: it defaults to the user's
// configured default model/mode (e.g. their subscription model), not the
// workflow's stored run model, and its pickers actually work.
const defaultModel = _useAppSelector((s) => s.settings.data.default_model);
const defaultMode = _useAppSelector((s) => s.settings.data.default_mode);
const settingsLoaded = _useAppSelector((s) => s.settings.loaded);
const [chatModel, setChatModel] = useState(defaultModel || 'sonnet');
const [chatMode, setChatMode] = useState(defaultMode || 'agent');
const settingsApplied = useRef(false);
useEffect(() => {
if (settingsLoaded && !settingsApplied.current) {
setChatModel(defaultModel || 'sonnet');
setChatMode(defaultMode || 'agent');
settingsApplied.current = true;
}
}, [settingsLoaded, defaultModel, defaultMode]);
const onCancel = useCallback(() => {
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } }));
}, [dispatch, workflow.id]);
const onSubmit = useCallback(async (text: string) => {
const cleaned = (text || '').trim();
if (!cleaned || busy) return undefined;
setBusy(true);
setError(null);
try {
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflow.id)}/parse-schedule`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) },
body: JSON.stringify({ text: cleaned }),
});
if (!res.ok) {
setError(`Couldn't parse that. Try "every Wednesday at 1pm" or "Mondays at 3pm".`);
return undefined;
}
const data = await res.json();
const cfg = data?.schedule as ScheduleConfig | undefined;
if (!cfg) {
setError(`Couldn't read a schedule out of that. Try being more specific.`);
return undefined;
}
setPending(cfg);
return cfg;
} catch (e) {
setError((e as Error)?.message || 'Network error.');
return undefined;
} finally {
setBusy(false);
}
}, [busy, workflow.id]);
const onConfirm = useCallback(async () => {
if (!pending) return;
setBusy(true);
try {
await dispatch(updateWorkflow({
id: workflow.id,
patch: { schedule: { ...pending, enabled: true } as Workflow['schedule'] },
ifMatch: workflow.updated_at || null,
}));
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } }));
} finally {
setBusy(false);
setPending(null);
}
}, [pending, dispatch, workflow.id, workflow.updated_at]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
{/* Inline header replacement. Image #49: subtitle on LEFT, Cancel
on RIGHT. Cancel matches the subtitle's weight/size/color so the
row reads as peers, not a heavy CTA; it just reddens on hover. */}
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<InlineSubtitle workflow={workflow} />
<Box sx={{ flex: 1 }} />
<Box
onClick={onCancel}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
fontSize: '0.82rem', fontWeight: 500,
color: c.text.muted, cursor: 'pointer',
'&:hover': { color: c.status.error },
}}>
<DeleteOutlineRounded sx={{ fontSize: 15 }} />
Cancel task scheduling
</Box>
</Box>
<StepList steps={steps} />
<Typography sx={{ fontSize: '0.92rem', color: c.text.secondary, lineHeight: 1.45, mt: 0.5 }}>
When should this workflow run (e.g. every Wednesday at 1pm)
</Typography>
{error && (
<Typography sx={{ fontSize: '0.82rem', color: c.status.error }}>{error}</Typography>
)}
{/* Spacer pushes the composer to the bottom of the card so the view
reads like a normal chat (prompt up top, input docked below). */}
<Box sx={{ flex: 1, minHeight: 40 }} />
{/* Real ChatInput (same one the toolbar / agent chat use) so the
composer matches Image #54 / #64 exactly: live model picker,
mode picker, thinking level, paperclip + mic, the works. We
ignore everything except the message text on send and route it
through /parse-schedule. sessionId is a stable per-workflow id
so ChatInput's draft persistence survives view re-mounts. */}
<Box sx={{ mx: -0.5 }}>
<ChatInput
onSend={(msg) => { void onSubmit(msg); }}
mode={chatMode}
onModeChange={setChatMode}
model={chatModel}
onModelChange={setChatModel}
embedded
autoFocus
sessionId={`schedule-${workflow.id}`}
disabled={busy}
/>
</Box>
<Dialog open={!!pending} onClose={() => setPending(null)}>
<Box sx={{ p: 2.5, minWidth: 360, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography sx={{ fontSize: '1rem', fontWeight: 700, color: c.text.primary }}>
Schedule this workflow?
</Typography>
<Typography sx={{ fontSize: '0.9rem', color: c.text.secondary, lineHeight: 1.5 }}>
The agent wants to set <b>{workflow.title}</b> to run <b>{pending && describe(pending)}</b>. You can change or cancel this anytime.
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 0.5 }}>
<Box
onClick={() => setPending(null)}
role="button"
sx={{ fontSize: '0.86rem', color: c.text.secondary, px: 1, py: 0.6, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>
Cancel
</Box>
<Box
onClick={onConfirm}
role="button"
sx={{
fontSize: '0.86rem', fontWeight: 700,
color: '#fff', bgcolor: c.accent.primary,
px: 1.4, py: 0.55, borderRadius: 999, cursor: 'pointer',
'&:hover': { filter: 'brightness(1.05)' },
}}>
{busy ? 'Applying…' : 'Schedule it'}
</Box>
</Box>
</Box>
</Dialog>
</Box>
);
}
function describe(s: ScheduleConfig): string {
const h12 = ((s.hour + 11) % 12) + 1;
const ampm = s.hour < 12 ? 'am' : 'pm';
const time = s.minute === 0 ? `${h12}${ampm}` : `${h12}:${String(s.minute).padStart(2, '0')}${ampm}`;
if (s.repeat_unit === 'day') return s.repeat_every === 1 ? `every day at ${time}` : `every ${s.repeat_every} days at ${time}`;
if (s.repeat_unit === 'month') return s.repeat_every === 1 ? `every month at ${time}` : `every ${s.repeat_every} months at ${time}`;
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
if (s.on_days.length === 5 && [1,2,3,4,5].every((d) => s.on_days.includes(d))) return `weekdays at ${time}`;
if (s.on_days.length === 2 && [0,6].every((d) => s.on_days.includes(d))) return `weekends at ${time}`;
if (s.on_days.length === 1) return `${labels[s.on_days[0]]}s at ${time}`;
return `weekly at ${time}`;
}
@@ -1,291 +0,0 @@
// Vertical step list, the one shared building block across every
// workflow card subview. Supports three orthogonal modes that compose:
//
// editable onChangeStep is set -> each row is a TextareaAutosize
// (PreviewView only).
// expandable expandable=true -> chevron next to each title;
// click reveals the raw prompt body.
// live stepStatuses is set -> per-step circle becomes done/active/
// failed; Running view also surfaces
// activeStepSubtitle + duration.
import React from 'react';
import Box from '@mui/material/Box';
import TextareaAutosize from '@mui/material/TextareaAutosize';
import Typography from '@mui/material/Typography';
import KeyboardArrowDownRounded from '@mui/icons-material/KeyboardArrowDownRounded';
import CheckRounded from '@mui/icons-material/CheckRounded';
import CloseRounded from '@mui/icons-material/CloseRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { Workflow, WorkflowRun } from '@/shared/state/workflowsSlice';
export type StepStatus = 'pending' | 'active' | 'done' | 'failed';
interface Props {
workflow?: Workflow | null;
steps: Workflow['steps'];
runs?: WorkflowRun[];
activeRunId?: string | null;
framed?: boolean;
// Edit mode
onChangeStep?: (idx: number, text: string) => void;
// Expand mode
expandable?: boolean;
expandedIds?: string[];
onToggleExpand?: (id: string) => void;
// Live mode
stepStatuses?: StepStatus[];
activeStepSubtitle?: string | null;
activeStepDuration?: string | null;
// Cap visible rows; render "... N more" beneath when truncated.
maxVisible?: number;
}
const CIRCLE_SIZE = 24;
const CONNECTOR_X = CIRCLE_SIZE / 2;
export default function StepList(props: Props) {
const {
steps, framed, onChangeStep,
expandable, expandedIds, onToggleExpand,
stepStatuses, activeStepSubtitle, activeStepDuration,
maxVisible = 4,
} = props;
const c = useClaudeTokens();
if (!steps || steps.length === 0) return null;
const visible = steps.slice(0, maxVisible);
const hiddenCount = Math.max(0, steps.length - visible.length);
const expanded = new Set(expandedIds || []);
return (
<Box sx={{ position: 'relative', pl: 0, mt: 0.25 }}>
{visible.length > 1 && (
<Box
aria-hidden
sx={{
position: 'absolute',
left: CONNECTOR_X - 0.5,
top: CIRCLE_SIZE * 0.5,
bottom: CIRCLE_SIZE * 0.5,
// '1px' not 1: MUI sx treats width:1 as 100%, which rendered the
// connector as a full-width grey band behind the steps.
width: '1px',
bgcolor: c.border.medium,
opacity: 0.65,
}}
/>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.85 }}>
{visible.map((s, idx) => {
const status: StepStatus = stepStatuses?.[idx] ?? 'pending';
const isActive = status === 'active';
const isDone = status === 'done';
const isFailed = status === 'failed';
const isExpanded = expanded.has(s.id);
const label = (s.label || '').trim() || firstWords(s.text, 6);
const rawBody = (s.text || '').trim();
const hasExpandableBody = expandable && rawBody && rawBody !== label;
return (
<Box key={s.id} sx={{ display: 'flex', flexDirection: 'column' }}>
<Box
onClick={hasExpandableBody && !isActive ? () => onToggleExpand?.(s.id) : undefined}
sx={{
display: 'flex', alignItems: 'flex-start', gap: 1.25,
position: 'relative',
cursor: hasExpandableBody && !isActive ? 'pointer' : 'default',
borderRadius: `${c.radius.md}px`,
px: (isActive || (isExpanded && hasExpandableBody)) ? 0.5 : 0,
py: (isActive || (isExpanded && hasExpandableBody)) ? 0.5 : 0,
mx: (isActive || (isExpanded && hasExpandableBody)) ? -0.5 : 0,
bgcolor: (isActive || (isExpanded && hasExpandableBody)) ? c.bg.elevated : 'transparent',
transition: 'background 0.18s ease',
'&:hover': hasExpandableBody && !isActive ? { bgcolor: c.bg.elevated } : {},
}}>
<StepDisc
index={idx}
status={status}
framed={!!framed}
c={c}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
{onChangeStep ? (
<TextareaAutosize
value={s.text}
onChange={(e) => onChangeStep(idx, e.target.value)}
minRows={1}
style={{
width: '100%',
resize: 'none',
boxSizing: 'border-box',
fontFamily: 'inherit',
fontSize: '0.92rem',
color: c.text.primary,
border: framed ? `1px solid ${c.border.medium}` : '1px solid transparent',
borderRadius: `${c.radius.md}px`,
background: framed ? c.bg.surface : 'transparent',
padding: '6px 10px',
lineHeight: 1.45,
outline: 'none',
overflow: 'hidden',
transition: 'border-color 0.12s ease, background 0.12s ease',
}}
/>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minHeight: CIRCLE_SIZE }}>
<Typography sx={{
fontSize: '0.92rem',
fontWeight: isActive ? 600 : 500,
color: c.text.primary,
lineHeight: 1.45,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{label}
</Typography>
{isActive && activeStepDuration && (
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mr: hasExpandableBody ? 0 : 0.5, flexShrink: 0 }}>
{activeStepDuration}
</Typography>
)}
{hasExpandableBody && !isActive && (
<KeyboardArrowDownRounded sx={{
fontSize: 18,
color: c.text.muted,
transform: isExpanded ? 'rotate(180deg)' : 'none',
transition: 'transform 0.18s ease',
flexShrink: 0,
}} />
)}
</Box>
)}
{/* Active step: tool call subtitle. Sits under the title
with a small leading glyph so the user can read it as
"what the agent is doing right now". */}
{isActive && activeStepSubtitle && (
<Typography sx={{
fontSize: '0.82rem',
color: c.text.secondary,
mt: 0.4,
display: 'flex', alignItems: 'center', gap: 0.5,
}}>
<Box component="span" sx={{ display: 'inline-flex', fontSize: 13 }}>{'▢'}</Box>
{activeStepSubtitle}
</Typography>
)}
{/* Expanded body: the raw prompt that lives under the
LLM label. Soft elevated panel so it reads as a
drill-down, not a separate step. */}
{hasExpandableBody && isExpanded && (
<Box sx={{
mt: 0.6,
p: 1,
borderRadius: `${c.radius.md}px`,
bgcolor: c.bg.elevated,
border: `1px solid ${c.border.subtle}`,
}}>
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, lineHeight: 1.5, whiteSpace: 'pre-wrap' }}>
{rawBody}
</Typography>
</Box>
)}
</Box>
</Box>
{isFailed && undefined}
{isDone && undefined}
</Box>
);
})}
</Box>
{hiddenCount > 0 && (
<Typography sx={{
fontSize: '0.86rem',
color: c.text.secondary,
mt: 0.6,
ml: 0,
}}>
... {hiddenCount} more
</Typography>
)}
</Box>
);
}
function StepDisc({ index, status, framed, c }: { index: number; status: StepStatus; framed: boolean; c: ReturnType<typeof useClaudeTokens> }) {
if (status === 'done') {
return (
<Box sx={{
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
bgcolor: c.text.muted + '55',
color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, position: 'relative', zIndex: 1,
}}>
<CheckRounded sx={{ fontSize: 15 }} />
</Box>
);
}
if (status === 'failed') {
return (
<Box sx={{
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
bgcolor: c.status.error,
color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, position: 'relative', zIndex: 1,
boxShadow: `0 0 0 3px ${c.status.error}22`,
}}>
<CloseRounded sx={{ fontSize: 15 }} />
</Box>
);
}
if (status === 'active') {
return (
<Box sx={{
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
border: `2px solid ${c.accent.primary}`,
bgcolor: c.bg.surface,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, position: 'relative', zIndex: 1,
animation: 'workflow-step-spin 1.4s linear infinite',
'@keyframes workflow-step-spin': {
'0%': { boxShadow: `0 0 0 0 ${c.accent.primary}55` },
'50%': { boxShadow: `0 0 0 4px ${c.accent.primary}00` },
'100%': { boxShadow: `0 0 0 0 ${c.accent.primary}55` },
},
}}>
<Box sx={{
width: 8, height: 8, borderRadius: '50%',
border: `1.5px solid ${c.accent.primary}`,
borderTopColor: 'transparent',
animation: 'workflow-step-dot 0.9s linear infinite',
'@keyframes workflow-step-dot': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
}} />
</Box>
);
}
// pending
void framed;
void index;
return (
<Box sx={{
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
border: `1px solid ${c.border.medium}`,
bgcolor: c.bg.surface,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, position: 'relative', zIndex: 1,
}} />
);
}
function firstWords(s: string, n: number): string {
const words = (s || '').trim().split(/\s+/).filter(Boolean);
if (words.length <= n) return words.join(' ');
return words.slice(0, n).join(' ') + '...';
}
@@ -1,932 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { motion } from 'framer-motion';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Snackbar from '@mui/material/Snackbar';
import CloseIcon from '@mui/icons-material/Close';
import HistoryIcon from '@mui/icons-material/HistoryRounded';
import PlayArrowIcon from '@mui/icons-material/PlayArrowRounded';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import InputBase from '@mui/material/InputBase';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
closeWorkflowCard,
fetchRuns,
openWorkflowCard as openWorkflowCardAction,
rekeyOpenCard,
runWorkflowNow,
updateWorkflow,
updateWorkflowCard,
type Workflow,
} from '@/shared/state/workflowsSlice';
import {
DEFAULT_CARD_H,
DEFAULT_CARD_W,
placeCard,
rekeyWorkflowCard,
removeWorkflowCard,
setWorkflowCardPosition,
setWorkflowCardSize,
} from '@/shared/state/dashboardLayoutSlice';
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
import { fetchSession } from '@/shared/state/agentsSlice';
import WorkflowEditViews from './WorkflowEditViews';
import { HistoryDetail, HistoryList, PreviewView, SavedView } from './WorkflowCardSubviews';
import { CompletedView, FailedView, RunningView } from './WorkflowCardLiveViews';
import SchedulingView from './SchedulingView';
import EditAgentView from './EditAgentView';
import StopRounded from '@mui/icons-material/StopRounded';
import PauseRounded from '@mui/icons-material/PauseRounded';
import { StatusDot, RunSparkline, LastFiredHint, isStaleSinceLastRun } from './workflowVisuals';
import { store } from '@/shared/state/store';
import { getAgentWorkTime } from '@/shared/agentWorkTime';
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<ResizeDir, string> = {
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<string, any> }[] = [
{ 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<Props> = ({
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]);
const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds);
// 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);
const [runToast, setRunToast] = useState<string | null>(null);
const [editDirty, setEditDirty] = useState(false);
// First-success celebration: one tiny burst the first time the
// workflow ever reaches success. We track the celebration in localStorage
// keyed by workflow id so we don't repeat it across reloads.
const [celebrate, setCelebrate] = useState(false);
useEffect(() => {
if (!workflow || !runs || runs.length === 0) return;
const successes = runs.filter((r) => r.status === 'success');
if (successes.length !== 1) return;
const key = `openswarm:first-success:${workflow.id}`;
if (typeof localStorage !== 'undefined' && localStorage.getItem(key)) return;
setCelebrate(true);
try { localStorage.setItem(key, '1'); } catch { /* private mode etc. */ }
const t = window.setTimeout(() => setCelebrate(false), 2200);
return () => window.clearTimeout(t);
}, [workflow?.id, runs]);
// Lazy-load runs whenever a view that needs them is open. Saved view
// uses runs for the live-fill connector + step duration estimates;
// History views obviously need them too.
useEffect(() => {
if (!card) return;
const needsRuns =
card.view === 'saved' ||
card.view === 'history' ||
card.view === 'history_detail' ||
card.view === 'running' ||
card.view === 'completed' ||
card.view === 'failed';
if (needsRuns && workflow && !runs) {
dispatch(fetchRuns(workflow.id));
}
}, [card?.view, workflow?.id, runs, dispatch]);
// Layout state (workflowCards in dashboardLayoutSlice) persists across
// app restarts; workflows.openCards in workflowsSlice does NOT — it's a
// transient view-state cache. On relaunch the user sees the workflow
// card position restored AND the source-chat tether redrawn, but the
// card body itself doesn't render because openCards is empty. Auto-
// create a Saved-view openCard once we know the workflow really exists
// server-side. Without this, the user sees only the orange tether arrow
// pointing at nothing.
useEffect(() => {
if (!workflow || card) return;
dispatch(openWorkflowCardAction({
workflowId: workflow.id,
sourceSessionId: workflow.source_session_id || null,
view: 'saved',
draft: null,
}));
}, [workflow?.id, card, dispatch]);
// Keep wheel-scroll inside the card body instead of letting it bubble
// up to the dashboard pan/zoom listener. Without this, scrolling the
// schedule/history list shifts the canvas underneath the card. Mirrors
// the chat-panel wheel guard in AgentChat.tsx. Ctrl/meta + wheel is
// intentionally allowed through so canvas zoom still works when the
// cursor is over a workflow card.
const bodyScrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = bodyScrollRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) return;
const atTop = el.scrollTop <= 0;
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
const scrollingDown = e.deltaY > 0;
const scrollingUp = e.deltaY < 0;
if ((scrollingUp && atTop) || (scrollingDown && atBottom)) {
e.preventDefault();
}
e.stopPropagation();
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
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]);
// X just hides the card. Schedule keeps firing in the background; the
// user can re-open from the Workflows hub. A confirm dialog here was
// more friction than value (users clicked through it without reading).
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;
// Chat views embed a full AgentChat that needs a fixed scroll viewport;
// every other view should size to its content so nothing is cut off and
// there's no dead space below short content. While the user is actively
// resizing, honor the dragged height.
// Chat views host a composer + conversation, so they keep a bounded height
// (composer docked at the bottom, content scrolls) like a normal chat card.
// Everything else fits to its content. Scheduling is a chat too (you talk to
// it in natural language), so it belongs here, not in the fit-to-content set.
const isChatView = card?.view === 'edit_agent' || card?.view === 'fix_agent' || card?.view === 'scheduling';
const autoHeight = !isChatView && !localResize && !isResizing;
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
if (!card) return null;
const isRunning = (runs || []).some((r) => r.status === 'running') || workflow?.last_run_status === 'running';
// Hairline border for the default idle state (item #19 in target #54
// diff). Keeps the card feeling like a soft surface, not a fenced
// box. Highlighted / selected / running still bump up so feedback
// is unambiguous.
const border = isHighlighted
? `2px solid ${c.accent.primary}`
: isSelected
? '2px solid #3b82f6'
: isRunning
? `1px solid ${c.accent.primary}80`
: `1px solid ${c.border.subtle}`;
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 (
<Box
component={motion.div}
// Mount animation: a soft pop-in matching AgentCard's spawn so the
// Convert-to-workflow swap doesn't feel instant. Springy scale +
// opacity over ~220ms; no position morph because the card already
// lands at the source chat's exact coords (handled in AgentCard's
// convert handler).
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ scale: { type: 'spring', stiffness: 320, damping: 26, mass: 0.6 }, opacity: { duration: 0.18 } }}
data-select-type="workflow-card"
data-select-id={workflowId}
data-select-meta={JSON.stringify({ name: title })}
onPointerDownCapture={() => 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: autoHeight ? 'auto' : displayH,
maxHeight: autoHeight ? 'min(82vh, 760px)' : undefined,
borderRadius: '14px',
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 =====
Matches target image #54 spec: drag-grip on the far left, then a
single bold title (no pill prefix), then a quiet close X. The
run-status indicator moved to the inline "Scheduled:" prose
below so the title row stays calm. Padding bumped from 1.1 to
1.4 vertical so the title has air around it. */}
<Box
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={handleDragPointerUp}
sx={{
display: 'flex', alignItems: 'center', gap: 1,
px: 2, py: 1.4,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none', userSelect: 'none',
flexShrink: 0,
zIndex: 16,
position: 'relative',
}}
>
<DragIndicatorIcon sx={{ fontSize: 18, color: c.text.muted }} />
{isDraft ? (
// Draft state: title is inline-editable. Patches the openCard's
// draft.title so PreviewView picks it up on Save. Saved cards
// keep the read-only Typography below.
<InputBase
data-no-drag
onPointerDown={(e) => e.stopPropagation()}
value={(card?.draft?.title as string) || ''}
placeholder="New workflow"
onChange={(e) => dispatch(updateWorkflowCard({ workflowId, patch: { draft: { ...(card?.draft || {}), title: e.target.value } } }))}
sx={{
flex: 1, fontWeight: 600, fontSize: '0.95rem', color: c.text.primary,
letterSpacing: '-0.005em',
'& input::placeholder': { color: c.text.muted, opacity: 1 },
}}
/>
) : (
<>
<Typography sx={{ fontWeight: 600, fontSize: '0.95rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', letterSpacing: '-0.005em' }}>
{title}
</Typography>
<StatusPill view={card.view} workflow={workflow} runs={runs} />
<Box sx={{ flex: 1 }} />
</>
)}
{runs && runs.length > 0 && <RunSparkline runs={runs} />}
<IconButton
size="small"
data-no-drag
onClick={(e) => { e.stopPropagation(); onClose(); }}
onPointerDown={(e) => e.stopPropagation()}
sx={{ p: 0.5, color: c.text.secondary, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}
>
<CloseIcon sx={{ fontSize: 17 }} />
</IconButton>
</Box>
{/* Action bar matches new design: History + Run flush-right (Edit moved to footer).
The flex spacer is the empty left side; History is a quiet text link, Run is the
accent pill. */}
{isDraft && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 2, pb: 1.25, pt: 0, flexShrink: 0 }}>
<SubtitleRow
workflow={null}
runs={null}
fallbackModel={card?.draft?.model}
fallbackMode={card?.draft?.mode}
fallbackSourceSessionId={card?.draft?.source_session_id}
/>
<Box sx={{ flex: 1 }} />
<TabBtn label="History" icon={<HistoryIcon sx={{ fontSize: 16 }} />} active={false} onClick={() => {}} />
<TabBtn label="Run" icon={<PlayArrowIcon sx={{ fontSize: 16 }} />} active={false} accent onClick={() => {}} />
</Box>
)}
{!isDraft && workflow && !isHeaderlessView(card.view) && card.view !== 'running' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 2, pb: 1.25, pt: 0, flexShrink: 0 }}>
<SubtitleRow workflow={workflow} runs={runs} />
<Box sx={{ flex: 1 }} />
<TabBtn
label="History"
icon={<HistoryIcon sx={{ fontSize: 16 }} />}
active={card.view === 'history' || card.view === 'history_detail'}
onClick={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: (card.view === 'history' || card.view === 'history_detail') ? 'saved' : 'history' } }))}
/>
<TabBtn
label={runStarting ? 'Starting…' : 'Run'}
icon={<PlayArrowIcon sx={{ fontSize: 16 }} />}
active={false}
accent
breathe={!runStarting && isStaleSinceLastRun(workflow)}
breatheTooltip="Haven't run this in a few days. Click to run it now."
onClick={async () => {
if (runStarting) return;
setRunStarting(true);
try {
const result = await dispatch(runWorkflowNow(workflow.id));
await dispatch(fetchRuns(workflow.id));
if (runWorkflowNow.fulfilled.match(result)) {
const payload = result.payload;
if (payload.status === 'skipped' && payload.error) {
setRunToast(`Run skipped: ${payload.error}`);
}
}
} finally {
setTimeout(() => setRunStarting(false), 600);
}
}}
/>
</Box>
)}
{!isDraft && workflow && card.view === 'running' && (
<RunningHeader workflowId={workflowId} />
)}
{/* ===== 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`. */}
<Box ref={bodyScrollRef} data-no-drag sx={{ flex: 1, p: 2, overflowY: 'auto', minHeight: 0, position: 'relative', overscrollBehavior: 'contain', display: 'flex', flexDirection: 'column', borderTop: `1px solid ${c.border.subtle}` }}>
{/* No AnimatePresence wrapper here on purpose: framer-motion's
crossfade was racing user-input events and stealing focus
from the title/description/step InputBases on every parent
re-render (Redux dispatches from selection/zOrder/etc.). The
tab body just swaps directly; the user doesn't notice the
missing crossfade. */}
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{card.view === 'preview' && (
<PreviewView
workflowId={workflowId}
steps={steps}
sourceSessionId={card.sourceSessionId || null}
initialDraft={card.draft || null}
onSaved={(wf) => {
// 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 && (
<SavedView
workflow={workflow}
steps={steps}
runs={runs}
activeRunId={(runs || []).find((r) => r.status === 'running')?.id || null}
/>
)}
{card.view === 'edit' && workflow && (
<WorkflowEditViews
workflow={workflow}
facet={card.editFacet || 'General'}
onChangeFacet={(f) => dispatch(updateWorkflowCard({ workflowId, patch: { editFacet: f } }))}
onDirtyChange={setEditDirty}
/>
)}
{card.view === 'history' && workflow && (
<HistoryList
runs={runs || []}
onOpen={async (run) => {
if (!run.session_id) {
dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history_detail', historyRunId: run.id } }));
return;
}
const sid = run.session_id;
if (!store.getState().agents.sessions[sid]) {
try { await dispatch(fetchSession(sid)).unwrap(); } catch { /* fall back to detail */ }
}
if (!store.getState().agents.sessions[sid]) {
dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history_detail', historyRunId: run.id } }));
return;
}
if (!store.getState().dashboardLayout.cards[sid]) {
dispatch(placeCard({
sessionId: sid,
x: cardX + cardWidth + 60,
y: cardY,
width: DEFAULT_CARD_W,
height: DEFAULT_CARD_H,
expandedSessionIds,
}));
}
dispatch(setPendingFocusAgentId(sid));
}}
/>
)}
{card.view === 'history_detail' && workflow && (
<HistoryDetail
run={(runs || []).find((r) => r.id === card.historyRunId) || null}
onBack={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }))}
/>
)}
{card.view === 'running' && workflow && (
<RunningView workflow={workflow} steps={steps} runs={runs} mode={card.sidecarKind === 'watching' ? 'sidecar-linked' : 'card'} />
)}
{card.view === 'completed' && workflow && (
<CompletedView workflow={workflow} steps={steps} runs={runs} mode={card.sidecarKind === 'viewing-completed' ? 'sidecar-linked' : 'card'} />
)}
{card.view === 'failed' && workflow && (
<FailedView workflow={workflow} steps={steps} runs={runs} mode={card.sidecarKind === 'viewing-error' ? 'sidecar-linked' : 'card'} />
)}
{card.view === 'scheduling' && workflow && (
<SchedulingView workflow={workflow} steps={steps} />
)}
{(card.view === 'edit_agent' || card.view === 'fix_agent') && workflow && (
<EditAgentView workflow={workflow} steps={steps} isFixMode={card.view === 'fix_agent'} />
)}
</Box>
</Box>
{/* ===== Resize handles ===== */}
{HANDLE_DEFS.map(({ dir, sx }) => (
<Box
key={dir}
className="resize-handle"
onPointerDown={handleResizeDown(dir)}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeUp}
sx={{
position: 'absolute',
cursor: CURSOR_MAP[dir],
opacity: 0,
zIndex: 25,
...sx,
}}
/>
))}
{/* First-success celebration. Tiny CSS-only sparkle so we don't
pull in a confetti library. ~2s self-clears via the effect. */}
{celebrate && (
<Box sx={{ position: 'absolute', inset: 0, pointerEvents: 'none', overflow: 'hidden', zIndex: 30 }}>
<Box sx={{
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
fontSize: '1.4rem', fontWeight: 700, color: c.accent.primary,
bgcolor: c.bg.surface, px: 1.2, py: 0.5, borderRadius: 999,
boxShadow: c.shadow.md,
animation: 'first-success-pop 1.4s ease-out forwards',
'@keyframes first-success-pop': {
'0%': { opacity: 0, transform: 'translate(-50%,-50%) scale(0.6)' },
'20%': { opacity: 1, transform: 'translate(-50%,-50%) scale(1.08)' },
'60%': { opacity: 1, transform: 'translate(-50%,-50%) scale(1.0)' },
'100%': { opacity: 0, transform: 'translate(-50%,-50%) scale(1.0)' },
},
}}>
🎉 First success
</Box>
</Box>
)}
{/* Toast for run outcomes that need explaining beyond the History
row (cost cap, "previous run still active," etc.). Auto-hides
after 6s; user can click anywhere to dismiss. */}
<Snackbar
open={Boolean(runToast)}
autoHideDuration={6000}
onClose={() => setRunToast(null)}
message={runToast || ''}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
/>
</Box>
);
};
function isHeaderlessView(view: string): boolean {
// Edit-agent / fix-agent / scheduling render their own Discard/Save
// (or Cancel) header inside the body so the parent skips the default
// History/Run row to avoid two stacked toolbars.
return view === 'edit_agent' || view === 'fix_agent' || view === 'scheduling';
}
function StatusPill({ view, workflow, runs }: { view: string; workflow: Workflow | undefined; runs: import('@/shared/state/workflowsSlice').WorkflowRun[] | undefined }) {
const c = useClaudeTokens();
// Pills appear on the title row to mirror Image #34 (completed source
// chat), #40 (running), #42 (completed workflow), #41 (running while
// watching). Saved / Preview / Edit / Scheduling have no pill.
let label = '';
let color = c.text.muted;
let bg = c.bg.elevated;
if (view === 'running') {
label = 'running';
color = c.status.success;
bg = c.status.successBg;
} else if (view === 'completed') {
label = 'completed';
color = c.text.secondary;
bg = c.bg.elevated;
} else {
// Image #46: failed view has NO pill in the title row; the red X
// on the failed step row carries the signal. Preview/Saved/Edit/
// Scheduling are likewise pill-less.
void workflow; void runs;
return null;
}
return (
<Box sx={{
display: 'inline-flex', alignItems: 'center',
fontSize: '0.74rem', fontWeight: 600,
px: 0.8, py: 0.18, borderRadius: `${c.radius.md}px`,
color, bgcolor: bg,
ml: 0.25,
}}>{label}</Box>
);
}
function SubtitleRow({ workflow, runs, fallbackModel, fallbackMode, fallbackSourceSessionId }: {
workflow: Workflow | null;
runs: import('@/shared/state/workflowsSlice').WorkflowRun[] | null;
fallbackModel?: string;
fallbackMode?: string;
fallbackSourceSessionId?: string | null;
}) {
const c = useClaudeTokens();
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
// Draft preview has no workflow yet; fall back to the converting chat's
// model/mode and use its work time for the "28s" so the subtitle reads the
// same as the source chat card did.
const sourceSession = useAppSelector((s) => fallbackSourceSessionId ? s.agents.sessions[fallbackSourceSessionId] : undefined);
// Match Image #34/#35/#36/#38/#40: "Claude Opus 4.6 agent 28s".
// Spaces between fields, all in muted text.
const effModel = workflow?.model || fallbackModel || '';
const modelLabel = React.useMemo(() => {
if (!effModel) return '';
for (const list of Object.values(modelsByProvider || {})) {
for (const m of (list as any[]) || []) {
if (m.value === effModel) return m.label || effModel;
}
}
return effModel;
}, [effModel, modelsByProvider]);
const modeLabel = workflow?.mode || fallbackMode || '';
const duration = React.useMemo(() => {
const finished = (runs || []).find((r) => r.finished_at);
if (finished && finished.finished_at) {
const ms = new Date(finished.finished_at).getTime() - new Date(finished.started_at).getTime();
if (ms > 0) {
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
return `${Math.floor(ms / 60_000)}m`;
}
}
if (sourceSession) {
const { total } = getAgentWorkTime(sourceSession.messages || [], sourceSession.status);
if (total > 0) return total < 60 ? `${total}s` : `${Math.floor(total / 60)}m`;
}
return '';
}, [runs, sourceSession]);
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1.25, fontSize: '0.82rem', color: c.text.muted, minWidth: 0, overflow: 'hidden' }}>
{modelLabel && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{modelLabel}</Box>}
{modeLabel && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{modeLabel}</Box>}
{duration && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{duration}</Box>}
</Box>
);
}
function RunningHeader({ workflowId }: { workflowId: string }) {
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]);
const runId = card?.runId || null;
const run = (runs || []).find((r) => r.id === runId);
const onStop = React.useCallback(async () => {
if (!run) return;
try {
const { API_BASE, getAuthToken } = await import('@/shared/config');
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
await fetch(`${API_BASE}/workflows/runs/${encodeURIComponent(run.id)}/stop`, {
method: 'POST',
headers: tok ? { Authorization: `Bearer ${tok}` } : {},
});
} catch { /* best-effort */ }
dispatch(updateWorkflowCard({ workflowId, patch: { view: 'saved', runId: null } }));
}, [dispatch, workflowId, run]);
// Pause = "let this run finish, but stop firing future schedules."
// Can't actually pause a streaming agent turn mid-call, so we flip
// schedule.enabled so the scheduler stops queuing the next fire. The
// button label flips to "Resume" while paused; user can re-enable
// without leaving the running view.
const isPaused = !!workflow && !workflow.schedule.enabled && workflow.schedule.runs_count > 0;
const onPauseToggle = React.useCallback(async () => {
if (!workflow) return;
const next = { ...workflow.schedule, enabled: isPaused };
await dispatch(updateWorkflow({
id: workflow.id,
patch: { schedule: next as Workflow['schedule'] },
ifMatch: workflow.updated_at || null,
}));
}, [dispatch, workflow, isPaused]);
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 2, pb: 1.25, pt: 0, flexShrink: 0 }}>
<SubtitleRow workflow={workflow || null} runs={runs || null} />
<Box sx={{ flex: 1 }} />
<Box
onClick={onStop}
role="button"
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35, fontSize: '0.82rem', fontWeight: 600, px: 1, py: 0.4, color: c.text.secondary, cursor: 'pointer', borderRadius: 999, '&:hover': { color: c.text.primary, bgcolor: c.bg.elevated } }}>
<StopRounded sx={{ fontSize: 15 }} />
Stop
</Box>
<Tooltip title={isPaused ? 'Schedule is paused. Click to resume future fires.' : 'Pause future scheduled fires. This run finishes normally.'}>
<Box
onClick={onPauseToggle}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.35,
fontSize: '0.82rem', fontWeight: 700,
px: 1.1, py: 0.4, borderRadius: 999,
bgcolor: c.accent.primary, color: '#fff', cursor: 'pointer',
'&:hover': { filter: 'brightness(1.05)' },
}}>
<PauseRounded sx={{ fontSize: 15 }} />
{isPaused ? 'Resume' : 'Pause'}
</Box>
</Tooltip>
</Box>
);
}
function TabBtn({ label, icon, active, accent, breathe, breatheTooltip, dot, dotTooltip, onClick }: { label: string; icon: React.ReactNode; active: boolean; accent?: boolean; breathe?: boolean; breatheTooltip?: string; dot?: boolean; dotTooltip?: string; onClick: () => void }) {
const c = useClaudeTokens();
const btn = (
<Box
onClick={onClick}
onPointerDown={(e) => e.stopPropagation()}
role="button"
data-no-drag
sx={{
// Consistent visual weight across Run/Edit/History per target
// #54: identical padding + border thickness, matched 32px row
// height. `accent` (Run only) gets the colored text + tinted bg
// so it reads as the primary verb without screaming "selected".
// Tabs no longer flip the bg on `active`; the body view itself
// tells the user where they are.
display: 'inline-flex', alignItems: 'center', gap: 0.5,
px: 1.25, py: 0.5,
minHeight: 32,
fontSize: '0.82rem', fontWeight: 600,
whiteSpace: 'nowrap',
color: accent ? c.accent.primary : c.text.secondary,
bgcolor: accent ? c.accent.primary + '14' : 'transparent',
// Only the Run (accent) tab carries a border; Edit/History sit as
// quiet text-with-icon affordances so the primary verb stands out.
border: accent ? `1px solid ${c.accent.primary}50` : '1px solid transparent',
borderRadius: `${c.radius.md}px`,
cursor: 'pointer', userSelect: 'none',
'&:hover': { bgcolor: accent ? c.accent.primary + '22' : c.bg.elevated, borderColor: accent ? c.accent.primary : 'transparent' },
// Active state: nudge bg only when this is a non-accent tab so the
// user can still see "you're on this view". Run's accent styling
// already does that job; piling a darker bg on top reads as
// disabled.
...(active && !accent && {
color: c.text.primary,
bgcolor: c.bg.elevated,
}),
// Subtle "ready" breath when a stale workflow's Run button hasn't
// been touched in over 24h. ~3% scale + glow swell, slow enough
// to read as ambient rather than urgent. Tooltip is on so users
// don't think the button is malfunctioning.
...(breathe && {
animation: 'workflow-run-breath 3.2s ease-in-out infinite',
'@keyframes workflow-run-breath': {
'0%, 100%': { boxShadow: `0 0 0 ${c.accent.primary}00`, transform: 'scale(1)' },
'50%': { boxShadow: `0 0 14px ${c.accent.primary}55`, transform: 'scale(1.03)' },
},
}),
}}>
{icon}
{label}
{dot && (
<Box sx={{
width: 7, height: 7, borderRadius: '50%',
bgcolor: c.accent.primary,
ml: 0.25,
}} />
)}
</Box>
);
if (dot && dotTooltip) {
return <Tooltip title={dotTooltip}>{btn}</Tooltip>;
}
if (breathe && breatheTooltip) {
return <Tooltip title={breatheTooltip}>{btn}</Tooltip>;
}
return btn;
}
export default React.memo(WorkflowCard);
@@ -1,554 +0,0 @@
// Run-state views for the workflow card. The card's `view` field flips to
// 'running' / 'completed' / 'failed' off of the workflow:run ws stream
// (see upsertRun reducer). Each view here renders the same step list
// with a different status overlay + a different footer.
import React, { useCallback, useMemo } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import HistoryIcon from '@mui/icons-material/HistoryRounded';
import PlayArrowIcon from '@mui/icons-material/PlayArrowRounded';
import StopRounded from '@mui/icons-material/StopRounded';
import PauseRounded from '@mui/icons-material/PauseRounded';
import RocketLaunchRounded from '@mui/icons-material/RocketLaunchRounded';
import BuildRounded from '@mui/icons-material/BuildRounded';
import EditOutlined from '@mui/icons-material/EditOutlined';
import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined';
import VisibilityOffOutlined from '@mui/icons-material/VisibilityOffOutlined';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
setCardSidecar,
toggleExpandedStep,
updateWorkflowCard,
type Workflow,
type WorkflowRun,
} from '@/shared/state/workflowsSlice';
import { DEFAULT_CARD_W, DEFAULT_CARD_H, placeCard } from '@/shared/state/dashboardLayoutSlice';
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
import { fetchSession } from '@/shared/state/agentsSlice';
import StepList, { type StepStatus } from './StepList';
// Helper: open a session next to the workflow card AND mark the card as
// sidecar-linked so the footer flips to Stop Watching/Viewing and the
// dashboard draws an arrow chip between the two cards.
function useOpenSidecar(workflowId: string) {
const dispatch = useAppDispatch();
const wfCardPos = useAppSelector((s) => s.dashboardLayout.workflowCards[workflowId]);
const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds);
return React.useCallback(async (sessionId: string, kind: 'watching' | 'viewing-completed' | 'viewing-error' | 'testing') => {
if (!sessionId) return;
try {
const { store } = await import('@/shared/state/store');
if (!store.getState().agents.sessions[sessionId]) {
try { await dispatch(fetchSession(sessionId)).unwrap(); } catch { /* not fatal */ }
}
if (!store.getState().dashboardLayout.cards[sessionId] && wfCardPos) {
dispatch(placeCard({
sessionId,
x: wfCardPos.x + wfCardPos.width + 60,
y: wfCardPos.y,
width: DEFAULT_CARD_W,
height: DEFAULT_CARD_H,
expandedSessionIds,
}));
}
dispatch(setPendingFocusAgentId(sessionId));
} catch { /* best-effort */ }
dispatch(setCardSidecar({ workflowId, sessionId, kind }));
}, [dispatch, workflowId, wfCardPos, expandedSessionIds]);
}
type ViewMode = 'card' | 'sidecar-linked';
// ---------- Shared bits ----------
function ProgressBar({ value, color }: { value: number; color: string }) {
const c = useClaudeTokens();
const pct = Math.max(0, Math.min(1, value));
return (
<Box sx={{ width: '100%', height: 4, borderRadius: 999, bgcolor: c.bg.elevated, overflow: 'hidden' }}>
<Box sx={{
width: `${pct * 100}%`, height: '100%', bgcolor: color,
transition: 'width 0.4s ease',
boxShadow: `0 0 6px ${color}66`,
}} />
</Box>
);
}
function PillButton({ label, onClick, icon, tone, filled, disabled }: {
label: string;
onClick: () => void;
icon?: React.ReactNode;
tone: 'accent' | 'success' | 'danger' | 'muted';
filled?: boolean;
disabled?: boolean;
}) {
const c = useClaudeTokens();
const colorFor = (t: typeof tone) =>
t === 'success' ? c.status.success : t === 'danger' ? c.status.error : t === 'accent' ? c.accent.primary : c.text.secondary;
const color = colorFor(tone);
const bg = filled ? color : 'transparent';
const fg = filled ? '#fff' : color;
return (
<Box
onClick={disabled ? undefined : onClick}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
fontSize: '0.86rem', fontWeight: 700,
px: 1.4, py: 0.55, borderRadius: 999,
cursor: disabled ? 'not-allowed' : 'pointer',
color: fg, bgcolor: bg,
border: filled ? `1px solid ${color}` : `1px solid ${color}55`,
opacity: disabled ? 0.5 : 1,
'&:hover': { filter: 'brightness(1.05)', bgcolor: filled ? color : color + '14' },
}}>
{icon}
{label}
</Box>
);
}
function GhostTextBtn({ label, onClick }: { label: string; onClick: () => void }) {
const c = useClaudeTokens();
return (
<Box
onClick={onClick}
role="button"
sx={{
fontSize: '0.86rem', fontWeight: 500, color: c.text.secondary,
cursor: 'pointer', px: 0.75, py: 0.5,
'&:hover': { color: c.text.primary },
}}>
{label}
</Box>
);
}
// ---------- RunningView (Image #40) ----------
export function RunningView({ workflow, steps, runs, mode = 'card' }: {
workflow: Workflow;
steps: Workflow['steps'];
runs?: WorkflowRun[];
mode?: ViewMode;
}) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const card = useAppSelector((s) => s.workflows.openCards[workflow.id]);
const runId = card?.runId || null;
const run = useMemo(() => (runs || []).find((r) => r.id === runId) || null, [runs, runId]);
// Prefer the backend's real active_step_idx (broadcast on each step
// bump in executor.execute). Fall back to elapsed/expected heuristic
// when the field is missing (older runs or first-frame race).
const heuristicIdx = useActiveStepIdx(steps.length, runs, runId);
const activeIdx = typeof run?.active_step_idx === 'number' ? run.active_step_idx : heuristicIdx;
const statuses: StepStatus[] = steps.map((_, i) =>
i < activeIdx ? 'done' : i === activeIdx ? 'active' : 'pending',
);
const completeCount = statuses.filter((s) => s === 'done').length;
const total = steps.length;
// Tool-call subtitle for the active step. Backend polls the session's
// messages at 1.5s cadence and broadcasts on workflow:run as the agent
// makes new tool calls. See executor.py _watch_tool_calls.
const activeSubtitle = run?.last_tool_label || null;
const activeDuration = formatLiveDuration(run);
const isLinked = mode === 'sidecar-linked' && card?.sidecarKind === 'watching';
const onStop = useCallback(async () => {
if (!runId) return;
try {
const { API_BASE, getAuthToken } = await import('@/shared/config');
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
await fetch(`${API_BASE}/workflows/runs/${encodeURIComponent(runId)}/stop`, {
method: 'POST',
headers: tok ? { Authorization: `Bearer ${tok}` } : {},
});
} catch { /* best-effort */ }
}, [runId]);
const onPause = useCallback(() => {
// Pause flips the global paused state; the in-flight run continues but
// future fires queue up behind it. Maps to the existing /pause-all path.
void undefined;
}, []);
const openSidecar = useOpenSidecar(workflow.id);
const onWatchLive = useCallback(() => {
if (run?.session_id) void openSidecar(run.session_id, 'watching');
}, [openSidecar, run?.session_id]);
const onStopWatching = useCallback(() => {
dispatch(setCardSidecar({ workflowId: workflow.id, sessionId: null, kind: null }));
}, [dispatch, workflow.id]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
<Typography sx={{ fontSize: '0.92rem', fontWeight: 700, color: c.status.success }}>
{completeCount} of {total} complete
</Typography>
</Box>
<ProgressBar value={total > 0 ? completeCount / total : 0} color={c.status.success} />
<StepList
workflow={workflow}
steps={steps}
stepStatuses={statuses}
activeStepSubtitle={activeSubtitle}
activeStepDuration={activeDuration}
/>
<Box sx={{ flex: 1 }} />
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
{isLinked ? (
<PillButton
label="Stop Watching"
tone="danger"
filled={false}
icon={<VisibilityOffOutlined sx={{ fontSize: 16 }} />}
onClick={onStopWatching}
/>
) : (
<PillButton
label="Watch Live"
tone="muted"
filled={false}
icon={<VisibilityOutlined sx={{ fontSize: 16 }} />}
onClick={onWatchLive}
/>
)}
</Box>
{/* Stop / Pause live in the header row, rendered by WorkflowCard.
See header-button overrides in WorkflowCard.tsx for the
per-view replacement of History/Run. */}
<Box sx={{ display: 'none' }} aria-hidden onClick={onStop} />
<Box sx={{ display: 'none' }} aria-hidden onClick={onPause} />
</Box>
);
}
function useActiveStepIdx(stepCount: number, runs: WorkflowRun[] | undefined, activeRunId: string | null | undefined): number {
const [, setTick] = React.useState(0);
React.useEffect(() => {
if (!activeRunId) return;
const id = window.setInterval(() => setTick((t) => (t + 1) % 1000000), 1000);
return () => window.clearInterval(id);
}, [activeRunId]);
if (!activeRunId || !runs) return 0;
const active = runs.find((r) => r.id === activeRunId && r.status === 'running');
if (!active) return 0;
const elapsed = Date.now() - new Date(active.started_at).getTime();
const completed = runs.filter((r) => (r.status === 'success' || r.status === 'ran_late') && r.finished_at);
if (completed.length === 0) {
return Math.min(stepCount - 1, Math.max(0, Math.floor(stepCount / 2)));
}
const durations = completed.slice(0, 10).map((r) => new Date(r.finished_at!).getTime() - new Date(r.started_at).getTime());
const avg = durations.reduce((a, b) => a + b, 0) / durations.length || 1;
const ratio = Math.min(0.99, Math.max(0, elapsed / avg));
return Math.min(stepCount - 1, Math.floor(ratio * stepCount));
}
function formatLiveDuration(run: WorkflowRun | null): string | null {
if (!run || run.status !== 'running') return null;
try {
const ms = Date.now() - new Date(run.started_at).getTime();
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const m = Math.floor(ms / 60_000);
return `${m}m`;
} catch { return null; }
}
// ---------- CompletedView (Image #42) ----------
export function CompletedView({ workflow, steps, runs, mode = 'card' }: {
workflow: Workflow;
steps: Workflow['steps'];
runs?: WorkflowRun[];
mode?: ViewMode;
}) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const card = useAppSelector((s) => s.workflows.openCards[workflow.id]);
const runId = card?.runId || null;
const run = useMemo(() => (runs || []).find((r) => r.id === runId) || null, [runs, runId]);
const statuses: StepStatus[] = steps.map(() => 'done');
const isLinked = mode === 'sidecar-linked' && card?.sidecarKind === 'viewing-completed';
const onDone = useCallback(() => {
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved', runId: null, sidecarSessionId: null, sidecarKind: null } }));
}, [dispatch, workflow.id]);
const onEdit = useCallback(() => {
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'edit_agent' } }));
}, [dispatch, workflow.id]);
const openSidecar = useOpenSidecar(workflow.id);
const onViewAgent = useCallback(() => {
if (run?.session_id) void openSidecar(run.session_id, 'viewing-completed');
}, [openSidecar, run?.session_id]);
const onStopViewing = useCallback(() => {
dispatch(setCardSidecar({ workflowId: workflow.id, sessionId: null, kind: null }));
}, [dispatch, workflow.id]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.6 }}>
<Box component="span" sx={{ color: c.status.success, fontSize: 18, lineHeight: 1, mr: 0.25 }}></Box>
<Typography sx={{ fontSize: '0.92rem', fontWeight: 700, color: c.status.success }}>
{steps.length} of {steps.length} complete
</Typography>
</Box>
<ProgressBar value={1} color={c.status.success} />
<StepList
workflow={workflow}
steps={steps}
stepStatuses={statuses}
/>
<Box sx={{ flex: 1 }} />
{/* Success card. Hidden in sidecar-linked mode (Image #43) so the
compacted card stays tight. Soft green tint + rocket icon. */}
{!isLinked && (
<Box sx={{
display: 'flex', alignItems: 'flex-start', gap: 1.25,
p: 1.5, borderRadius: `${c.radius.lg}px`,
bgcolor: c.status.successBg,
border: `1px solid ${c.status.success}30`,
}}>
<Box sx={{
width: 32, height: 32, borderRadius: `${c.radius.md}px`,
bgcolor: c.status.success + '22', color: c.status.success,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<RocketLaunchRounded sx={{ fontSize: 16 }} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.95rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.3 }}>
Workflow Success!
</Typography>
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, mt: 0.25, lineHeight: 1.45 }}>
If you&apos;re curious, you can click the green button below to see exactly what the agent did.
</Typography>
</Box>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
<PillButton
label="Edit"
tone="muted"
filled={false}
icon={<EditOutlined sx={{ fontSize: 15 }} />}
onClick={onEdit}
/>
<Box sx={{ flex: 1 }} />
{/* Image #43: Done is hidden in sidecar mode; Stop Viewing alone
fills the right slot. Default mode keeps Done + View Agent. */}
{!isLinked && <GhostTextBtn label="Done" onClick={onDone} />}
{isLinked ? (
<PillButton
label="Stop Viewing"
tone="success"
filled={false}
icon={<VisibilityOffOutlined sx={{ fontSize: 16 }} />}
onClick={onStopViewing}
/>
) : (
<PillButton
label="View Agent"
tone="success"
filled
onClick={onViewAgent}
/>
)}
</Box>
</Box>
);
}
// ---------- FailedView (Image #46) ----------
export function FailedView({ workflow, steps, runs, mode = 'card' }: {
workflow: Workflow;
steps: Workflow['steps'];
runs?: WorkflowRun[];
mode?: ViewMode;
}) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const card = useAppSelector((s) => s.workflows.openCards[workflow.id]);
const runId = card?.runId || null;
const run = useMemo(() => (runs || []).find((r) => r.id === runId) || null, [runs, runId]);
const failedIdx = guessFailedIdx(run, steps.length);
const statuses: StepStatus[] = steps.map((_, i) =>
i < failedIdx ? 'done' : i === failedIdx ? 'failed' : 'pending',
);
const isLinked = mode === 'sidecar-linked' && card?.sidecarKind === 'viewing-error';
const onIgnore = useCallback(() => {
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved', runId: null, sidecarSessionId: null, sidecarKind: null } }));
}, [dispatch, workflow.id]);
const openSidecar = useOpenSidecar(workflow.id);
const onViewError = useCallback(() => {
if (run?.session_id) void openSidecar(run.session_id, 'viewing-error');
}, [openSidecar, run?.session_id]);
const onStopViewing = useCallback(() => {
dispatch(setCardSidecar({ workflowId: workflow.id, sessionId: null, kind: null }));
}, [dispatch, workflow.id]);
const onFixWithAgent = useCallback(() => {
if (!run) return;
const stepLabel = steps[failedIdx]?.label || steps[failedIdx]?.text?.slice(0, 60) || `Step ${failedIdx + 1}`;
dispatch(updateWorkflowCard({
workflowId: workflow.id,
patch: {
view: 'fix_agent',
sidecarSessionId: null,
sidecarKind: null,
fixSeed: { runId: run.id, stepIdx: failedIdx, stepLabel, error: run.error || 'Step failed.' },
},
}));
}, [dispatch, workflow.id, run, steps, failedIdx]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
<StepList
workflow={workflow}
steps={steps}
stepStatuses={statuses}
/>
<Box sx={{ flex: 1 }} />
<Box sx={{
display: 'flex', alignItems: 'flex-start', gap: 1.25,
p: 1.5, borderRadius: `${c.radius.lg}px`,
bgcolor: c.status.errorBg,
border: `1px solid ${c.status.error}30`,
}}>
<Box sx={{
width: 32, height: 32, borderRadius: `${c.radius.md}px`,
bgcolor: c.status.error + '22', color: c.status.error,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<BuildRounded sx={{ fontSize: 16 }} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.95rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.3 }}>
Fix with an Agent
</Typography>
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, mt: 0.25, lineHeight: 1.45 }}>
Have an agent modify, test, and iterate on the workflow until it works as expected.
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
{isLinked ? (
<PillButton
label="Stop Viewing"
tone="danger"
filled={false}
icon={<VisibilityOffOutlined sx={{ fontSize: 16 }} />}
onClick={onStopViewing}
/>
) : (
<PillButton
label="View Error"
tone="muted"
filled={false}
icon={<VisibilityOutlined sx={{ fontSize: 16 }} />}
onClick={onViewError}
/>
)}
<Box sx={{ flex: 1 }} />
<GhostTextBtn label="Ignore" onClick={onIgnore} />
<PillButton
label="Fix with Agent"
tone="danger"
filled
onClick={onFixWithAgent}
/>
</Box>
</Box>
);
}
function guessFailedIdx(run: WorkflowRun | null, total: number): number {
if (!run) return Math.max(0, total - 1);
// Backend pins active_step_idx at the failed step before flipping
// status to 'failure'. Prefer that; fall back to parsing "Step N"
// out of the error string for legacy runs.
if (typeof run.active_step_idx === 'number') {
return Math.max(0, Math.min(total - 1, run.active_step_idx));
}
if (run.error) {
const m = /step\s+(\d+)/i.exec(run.error);
if (m) {
const n = parseInt(m[1], 10);
if (!Number.isNaN(n) && n >= 1 && n <= total) return n - 1;
}
}
return Math.max(0, Math.min(total - 1, 1));
}
// ---------- Header overrides ----------
// The card header normally renders {History | Run}. Running shows
// {Stop | Pause}, Completed/Failed keep {History | Run}, Edit/Fix shows
// {Discard | Save}, Scheduling shows {Cancel task scheduling}. The
// WorkflowCard hands off via this helper so each view can declare its
// own header without the parent fanning out a switch.
export interface HeaderActions {
left?: React.ReactNode;
right: React.ReactNode;
}
export function useHeaderActions(workflow: Workflow | null, view: string): HeaderActions {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
return useMemo<HeaderActions>(() => {
if (!workflow) return { right: null };
const HistoryRun = (
<>
<Box
onClick={() => dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'history' } }))}
role="button"
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: '0.82rem', fontWeight: 600, px: 1, py: 0.4, color: c.text.secondary, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>
<HistoryIcon sx={{ fontSize: 15 }} />
History
</Box>
<Box
onClick={() => dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } }))}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.35,
fontSize: '0.82rem', fontWeight: 700,
px: 1.1, py: 0.4, borderRadius: 999,
bgcolor: c.accent.primary, color: '#fff', cursor: 'pointer',
'&:hover': { filter: 'brightness(1.05)' },
}}>
<PlayArrowIcon sx={{ fontSize: 15 }} />
Run
</Box>
</>
);
if (view === 'running') {
return {
right: (
<>
<Box role="button" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35, fontSize: '0.82rem', fontWeight: 600, px: 1, py: 0.4, color: c.text.secondary, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>
<StopRounded sx={{ fontSize: 15 }} />
Stop
</Box>
<Box role="button" sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.35,
fontSize: '0.82rem', fontWeight: 700,
px: 1.1, py: 0.4, borderRadius: 999,
bgcolor: c.accent.primary, color: '#fff', cursor: 'pointer',
'&:hover': { filter: 'brightness(1.05)' },
}}>
<PauseRounded sx={{ fontSize: 15 }} />
Pause
</Box>
</>
),
};
}
return { right: HistoryRun };
}, [workflow, view, dispatch, c]);
}
@@ -1,598 +0,0 @@
import React, { useCallback, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Popover from '@mui/material/Popover';
import Tooltip from '@mui/material/Tooltip';
import InputBase from '@mui/material/InputBase';
import HistoryIcon from '@mui/icons-material/HistoryToggleOffRounded';
import CalendarMonthRounded from '@mui/icons-material/CalendarMonthRounded';
import EditOutlined from '@mui/icons-material/EditOutlined';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
createWorkflow,
toggleExpandedStep,
updateWorkflow,
updateWorkflowCard,
type Workflow,
type WorkflowRun,
} from '@/shared/state/workflowsSlice';
import { CostChip, humanDuration, routingFor, StreakBadge } from './workflowVisuals';
import StepList from './StepList';
export function statusColor(s: string, c: ReturnType<typeof useClaudeTokens>): 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<typeof useClaudeTokens>): 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; }
}
type ActionBtnTone = 'muted' | 'success' | 'danger';
export function ActionBtn({ label, tone, disabled, onClick, icon }: { label: string; tone: ActionBtnTone; disabled?: boolean; onClick: () => void; icon?: 'trash' | 'check' }) {
const c = useClaudeTokens();
// Tone -> color triple. Matches target #58/#63 styling:
// success = green pill (Save)
// danger = red/pink pill (Discard)
// muted = neutral pill (Undo)
const palette = tone === 'success'
? { color: c.status.success, bg: c.status.successBg, border: c.status.success + '60', hover: c.status.success + '30' }
: tone === 'danger'
? { color: c.status.error, bg: c.status.errorBg, border: c.status.error + '60', hover: c.status.error + '30' }
: { color: c.text.secondary, bg: c.bg.secondary, border: c.border.subtle, hover: c.bg.elevated };
return (
<Box
onClick={disabled ? undefined : onClick}
role="button"
sx={{
// Compact pill matching target #58/#63. Smaller padding + smaller
// glyphs so the buttons stop overshadowing the step body.
display: 'inline-flex', alignItems: 'center', gap: 0.4,
fontSize: '0.78rem', fontWeight: 600,
px: 1, py: 0.35,
borderRadius: 999,
cursor: disabled ? 'not-allowed' : 'pointer',
color: palette.color,
bgcolor: palette.bg,
border: `1px solid ${palette.border}`,
opacity: disabled ? 0.5 : 1,
'&:hover': { bgcolor: palette.hover },
}}>
{icon === 'trash' && (
<Box component="span" sx={{ display: 'inline-flex', fontSize: 12, lineHeight: 1 }}>{'\u{1F5D1}'}</Box>
)}
{icon === 'check' && (
<Box component="span" sx={{ display: 'inline-flex', fontSize: 12, lineHeight: 1 }}>{'✓'}</Box>
)}
{label}
</Box>
);
}
export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, onSaved }: {
workflowId: string;
steps: Workflow['steps'];
sourceSessionId: string | null;
initialDraft: Partial<Workflow> | null;
onSaved: (w: Workflow) => void;
}) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const [busy, setBusy] = useState(false);
// Title + description live in the openCard draft so the parent header
// (which renders the inline-editable title) and PreviewView body (which
// renders the inline-editable description + steps) stay in sync. On
// Save we pull whatever's currently in the draft, falling back to the
// initialDraft passed at mount time.
const card = useAppSelector((s) => s.workflows.openCards[workflowId]);
const liveDraft = (card?.draft ?? initialDraft ?? {}) as Partial<Workflow>;
const title = (liveDraft.title as string) || 'New workflow';
const description = (liveDraft.description as string) || '';
// The new workflow runs with the user's configured default model/mode (their
// subscription, etc.), falling back to whatever the source chat used. Without
// this the backend picks its own default, which surprised users who'd set a
// subscription default but saw the workflow created on an API-key model.
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
const defaultMode = useAppSelector((s) => s.settings.data.default_mode);
// Steps render compact (label + chevron, capped + "... N more"), same as
// the saved card. The raw prompt drills down on click. Keeping them short
// is what leaves room for the schedule prompt + buttons to stay on-card.
const expandedIds = card?.expandedStepIds || [];
const onToggleStep = useCallback((stepId: string) => {
dispatch(toggleExpandedStep({ workflowId, stepId }));
}, [dispatch, workflowId]);
const onChangeDescription = useCallback((value: string) => {
dispatch(updateWorkflowCard({ workflowId, patch: { draft: { ...liveDraft, description: value } } }));
}, [dispatch, workflowId, liveDraft]);
// Both buttons persist the workflow; the only difference is where they land.
// Ignore = save and show the saved card. Schedule = save then open the
// natural-language scheduling composer. (Ignore used to delete the card,
// which surprised people; the schedule prompt is optional, the workflow isn't.)
const saveWorkflow = useCallback(async (): Promise<Workflow | null> => {
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,
// The user's configured default wins over whatever model the source chat
// happened to run on, so a converted workflow behaves like a fresh chat.
model: defaultModel || (liveDraft.model as string),
mode: defaultMode || (liveDraft.mode as string),
} as Partial<Workflow>));
const wf = (result as unknown as { payload: Workflow }).payload;
if (wf?.id) { onSaved(wf); return wf; }
return null;
}, [dispatch, title, description, steps, sourceSessionId, onSaved, liveDraft, defaultModel, defaultMode]);
const onIgnore = useCallback(async () => {
if (busy) return;
setBusy(true);
try { await saveWorkflow(); } finally { setBusy(false); }
}, [busy, saveWorkflow]);
const onSaveThenSchedule = useCallback(async () => {
if (busy) return;
setBusy(true);
try {
const wf = await saveWorkflow();
if (wf?.id) dispatch(updateWorkflowCard({ workflowId: wf.id, patch: { view: 'scheduling' } }));
} finally {
setBusy(false);
}
}, [busy, saveWorkflow, dispatch]);
void onChangeDescription;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
<StepList steps={steps} expandable expandedIds={expandedIds} onToggleExpand={onToggleStep} />
<Box sx={{ flex: 1 }} />
{/* Schedule prompt card. Soft accent tint + calendar icon. Accent is the
same color the human-intervention (AskUserQuestion) popup uses. */}
<Box sx={{
display: 'flex', alignItems: 'flex-start', gap: 1.25,
p: 1.5, borderRadius: `${c.radius.lg}px`,
bgcolor: c.accent.primary + '10',
border: `1px solid ${c.accent.primary}30`,
}}>
<Box sx={{
width: 32, height: 32, borderRadius: `${c.radius.md}px`,
bgcolor: c.accent.primary + '22', color: c.accent.primary,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<CalendarMonthRounded sx={{ fontSize: 18 }} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.95rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.3 }}>
Schedule this workflow?
</Typography>
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, mt: 0.25, lineHeight: 1.45 }}>
You can have workflows run on a recurring basis, automatically.
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 1.5 }}>
<Box
onClick={onIgnore}
role="button"
sx={{
fontSize: '0.86rem', fontWeight: 500, color: c.text.secondary,
cursor: busy ? 'wait' : 'pointer', px: 0.75, py: 0.5,
opacity: busy ? 0.6 : 1,
'&:hover': { color: c.text.primary },
}}>
Ignore
</Box>
<Box
onClick={onSaveThenSchedule}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
fontSize: '0.88rem', fontWeight: 700,
px: 1.75, py: 0.6, borderRadius: 999,
color: '#fff', bgcolor: c.accent.primary,
cursor: busy ? 'wait' : 'pointer',
opacity: busy ? 0.6 : 1,
'&:hover': { bgcolor: c.accent.primary, filter: 'brightness(1.06)' },
}}>
Schedule Workflow
</Box>
</Box>
</Box>
);
}
// Render the workflow's permission tiers as a flat prose line so the
// SavedView reads like a sentence, not a chip salad. Mirrors target #54.
function describePermissions(workflow: Workflow): string {
const tiers = workflow.permissions || [];
if (tiers.length === 0) return 'Notify me in Open Swarm';
const parts: string[] = [];
for (const t of tiers) {
if (t.kind === 'notify') parts.push('notify in app');
else if (t.kind === 'text') parts.push('text');
else if (t.kind === 'call') parts.push('call');
}
return `First ${parts.join(', then ')}`;
}
function describeSchedule(workflow: Workflow): string {
const s = workflow.schedule;
if (!s.enabled) return 'Not scheduled';
const h12 = ((s.hour + 11) % 12) + 1;
const ampm = s.hour < 12 ? 'am' : 'pm';
const time = s.minute === 0 ? `${h12}${ampm}` : `${h12}:${String(s.minute).padStart(2, '0')}${ampm}`;
if (s.repeat_unit === 'day') return s.repeat_every === 1 ? `Daily at ${time}` : `Every ${s.repeat_every} days at ${time}`;
if (s.repeat_unit === 'month') return s.repeat_every === 1 ? `Monthly at ${time}` : `Every ${s.repeat_every} months at ${time}`;
if (s.on_days.length === 5 && [1,2,3,4,5].every((d) => s.on_days.includes(d))) return `Weekdays at ${time}`;
if (s.on_days.length === 2 && [0,6].every((d) => s.on_days.includes(d))) return `Weekends at ${time}`;
if (s.on_days.length === 1) {
// Image #50: "Mondays at 3pm" (plural day, no "Every" prefix). Reads
// more naturally than "Every Mon at 3pm".
const plurals = ['Sundays', 'Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays'];
return `${plurals[s.on_days[0]]} at ${time}`;
}
return `Weekly at ${time}`;
}
export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Workflow; steps: Workflow['steps']; runs?: WorkflowRun[]; activeRunId?: string | null }) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
void runs; void activeRunId;
const card = useAppSelector((s) => s.workflows.openCards[workflow.id]);
const expandedIds = card?.expandedStepIds || [];
const openEditAgent = useCallback(() => {
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'edit_agent' } }));
}, [dispatch, workflow.id]);
const openScheduling = useCallback(() => {
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'scheduling' } }));
}, [dispatch, workflow.id]);
const onToggleStep = useCallback((stepId: string) => {
dispatch(toggleExpandedStep({ workflowId: workflow.id, stepId }));
}, [dispatch, workflow.id]);
const scheduleLine = workflow.schedule.enabled ? describeSchedule(workflow) : 'Schedule this workflow';
const scheduleClickable = !workflow.schedule.enabled;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
<StepList
workflow={workflow}
steps={steps}
expandable
expandedIds={expandedIds}
onToggleExpand={onToggleStep}
/>
<Box sx={{ flex: 1 }} />
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Box
onClick={scheduleClickable ? openScheduling : undefined}
role={scheduleClickable ? 'button' : undefined}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.6,
color: c.text.secondary, fontSize: '0.86rem', minWidth: 0,
cursor: scheduleClickable ? 'pointer' : 'default',
'&:hover': scheduleClickable ? { color: c.text.primary } : {},
}}>
<CalendarMonthRounded sx={{ fontSize: 16, color: c.text.muted, flexShrink: 0 }} />
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{scheduleLine}</Box>
</Box>
<Box
onClick={openEditAgent}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.45,
fontSize: '0.82rem', fontWeight: 600,
px: 1.25, py: 0.5,
borderRadius: 999,
cursor: 'pointer',
color: c.text.secondary,
bgcolor: 'transparent',
border: `1px solid ${c.border.medium}`,
'&:hover': { bgcolor: c.bg.elevated, borderColor: c.border.strong || c.border.medium, color: c.text.primary },
}}>
<EditOutlined sx={{ fontSize: 15 }} />
Edit
</Box>
</Box>
</Box>
);
}
// kept on file for legacy uses; once the audit popover migrates, this and
// the StreakBadge / habit-suggestion blocks above can be deleted entirely.
void StreakBadgeRow;
// Splits StreakBadge out so the SavedView body doesn't have to ferry
// the runs array through both the chip row (gone) and the step list.
function StreakBadgeRow({ runs }: { runs?: WorkflowRun[] }) {
if (!runs || runs.length === 0) return null;
return (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<StreakBadge runs={runs} />
</Box>
);
}
// Audit-trace popover. Lazy-fetches the last N edits from /workflows/{id}/audit
// on open, renders a compact list. The trigger sits inline with the chip
// row so power users can spot it without cluttering the title.
function AuditTraceLink({ workflowId }: { workflowId: string }) {
const c = useClaudeTokens();
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const [entries, setEntries] = useState<Array<{ ts: string; who: string; diff: Record<string, { before: unknown; after: unknown }> }> | null>(null);
const [loading, setLoading] = useState(false);
// Probe the audit log once on mount so we can hide the trigger entirely
// when there are no edits (item #21 in target #54 diff). Fire-and-forget;
// a failure leaves entries=null which renders nothing.
React.useEffect(() => {
let alive = true;
(async () => {
try {
const { API_BASE, getAuthToken } = await import('@/shared/config');
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflowId)}/audit?limit=5`, {
headers: tok ? { Authorization: `Bearer ${tok}` } : {},
});
const data = await res.json();
if (alive) setEntries(Array.isArray(data?.entries) ? data.entries : []);
} catch {
if (alive) setEntries([]);
}
})();
return () => { alive = false; };
}, [workflowId]);
// The popover open handler must be declared BEFORE the conditional
// return below; otherwise React sees a different hook-count between
// the "loading" render (returns early) and the "loaded with entries"
// render (calls useCallback), which triggers the "Rendered more hooks
// than during the previous render" crash.
const open = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
setAnchor(e.currentTarget);
if (entries !== null) return;
setLoading(true);
try {
const { API_BASE, getAuthToken } = await import('@/shared/config');
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflowId)}/audit?limit=5`, {
headers: tok ? { Authorization: `Bearer ${tok}` } : {},
});
const data = await res.json();
setEntries(Array.isArray(data?.entries) ? data.entries : []);
} catch {
setEntries([]);
} finally {
setLoading(false);
}
}, [entries, workflowId]);
// Hide entirely until we know whether there are edits to surface.
if (entries === null || entries.length === 0) return null;
const close = () => setAnchor(null);
const count = entries?.length ?? 0;
return (
<>
<Tooltip title="Recent edits to this workflow">
<Box onClick={open} role="button" sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.3,
fontSize: '0.7rem', color: c.text.muted, cursor: 'pointer',
px: 0.5, py: 0.25, borderRadius: 0.75,
'&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated },
}}>
<HistoryIcon sx={{ fontSize: 12 }} />
{entries === null ? 'edits' : `${count} edit${count === 1 ? '' : 's'}`}
</Box>
</Tooltip>
<Popover
open={Boolean(anchor)}
anchorEl={anchor}
onClose={close}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}>
<Box sx={{ minWidth: 280, maxWidth: 360, p: 1 }}>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.5 }}>
RECENT EDITS
</Typography>
{loading && <Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>Loading</Typography>}
{!loading && (entries === null || entries.length === 0) && (
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>No edits yet.</Typography>
)}
{!loading && entries && entries.map((e, idx) => {
const fields = Object.keys(e.diff || {}).filter((k) => k !== 'updated_at');
const summary = fields.length === 0 ? 'no field changes' : fields.slice(0, 3).join(', ') + (fields.length > 3 ? `, +${fields.length - 3} more` : '');
return (
<Box key={idx} sx={{ display: 'flex', flexDirection: 'column', py: 0.5, borderTop: idx === 0 ? 'none' : `1px solid ${c.border.subtle}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary, fontWeight: 600 }}>{e.who || 'user'}</Typography>
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost }}>{relTimeShort(e.ts)}</Typography>
</Box>
<Typography sx={{ fontSize: '0.74rem', color: c.text.secondary }}>{summary}</Typography>
</Box>
);
})}
</Box>
</Popover>
</>
);
}
function relTimeShort(iso: string): string {
try {
const ms = Date.now() - new Date(iso).getTime();
if (ms < 60000) return 'just now';
const m = Math.floor(ms / 60000);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
return `${d}d ago`;
} catch { return ''; }
}
function runDuration(r: WorkflowRun): string | null {
if (!r.finished_at) return null;
try {
const ms = new Date(r.finished_at).getTime() - new Date(r.started_at).getTime();
if (ms <= 0) return null;
return humanDuration(ms);
} catch { return null; }
}
// Groups runs into "This week / Last week / Month YYYY" buckets so a
// long history list reads as eras rather than 50 same-looking dates.
function groupKey(iso: string): string {
try {
const d = new Date(iso);
const now = new Date();
const day = 24 * 3600 * 1000;
const startOfWeek = (x: Date) => { const y = new Date(x); y.setHours(0, 0, 0, 0); y.setDate(y.getDate() - y.getDay()); return y; };
const thisWeekStart = startOfWeek(now).getTime();
const lastWeekStart = thisWeekStart - 7 * day;
if (d.getTime() >= thisWeekStart) return 'This week';
if (d.getTime() >= lastWeekStart) return 'Last week';
return d.toLocaleString('en', { month: 'long', year: 'numeric' });
} catch { return 'Earlier'; }
}
export function HistoryList({ runs, onOpen }: { runs: WorkflowRun[]; onOpen: (r: WorkflowRun) => void }) {
const c = useClaudeTokens();
const [expandedId, setExpandedId] = useState<string | null>(null);
// Filter chips: all / failures / late. Power-users debugging a flaky
// workflow shouldn't have to scroll past successes.
const [filter, setFilter] = useState<'all' | 'failure' | 'ran_late'>('all');
const filtered = useMemo(() => {
if (filter === 'all') return runs;
return (runs || []).filter((r) => r.status === filter);
}, [runs, filter]);
const groups = useMemo(() => {
const out: Array<{ key: string; runs: WorkflowRun[] }> = [];
for (const r of filtered || []) {
const k = groupKey(r.started_at);
const last = out[out.length - 1];
if (last && last.key === k) last.runs.push(r);
else out.push({ key: k, runs: [r] });
}
return out;
}, [filtered]);
// Header sparkline summarising recent successes/failures so users can
// see "lately broken" before scrolling.
const recent = (runs || []).slice(0, 30);
if (!runs || runs.length === 0) {
return <Typography sx={{ fontSize: '0.88rem', color: c.text.muted, py: 1.5, textAlign: 'center' }}>No runs yet</Typography>;
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.75 }}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25 }}>
{recent.map((r) => (
<Box key={r.id} sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: statusColor(r.status, c) }} />
))}
</Box>
<Box sx={{ flex: 1 }} />
{(['all', 'failure', 'ran_late'] as const).map((k) => (
<Box key={k} onClick={() => setFilter(k)} role="button" sx={{
fontSize: '0.72rem', fontWeight: 600,
color: filter === k ? c.accent.primary : c.text.muted,
bgcolor: filter === k ? c.accent.primary + '14' : 'transparent',
border: `1px solid ${filter === k ? c.accent.primary + '40' : c.border.subtle}`,
px: 0.7, py: 0.2, borderRadius: 999, cursor: 'pointer',
'&:hover': { color: c.accent.primary },
}}>
{k === 'all' ? 'All' : k === 'failure' ? 'Failures only' : 'Ran late only'}
</Box>
))}
</Box>
{groups.map(({ key, runs: gRuns }) => (
<Box key={key} sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mt: 0.5, mb: 0.25 }}>
{key.toUpperCase()}
</Typography>
{gRuns.map((r) => {
const expanded = expandedId === r.id;
const dur = runDuration(r);
return (
<Box key={r.id}>
<Box
onClick={() => setExpandedId(expanded ? null : r.id)}
sx={{ display: 'flex', alignItems: 'center', gap: 1.25, py: 0.6, px: 0.5, cursor: 'pointer', borderRadius: 0.75, '&:hover': { bgcolor: c.bg.elevated } }}>
<Box sx={{ fontSize: '0.72rem', fontWeight: 700, color: statusColor(r.status, c), bgcolor: statusBg(r.status, c), px: 0.8, py: 0.3, borderRadius: 0.75, minWidth: 64, textAlign: 'center' }}>
{labelForStatus(r.status)}
</Box>
<Typography sx={{ fontSize: '0.88rem', color: c.text.primary, flex: 1 }}>{formatRunDate(r.started_at)}</Typography>
{dur && <Typography sx={{ fontSize: '0.74rem', color: c.text.ghost }}>{dur}</Typography>}
{r.cost_usd > 0 && <Typography sx={{ fontSize: '0.74rem', color: c.text.ghost }}>${r.cost_usd.toFixed(4)}</Typography>}
{/* Chevron makes the row read as expandable instead of
static text. Rotates 180° while open so the affordance
stays visible after click. */}
<Box sx={{ fontSize: '0.7rem', color: c.text.ghost, transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s ease' }}></Box>
</Box>
{expanded && (
<Box sx={{ ml: 8, mt: 0.25, mb: 0.75, p: 1, bgcolor: c.bg.elevated, borderRadius: 0.75, border: `1px solid ${c.border.subtle}` }}>
{r.error ? (
<Typography sx={{ fontSize: '0.78rem', color: c.status.error, lineHeight: 1.4 }}>{r.error}</Typography>
) : (
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, lineHeight: 1.4 }}>
{r.session_id ? `Saved as session ${r.session_id.slice(0, 8)}.` : 'No session was recorded for this run.'} Click below to see the full conversation.
</Typography>
)}
<Box sx={{ mt: 0.5, display: 'flex', justifyContent: 'flex-end' }}>
<Box onClick={(e) => { e.stopPropagation(); onOpen(r); }} role="button" sx={{ fontSize: '0.74rem', fontWeight: 600, color: c.accent.primary, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}>
See full conversation
</Box>
</Box>
</Box>
)}
</Box>
);
})}
</Box>
))}
</Box>
);
}
export function HistoryDetail({ run, onBack }: { run: WorkflowRun | null; onBack: () => void }) {
const c = useClaudeTokens();
if (!run) return <Typography sx={{ fontSize: '0.88rem', color: c.text.muted }}>Run not found</Typography>;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box onClick={onBack} role="button" sx={{ fontSize: '0.82rem', color: c.text.muted, cursor: 'pointer', '&:hover': { color: c.accent.primary } }}> back</Box>
<Box sx={{ fontSize: '0.72rem', fontWeight: 700, color: statusColor(run.status, c), bgcolor: statusBg(run.status, c), px: 0.8, py: 0.3, borderRadius: 0.75 }}>{labelForStatus(run.status)}</Box>
<Typography sx={{ fontSize: '0.88rem', color: c.text.primary, fontWeight: 600 }}>{formatRunDate(run.started_at)}</Typography>
</Box>
{run.error && (
<Typography sx={{ fontSize: '0.85rem', color: c.status.error, bgcolor: c.status.errorBg, p: 1, borderRadius: 0.75 }}>{run.error}</Typography>
)}
<Typography sx={{ fontSize: '0.85rem', color: c.text.secondary, lineHeight: 1.5 }}>Started {formatRunDate(run.started_at)}, finished {run.finished_at ? formatRunDate(run.finished_at) : 'in progress'}.</Typography>
{run.session_id && (
<Box sx={{ fontSize: '0.82rem', color: c.accent.primary, mt: 0.5 }}>Session: {run.session_id.slice(0, 8)}</Box>
)}
</Box>
);
}
@@ -1,162 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import ArrowBackRounded from '@mui/icons-material/ArrowBackRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch } from '@/shared/hooks';
import { updateWorkflow, updateWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice';
import { validateDraft } from './permissionsUtils';
import { ActionBtn, HINT_FS, LABEL_FS } from './workflowEditCommon';
import GeneralFacet from './GeneralFacet';
import ActionsFacet from './ActionsFacet';
import ScheduleFacet from './ScheduleFacet';
interface Props {
workflow: Workflow;
facet: 'General' | 'Actions' | 'Schedule';
onChangeFacet: (facet: 'General' | 'Actions' | 'Schedule') => void;
// Lifted dirty state so the parent card can decorate the Edit tab with
// an unsaved-changes dot. Optional; older callers don't need to wire it.
onDirtyChange?: (dirty: boolean) => void;
}
export default function WorkflowEditViews({ workflow, facet, onChangeFacet, onDirtyChange }: Props) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const [draft, setDraft] = useState<Workflow>(workflow);
const [busy, setBusy] = useState(false);
const [savedFlash, setSavedFlash] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(workflow), [draft, workflow]);
// Push the dirty flag up so the parent card can decorate the Edit tab.
useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]);
// Clear the parent's flag on unmount so a closed editor doesn't leave
// a stale "you have unsaved changes" dot on the tab.
useEffect(() => () => { onDirtyChange?.(false); }, [onDirtyChange]);
// Save is explicit only. The previous auto-save raced the Save button:
// the user toggled a field, autosave fired 800ms later, dirty went
// false, and a manual Save click became a no-op.
const onSave = useCallback(async () => {
if (busy || !dirty) return;
const reason = validateDraft(draft);
if (reason) {
setSaveError(reason);
return;
}
setSaveError(null);
setBusy(true);
try {
// If-Match: pass the workflow's current updated_at so the backend
// can reject a stale write. Without this, two open windows or a
// mid-edit background fire silently clobber each other.
const result = await dispatch(updateWorkflow({
id: workflow.id,
patch: draft,
ifMatch: workflow.updated_at || null,
}));
if (updateWorkflow.fulfilled.match(result)) {
// Rebase the draft on the server's echoed copy. Without this,
// `dirty` would stay true after Save (because updated_at differs)
// and the user would see a phantom "unsaved" state.
const saved = result.payload as Workflow;
if (saved) setDraft(saved);
setSavedFlash(true);
setTimeout(() => setSavedFlash(false), 1400);
} else if (result.payload?.kind === 'stale') {
setSaveError('This workflow was changed in another window or by a recent run. Discard to reload the latest, then re-apply your edits.');
} else {
setSaveError(result.payload?.message || 'Save failed. Please try again.');
}
} catch (e) {
setSaveError((e as Error)?.message || 'Save failed.');
} finally {
setBusy(false);
}
}, [busy, dirty, dispatch, workflow.id, workflow.updated_at, draft]);
const onDiscard = useCallback(() => {
setDraft(workflow);
setSaveError(null);
}, [workflow]);
// Right-edge save indicator. dirty + busy + savedFlash collapse to a
// single state so the button doesn't flicker between "Save now" and
// "Up to date" mid-keystroke. When idle and clean, show a quiet
// check-mark "Saved" label that's identical to the post-flash state.
const saveState: 'dirty' | 'busy' | 'saved' = busy ? 'busy' : dirty ? 'dirty' : 'saved';
const _flash = savedFlash; void _flash;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
{/* Top control row, target image #67:
"Currently Editing [Select▾]" spacer [Discard] [Save]
Discard + Save are the same pill-style buttons used at the
bottom of SavedView; placing them here gives the user a single
place to commit OR throw away whatever they just edited. */}
{/* Match target image #111: left cluster (label + facet picker)
flush-left, action pills flush-right, generous breathing room
between. Gap inside each cluster stays tight so the two read
as two distinct groups, not five evenly-spaced chips. */}
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'nowrap', minWidth: 0, py: 0.5 }}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
<Box
onClick={() => dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } }))}
role="button"
aria-label="Back"
sx={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 26, height: 26, borderRadius: 999, mr: 0.25,
color: c.text.secondary, cursor: 'pointer',
'&:hover': { color: c.text.primary, bgcolor: c.bg.elevated },
}}>
<ArrowBackRounded sx={{ fontSize: 17 }} />
</Box>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, fontWeight: 500 }}>Currently Editing</Typography>
<Select
size="small"
value={facet}
onChange={(e) => onChangeFacet(e.target.value as Props['facet'])}
sx={{ fontSize: LABEL_FS, minWidth: 110, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="General">General</MenuItem>
<MenuItem value="Actions">Actions</MenuItem>
<MenuItem value="Schedule">Schedule</MenuItem>
</Select>
</Box>
<Box sx={{ flex: 1, minWidth: 24 }} />
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
<ActionBtn
label="Discard"
tone="danger"
icon="trash"
disabled={!dirty || busy}
onClick={onDiscard}
/>
<Box sx={{ display: 'inline-flex', minWidth: 80, justifyContent: 'center' }}>
<ActionBtn
label={busy ? 'Saving…' : 'Save'}
tone="success"
icon="check"
disabled={!dirty || busy || saveState === 'saved'}
onClick={onSave}
/>
</Box>
</Box>
</Box>
{saveError && (
<Typography sx={{ fontSize: HINT_FS, color: c.status.error, bgcolor: c.status.errorBg, px: 1, py: 0.5, borderRadius: `${c.radius.md}px` }}>
{saveError}
</Typography>
)}
{facet === 'General' && <GeneralFacet draft={draft} setDraft={setDraft} />}
{facet === 'Actions' && <ActionsFacet draft={draft} setDraft={setDraft} />}
{facet === 'Schedule' && <ScheduleFacet draft={draft} setDraft={setDraft} />}
</Box>
);
}
@@ -1,631 +0,0 @@
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 CallSplitRoundedIcon from '@mui/icons-material/CallSplitRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
addWorkflowCard,
closeWorkflowsHub,
setWorkflowsHubPosition,
setWorkflowsHubSize,
} from '@/shared/state/dashboardLayoutSlice';
import { openWorkflowCard, fetchPausedState, setPausedAll, updateWorkflow, deleteWorkflow, runWorkflowNow } from '@/shared/state/workflowsSlice';
import type { Workflow } from '@/shared/state/workflowsSlice';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Switch from '@mui/material/Switch';
import Tooltip from '@mui/material/Tooltip';
import { useEffect } from 'react';
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<ResizeDir, string> = {
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<string, any> }[] = [
{ 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';
// Small badge in the hub header that adds up successful scheduled runs
// across all workflows and renders an approximate "time saved" figure.
// Heuristic: 3 minutes saved per scheduled run that the user would have
// otherwise done by hand. Not precise — meant as a quiet "you got back
// X hours" affirmation, not an audit number.
function TimeSavedBadge() {
const c = useClaudeTokens();
const runsByWorkflow = useAppSelector((s) => s.workflows.runs);
const items = useAppSelector((s) => s.workflows.items);
let count = 0;
for (const arr of Object.values(runsByWorkflow)) {
for (const r of arr) {
if (r.triggered_by === 'schedule' && (r.status === 'success' || r.status === 'ran_late')) count += 1;
}
}
// Fallback: if no runs are loaded yet (cards never opened), use
// last_run_status as a coarse proxy so brand-new users don't see 0.
if (count === 0) {
for (const w of Object.values(items)) {
if (w.last_run_status === 'success' || w.last_run_status === 'ran_late') count += 1;
}
}
if (count === 0) return null;
const totalMin = count * 3;
const hours = totalMin / 60;
// Show "X done · ~Y hrs" so the user gets both the run count and a
// sense of time. Dot-separator reads quieter than the old green pill.
const timeLabel = hours >= 1 ? `~${hours.toFixed(1)} hrs` : `~${totalMin} min`;
return (
<Tooltip title={`${count} workflow runs completed for you. Rough estimate of ~3 min saved per run vs. doing it by hand.`}>
<Box sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
ml: 1, px: 0.85, py: 0.2,
fontSize: '0.74rem', fontWeight: 600,
color: c.text.secondary,
bgcolor: 'transparent',
border: `1px solid ${c.border.subtle}`,
borderRadius: 999,
}}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 14, height: 14, borderRadius: '50%', bgcolor: (c.status.success || c.accent.primary) + '22', color: c.status.success || c.accent.primary, fontSize: 9, fontWeight: 800 }}></Box>
<span style={{ color: c.text.primary }}>{count}</span>
<span style={{ color: c.text.muted }}>·</span>
<span style={{ color: c.text.secondary }}>{timeLabel} back</span>
</Box>
</Tooltip>
);
}
const WorkflowsHubCard: React.FC<Props> = ({
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 paused = useAppSelector((s) => s.workflows.paused);
useEffect(() => { dispatch(fetchPausedState()); }, [dispatch]);
const togglePaused = useCallback(() => {
dispatch(setPausedAll(!paused));
}, [dispatch, paused]);
const [view, setView] = useState<CalendarView>('Week');
const [viewOpen, setViewOpen] = useState(false);
const [refDate, setRefDate] = useState(new Date());
const [search, setSearch] = useState('');
const [sidebarOpen, setSidebarOpen] = useState(true);
// Right-click on a sidebar row opens this menu pinned to the cursor.
// Mirrors the calendar pill context menu so the two surfaces feel
// consistent. closeMenu wipes both state + DOM-focus.
const [sidebarCtxMenu, setSidebarCtxMenu] = useState<{ x: number; y: number; workflow: Workflow } | null>(null);
const closeSidebarCtxMenu = useCallback(() => setSidebarCtxMenu(null), []);
// "Scheduled" = the workflow has a real cadence configured at any
// point (even if currently paused via the checkbox). Filtering by
// `enabled` would yank rows out from under the user the moment they
// unticked the box, which feels wrong. on_days/hour/minute being set
// is a good proxy for "user already configured this." Falls back to
// enabled flag for legacy records.
const scheduled = useMemo(() => Object.values(workflows).filter((w) => isSchedulable(w)), [workflows]);
const unscheduled = useMemo(() => Object.values(workflows).filter((w) => !isSchedulable(w)), [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 (
<Box
data-select-type="workflows-hub-card"
sx={{
position: 'absolute',
contain: 'layout style',
willChange: 'transform',
left: dx,
top: dy,
width: dw,
height: dh,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
borderRadius: `${c.radius.lg}px`,
boxShadow: (isDragging || isResizing) ? c.shadow.lg : c.shadow.md,
display: 'flex',
flexDirection: 'column',
zIndex: (isDragging || isResizing) ? 999999 : cardZOrder,
transition: (isDragging || isResizing) ? 'none' : 'box-shadow 0.3s ease',
'&:hover .resize-handle': { opacity: 1 },
}}
>
{/* ===== Title strip (drag handle) ===== */}
<Box
onPointerDown={onHeaderPointerDown}
onPointerMove={onHeaderPointerMove}
onPointerUp={onHeaderPointerUp}
sx={{
display: 'flex', alignItems: 'center', gap: 0.6,
px: 1.5, py: 0.6,
borderBottom: `1px solid ${c.border.subtle}`,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none', userSelect: 'none',
flexShrink: 0,
}}
>
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 18, height: 18, color: c.accent.primary }}>
{/* CallSplit natively forks upward; rotated 90deg the fork
points right, matching the Workflows brand mark. */}
<CallSplitRoundedIcon sx={{ fontSize: 16, transform: 'rotate(90deg)' }} />
</Box>
<Typography sx={{ flex: 1, fontWeight: 700, fontSize: '0.88rem', color: c.text.primary }}>Workflows</Typography>
<IconButton
size="small"
data-no-drag
onClick={(e) => { 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 } }}
>
<CloseIcon sx={{ fontSize: 15 }} />
</IconButton>
</Box>
{/* ===== Toolbar row (matches Figma image #8 header) ===== */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.65, px: 1.5, py: 0.7, borderBottom: `1px solid ${c.border.subtle}`, flexShrink: 0 }}>
<Tooltip title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}>
<IconButton size="small" data-no-drag onClick={() => setSidebarOpen((v) => !v)} sx={{ p: 0.5, color: sidebarOpen ? c.text.secondary : c.text.muted, '&:hover': { color: c.text.primary } }}>
<MenuIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Box
onClick={onNew}
role="button"
data-no-drag
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
fontSize: '0.85rem', fontWeight: 600, color: c.text.primary,
bgcolor: c.bg.elevated, border: `1px solid ${c.border.subtle}`,
px: 1, py: 0.4, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
'&:hover': { borderColor: c.accent.primary, color: c.accent.primary },
}}
>
<AddIcon sx={{ fontSize: 14 }} />
New
</Box>
<Tooltip title={paused ? 'Scheduled runs are paused. In-flight runs will finish; new fires are blocked until you resume.' : 'Stop all future scheduled runs without disabling them one-by-one. Any run already in flight will finish.'}>
<Box
onClick={togglePaused}
role="button"
data-no-drag
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4, ml: 0.5,
fontSize: '0.8rem', fontWeight: 600,
color: paused ? c.status.warning || c.accent.primary : c.text.secondary,
bgcolor: paused ? (c.status.warningBg || c.bg.elevated) : 'transparent',
border: `1px solid ${paused ? (c.status.warning || c.accent.primary) + '60' : c.border.subtle}`,
px: 0.85, py: 0.3, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
'&:hover': { color: c.text.primary, borderColor: c.border.medium },
}}>
<Switch size="small" checked={paused} sx={{ pointerEvents: 'none', mr: -0.5, ml: -0.5 }} />
<span>{paused ? 'Paused' : 'Pause all'}</span>
</Box>
</Tooltip>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.75 }}>
<Box
onClick={() => 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</Box>
<IconButton size="small" data-no-drag onClick={() => setRefDate(addDays(refDate, view === 'Month' ? -28 : -7))} sx={{ p: 0.3 }}><ChevronLeftIcon sx={{ fontSize: 18 }} /></IconButton>
<IconButton size="small" data-no-drag onClick={() => setRefDate(addDays(refDate, view === 'Month' ? 28 : 7))} sx={{ p: 0.3 }}><ChevronRightIcon sx={{ fontSize: 18 }} /></IconButton>
<Typography sx={{ fontSize: '0.92rem', fontWeight: 600, color: c.text.primary }}>{monthLabel}</Typography>
<TimeSavedBadge />
</Box>
<IconButton size="small" data-no-drag sx={{ p: 0.5, color: c.text.muted }}>
<SearchIcon sx={{ fontSize: 18 }} />
</IconButton>
<Box sx={{ position: 'relative' }}>
<Box
onClick={() => 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}
<KeyboardArrowDownIcon sx={{ fontSize: 16 }} />
</Box>
{viewOpen && (
<Box sx={{ position: 'absolute', top: '100%', right: 0, mt: 0.5, bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, boxShadow: c.shadow.md, zIndex: 5, minWidth: 110 }}>
{(['Week', 'Month', 'List'] as const).map((v) => (
<Box
key={v}
data-no-drag
onClick={() => { 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}
</Box>
))}
</Box>
)}
</Box>
</Box>
{/* ===== Body: sidebar + main calendar ===== */}
<Box sx={{ flex: 1, display: 'flex', minHeight: 0 }}>
{/* Sidebar */}
{sidebarOpen && (
<Box sx={{ width: 240, flexShrink: 0, borderRight: `1px solid ${c.border.subtle}`, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ px: 1.5, pt: 1.25, pb: 0.75 }}>
<InputBase
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search workflows"
startAdornment={<SearchIcon sx={{ fontSize: 16, color: c.text.muted, mr: 0.75 }} />}
sx={{ fontSize: '0.82rem', color: c.text.primary, width: '100%', '& input::placeholder': { color: c.text.ghost, opacity: 1 } }}
/>
</Box>
<MiniMonth refDate={refDate} onPick={setRefDate} />
<Box sx={{ flex: 1, overflowY: 'auto', px: 1.5, pb: 1.5 }}>
<SidebarSection title="Scheduled workflows" items={scheduled.filter((w) => match(w.title, search))} onPick={onSelectWorkflow} scheduled onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} />
<SidebarSection title="Un-scheduled workflows" items={unscheduled.filter((w) => match(w.title, search))} onPick={onSelectWorkflow} scheduled={false} onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} />
</Box>
</Box>
)}
{/* Main calendar area */}
<Box sx={{ flex: 1, minWidth: 0, overflow: 'auto', p: 1.5 }}>
<ScheduleCalendar view={view} density="roomy" onSelectWorkflow={onSelectWorkflow} refDate={refDate} />
</Box>
</Box>
{/* Right-click menu shared across all sidebar workflow rows */}
<Menu
open={Boolean(sidebarCtxMenu)}
onClose={closeSidebarCtxMenu}
anchorReference="anchorPosition"
anchorPosition={sidebarCtxMenu ? { top: sidebarCtxMenu.y, left: sidebarCtxMenu.x } : undefined}>
<MenuItem onClick={() => {
if (!sidebarCtxMenu) return;
dispatch(runWorkflowNow(sidebarCtxMenu.workflow.id));
closeSidebarCtxMenu();
}}>Run now</MenuItem>
<MenuItem onClick={() => {
if (!sidebarCtxMenu) return;
const wf = sidebarCtxMenu.workflow;
dispatch(updateWorkflow({
id: wf.id,
patch: { schedule: { ...wf.schedule, enabled: !wf.schedule.enabled } as any },
ifMatch: wf.updated_at || null,
}));
closeSidebarCtxMenu();
}}>{sidebarCtxMenu?.workflow.schedule.enabled ? 'Pause schedule' : 'Resume schedule'}</MenuItem>
<MenuItem onClick={() => {
if (!sidebarCtxMenu) return;
dispatch(addWorkflowCard({ workflowId: sidebarCtxMenu.workflow.id }));
dispatch(openWorkflowCard({ workflowId: sidebarCtxMenu.workflow.id, view: 'edit_agent' }));
closeSidebarCtxMenu();
}}>Edit</MenuItem>
<MenuItem
onClick={() => {
if (!sidebarCtxMenu) return;
const ok = window.confirm(`Delete "${sidebarCtxMenu.workflow.title}"? Scheduled runs will stop.`);
if (ok) dispatch(deleteWorkflow(sidebarCtxMenu.workflow.id));
closeSidebarCtxMenu();
}}
sx={{ color: c.status.error }}>
Delete
</MenuItem>
</Menu>
{/* Resize handles */}
{HANDLE_DEFS.map(({ dir, sx }) => (
<Box
key={dir}
className="resize-handle"
onPointerDown={onResizeDown(dir)}
onPointerMove={onResizeMove}
onPointerUp={onResizeUp}
sx={{ position: 'absolute', cursor: CURSOR_MAP[dir], opacity: 0, zIndex: 25, ...sx }}
/>
))}
</Box>
);
};
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 (
<Box sx={{ px: 1.5, pb: 1, borderBottom: `1px solid ${c.border.subtle}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', py: 0.5 }}>
<Typography sx={{ flex: 1, fontSize: '0.82rem', fontWeight: 700, color: c.text.primary }}>{label}</Typography>
<IconButton size="small" data-no-drag onClick={() => onPick(addMonths(refDate, -1))} sx={{ p: 0.15 }}><ChevronLeftIcon sx={{ fontSize: 14 }} /></IconButton>
<IconButton size="small" data-no-drag onClick={() => onPick(addMonths(refDate, 1))} sx={{ p: 0.15 }}><ChevronRightIcon sx={{ fontSize: 14 }} /></IconButton>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
{WEEKDAY_LABEL.map((l, i) => (
<Typography key={`${l}-${i}`} sx={{ textAlign: 'center', fontSize: '0.66rem', color: c.text.muted, fontWeight: 600, py: 0.2 }}>{l}</Typography>
))}
{cells.map((d) => {
const isToday = sameDay(d, today);
const inMonth = d.getMonth() === refDate.getMonth();
const selected = sameDay(d, refDate);
return (
<Box key={d.toISOString()} onClick={() => onPick(d)} data-no-drag sx={{ textAlign: 'center', py: 0.2, opacity: inMonth ? 1 : 0.4, cursor: 'pointer' }}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 22, height: 22, borderRadius: '50%', bgcolor: isToday ? c.accent.primary : selected ? c.accent.primary + '30' : 'transparent', color: isToday ? '#fff' : c.text.secondary, fontWeight: isToday ? 700 : 500, fontSize: '0.72rem' }}>{d.getDate()}</Box>
</Box>
);
})}
</Box>
</Box>
);
}
function SidebarSection({ title, items, onPick, scheduled, onContext }: {
title: string;
items: Workflow[];
onPick: (id: string) => void;
scheduled: boolean;
onContext: (workflow: Workflow, e: React.MouseEvent) => void;
}) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const [open, setOpen] = useState(true);
const toggleEnabled = useCallback((wf: Workflow, e: React.MouseEvent) => {
e.stopPropagation();
dispatch(updateWorkflow({
id: wf.id,
patch: { schedule: { ...wf.schedule, enabled: !wf.schedule.enabled } as any },
ifMatch: wf.updated_at || null,
}));
}, [dispatch]);
return (
<Box sx={{ mt: 1.5 }}>
<Box
onClick={() => 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 } }}>
<Typography sx={{ flex: 1, fontSize: '0.78rem', fontWeight: 700, color: c.text.secondary }}>{title}</Typography>
<KeyboardArrowDownIcon className="section-chev" sx={{ fontSize: 14, color: c.text.muted, transform: open ? 'rotate(0deg)' : 'rotate(-90deg)', transition: 'transform 0.15s ease' }} />
</Box>
{open && items.length === 0 && (
<Typography sx={{ fontSize: '0.76rem', color: c.text.muted, fontStyle: 'italic', py: 0.5, pl: 0.5 }}>None yet</Typography>
)}
{open && items.map((w) => (
<Box
key={w.id}
onClick={() => onPick(w.id)}
onContextMenu={(e) => { e.preventDefault(); onContext(w, e); }}
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 ? (
<Tooltip title={w.schedule.enabled ? 'Pause this schedule' : 'Resume this schedule'}>
<Box
onClick={(e) => toggleEnabled(w, e)}
sx={{
width: 14, height: 14, borderRadius: '3px', flexShrink: 0,
border: `1.5px solid ${w.schedule.enabled ? c.accent.primary : c.border.medium}`,
bgcolor: w.schedule.enabled ? c.accent.primary : 'transparent',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 10, lineHeight: 1, fontWeight: 700,
cursor: 'pointer',
'&:hover': { borderColor: c.accent.primary },
}}>
{w.schedule.enabled ? '✓' : ''}
</Box>
</Tooltip>
) : (
<AddIcon sx={{ fontSize: 13, color: c.text.muted, flexShrink: 0 }} />
)}
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textDecoration: scheduled && !w.schedule.enabled ? 'line-through' : 'none', opacity: scheduled && !w.schedule.enabled ? 0.6 : 1 }}>{w.title}</Typography>
</Box>
))}
</Box>
);
}
function isSchedulable(w: Workflow): boolean {
if (w.schedule.enabled) return true;
// Heuristic: any prior config means the user already opened the
// Schedule facet and committed something. Pure defaults stay in
// "Un-scheduled" so brand-new workflows don't pollute the list.
const s = w.schedule;
return Boolean(s.on_days?.length || s.ends_at || s.max_runs || s.runs_count);
}
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);
@@ -1,31 +0,0 @@
import type { Workflow, PermissionTier } from '@/shared/state/workflowsSlice';
// Pre-save validation. Returns the first user-visible reason save should
// be blocked, or null when the draft is good to ship. Phone numbers on
// text/call tiers must be non-empty and at least 7 digits so the eventual
// SMS/voice bridge has something usable to dial.
export 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;
}
// Walk the existing permissions list and produce the next tier in the
// chain (notify -> text -> call). Returns null if we're already at call,
// which the UI uses to hide the "+ add backup" affordance.
export function nextTierAfter(tiers: PermissionTier[]): PermissionTier | null {
const last = tiers.length ? tiers[tiers.length - 1].kind : 'notify';
if (last === 'notify') return { kind: 'text', after_minutes: 5, phone: '' };
if (last === 'text') return { kind: 'call', after_minutes: 60, phone: '' };
return null;
}
@@ -1,103 +0,0 @@
// Lightweight text-to-schedule detector. Runs on agent replies (and user
// prompts) to surface a "Schedule this?" chip when the conversation has
// time-shaped language. Cheap regex pass, no LLM call. Returns the best
// matching preset or null. Conservative on purpose: a false positive
// shows a quietly-dismissable chip; a false negative just means the user
// uses the regular Schedule button.
import type { ScheduleConfig } from '@/shared/state/workflowsSlice';
import { defaultSchedule } from './scheduleUtils';
export interface DetectedSchedule {
schedule: ScheduleConfig;
presetLabel: string;
}
const HOUR_WORDS: Record<string, number> = {
morning: 9, noon: 12, afternoon: 14, evening: 18, night: 21, midnight: 0,
};
// Match "9am" / "9 a.m." / "10:30 PM" / "at 7am" — but ONLY when there's
// either an explicit am/pm suffix or an "at " prefix. Plain digits with
// no time context ("3 new messages", "May 16", "$50 offer") used to slip
// through and we'd misread them as the schedule hour. Anchoring on
// `(am|pm)` OR `at ` blocks that.
const HOUR_RE = /\b(?:at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.|p\.m\.)?|(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.|p\.m\.))\b/i;
const DAY_RE = /\b(sun|mon|tue|wed|thu|fri|sat)(?:day)?s?\b/gi;
const DAY_MAP: Record<string, number> = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
export function detectSchedule(text: string): DetectedSchedule | null {
if (!text) return null;
const t = text.toLowerCase();
// Require either an explicit frequency keyword or a clear weekday +
// time pattern. Avoids false positives on stray "tomorrow at 9."
const isDaily = /\b(every ?day|each day|daily)\b/.test(t);
const isWeekdays = /\b(weekdays?|each weekday|every weekday|mon(?:day)?\s*(?:to|-|through|)\s*fri(?:day)?)\b/.test(t);
const isWeekly = /\b(every week|weekly|each week|once a week)\b/.test(t);
const isMonthly = /\b(every month|monthly|each month|once a month)\b/.test(t);
const dayMatches = Array.from(t.matchAll(DAY_RE)).map((m) => DAY_MAP[m[1].toLowerCase().slice(0, 3)]);
const hasExplicitDays = dayMatches.length > 0;
if (!isDaily && !isWeekdays && !isWeekly && !isMonthly && !hasExplicitDays) return null;
// Extract hour:minute.
let hour = 9;
let minute = 0;
let presetTimeWord: string | null = null;
for (const word of Object.keys(HOUR_WORDS)) {
if (t.includes(word)) { hour = HOUR_WORDS[word]; presetTimeWord = word; break; }
}
const hm = t.match(HOUR_RE);
if (hm) {
// The two branches of HOUR_RE give us hour/minute/ampm in either
// capture group 1-3 (the "at H" branch) or 4-6 (the "Ham/pm" branch).
const rawStr = hm[1] || hm[4];
const minStr = hm[2] || hm[5];
const ampm = (hm[3] || hm[6] || '').toLowerCase();
const raw = rawStr ? parseInt(rawStr, 10) : NaN;
const m = minStr ? parseInt(minStr, 10) : 0;
let h = raw;
if (ampm.startsWith('p') && h < 12) h += 12;
if (ampm.startsWith('a') && h === 12) h = 0;
if (Number.isFinite(h) && h >= 0 && h < 24) {
if (!presetTimeWord) { hour = h; minute = m; }
}
}
const base = defaultSchedule();
if (isMonthly) {
return {
schedule: { ...base, enabled: true, repeat_unit: 'month', repeat_every: 1, hour, minute },
presetLabel: `Every month at ${formatHour(hour, minute)}`,
};
}
if (isWeekdays) {
return {
schedule: { ...base, enabled: true, repeat_unit: 'week', repeat_every: 1, on_days: [1, 2, 3, 4, 5], hour, minute },
presetLabel: `Weekdays at ${formatHour(hour, minute)}`,
};
}
if (isDaily) {
return {
schedule: { ...base, enabled: true, repeat_unit: 'day', repeat_every: 1, hour, minute },
presetLabel: `Every day at ${formatHour(hour, minute)}`,
};
}
if (hasExplicitDays || isWeekly) {
const days = Array.from(new Set(dayMatches.length ? dayMatches : [new Date().getDay()]));
days.sort();
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const label = days.length === 1 ? `Every ${dayNames[days[0]]} at ${formatHour(hour, minute)}` : `${days.map((d) => dayNames[d]).join('/')} at ${formatHour(hour, minute)}`;
return {
schedule: { ...base, enabled: true, repeat_unit: 'week', repeat_every: 1, on_days: days, hour, minute },
presetLabel: label,
};
}
return null;
}
function formatHour(h: number, m: number): string {
const suffix = h < 12 ? 'am' : 'pm';
const h12 = ((h + 11) % 12) + 1;
return m === 0 ? `${h12}${suffix}` : `${h12}:${String(m).padStart(2, '0')}${suffix}`;
}
@@ -1,171 +0,0 @@
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 {
// Pick the host's IANA tz so new schedules start with an explicit zone
// instead of the legacy "local" sentinel. Backend storage still coerces
// "local" if a record predates this default; new records skip that path.
let tz = 'local';
try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'local'; } catch { /* keep 'local' */ }
return {
enabled: false,
repeat_every: 1,
repeat_unit: 'week',
on_days: [],
hour: 9,
minute: 0,
timezone: tz,
on_missed: 'skip',
ends_at: null,
max_runs: null,
runs_count: 0,
};
}
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;
}
function lastDayOfMonth(year: number, monthZeroBased: number): number {
// Date(year, month, 0) returns the last day of the previous month, so
// passing month+1 gives the last day of `monthZeroBased`. Matches the
// backend's calendar.monthrange behavior so the FE preview no longer
// clamps to day 28 (the old shared bug between this and previewNextRun).
return new Date(year, monthZeroBased + 1, 0).getDate();
}
export function fireTimesWithin(workflow: Workflow, from: Date, to: Date, cap = 40): Date[] {
const sched = workflow.schedule;
if (!sched.enabled) return [];
// Honor end conditions on the FE preview too, so the calendar doesn't
// paint pills for fires the backend will refuse to run. ends_at is an
// ISO string in workflow state; max_runs/runs_count are numbers.
if (sched.ends_at) {
const endsAt = new Date(sched.ends_at);
if (!Number.isNaN(endsAt.getTime()) && endsAt.getTime() <= from.getTime()) return [];
if (!Number.isNaN(endsAt.getTime()) && endsAt.getTime() < to.getTime()) to = endsAt;
}
// Don't paint fires for days that predate the workflow itself. A
// workflow created this Wednesday shouldn't show pills on Sun/Mon/Tue
// of the same week. created_at is an ISO string; only floor on success.
if (workflow.created_at) {
const createdAt = new Date(workflow.created_at);
if (!Number.isNaN(createdAt.getTime()) && createdAt.getTime() > from.getTime()) {
from = createdAt;
}
}
if (sched.max_runs != null && sched.runs_count >= sched.max_runs) return [];
const remainingRuns = sched.max_runs != null ? Math.max(0, sched.max_runs - sched.runs_count) : Infinity;
const effectiveCap = Math.min(cap, remainingRuns);
if (effectiveCap === 0) 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 < effectiveCap; 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') {
const startDay = from.getDate();
let year = from.getFullYear();
let month = from.getMonth();
let guard = 0;
while (out.length < effectiveCap && guard < 60) {
const day = Math.min(startDay, lastDayOfMonth(year, month));
const d = new Date(year, month, day, sched.hour, sched.minute, 0, 0);
if (d > to) break;
if (d >= from) out.push(d);
month += Math.max(1, sched.repeat_every);
year += Math.floor(month / 12);
month = ((month % 12) + 12) % 12;
guard += 1;
}
return out;
}
const allowed = sched.on_days.length ? sched.on_days : [from.getDay()];
for (let i = 0; i < 60 && out.length < effectiveCap; 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;
}
@@ -1,52 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import CheckIcon from '@mui/icons-material/Check';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
export const BODY_FS = '0.88rem';
export const LABEL_FS = '0.82rem';
export const HINT_FS = '0.78rem';
export const INPUT_FS = '0.88rem';
export function FieldRow({ label, children, align }: { label: string; children: React.ReactNode; align?: 'top' | 'center' }) {
const c = useClaudeTokens();
return (
<Box sx={{ display: 'flex', alignItems: align === 'top' ? 'flex-start' : 'center', gap: 1 }}>
<Typography sx={{ width: 100, flexShrink: 0, fontSize: LABEL_FS, color: c.text.secondary, mt: align === 'top' ? 0.75 : 0, fontWeight: 500 }}>{label}:</Typography>
{children}
</Box>
);
}
type ActionBtnTone = 'muted' | 'success' | 'danger';
export function ActionBtn({ label, tone, disabled, onClick, icon }: { label: string; tone: ActionBtnTone; disabled?: boolean; onClick: () => void; icon?: 'trash' | 'check' }) {
const c = useClaudeTokens();
const palette = tone === 'success'
? { color: c.status.success, bg: c.status.successBg, border: c.status.success + '60', hover: c.status.success + '30' }
: tone === 'danger'
? { color: c.status.error, bg: c.status.errorBg, border: c.status.error + '60', hover: c.status.error + '30' }
: { color: c.text.secondary, bg: c.bg.secondary, border: c.border.subtle, hover: c.bg.elevated };
return (
<Box
onClick={disabled ? undefined : onClick}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.45,
fontSize: LABEL_FS, fontWeight: 600, px: 1.25, py: 0.5,
borderRadius: 999,
cursor: disabled ? 'not-allowed' : 'pointer',
color: palette.color,
bgcolor: palette.bg,
border: `1px solid ${palette.border}`,
opacity: disabled ? 0.5 : 1,
'&:hover': { bgcolor: palette.hover },
}}>
{icon === 'trash' && <DeleteOutlineIcon sx={{ fontSize: 15 }} />}
{icon === 'check' && <CheckIcon sx={{ fontSize: 15 }} />}
{label}
</Box>
);
}
@@ -1,503 +0,0 @@
// Shared visual helpers for the workflow card UI tier: schedule/permission
// pill chips, status dot, run-status sparkline, step connector, step icon
// auto-classifier. Kept as plain functions/components so individual views
// can compose without owning the styling.
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import Popover from '@mui/material/Popover';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import { useAppDispatch } from '@/shared/hooks';
import { updateWorkflow } from '@/shared/state/workflowsSlice';
import ScheduleIcon from '@mui/icons-material/ScheduleRounded';
import NotificationsIcon from '@mui/icons-material/NotificationsRounded';
import SmsIcon from '@mui/icons-material/SmsRounded';
import PhoneInTalkIcon from '@mui/icons-material/PhoneInTalkRounded';
import EmailIcon from '@mui/icons-material/MailOutlineRounded';
import EventNoteIcon from '@mui/icons-material/EventNoteRounded';
import ChromeReaderModeIcon from '@mui/icons-material/ChromeReaderModeRounded';
import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
import CalendarTodayIcon from '@mui/icons-material/CalendarTodayRounded';
import ArticleIcon from '@mui/icons-material/ArticleRounded';
import LanguageIcon from '@mui/icons-material/LanguageRounded';
import AttachMoneyIcon from '@mui/icons-material/AttachMoneyRounded';
import AllInclusiveIcon from '@mui/icons-material/AllInclusiveRounded';
import CodeIcon from '@mui/icons-material/CodeRounded';
import SearchIcon from '@mui/icons-material/SearchRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { Workflow, WorkflowRun, ScheduleConfig, PermissionTier } from '@/shared/state/workflowsSlice';
import { formatTime, WEEKDAY_LABEL } from './scheduleUtils';
// ---------- Status colors ----------
export type LastRunStatus = NonNullable<Workflow['last_run_status']>;
export function statusDotColor(status: LastRunStatus | null | undefined, c: ReturnType<typeof useClaudeTokens>) {
switch (status) {
case 'success': return c.status.success;
case 'ran_late': return c.status.warning || '#f59e0b';
case 'failure': return c.status.error;
case 'running': return c.accent.primary;
case 'skipped': return c.text.muted;
default: return c.text.ghost;
}
}
// Human-readable status word. We surface "ran late" instead of the
// underscore-y "ran_late" everywhere it'd be visible to a user.
export function statusWord(status: LastRunStatus | null | undefined): string {
if (!status) return 'Never run';
if (status === 'ran_late') return 'Ran late';
return status.charAt(0).toUpperCase() + status.slice(1);
}
// Status pill rendered next to the title. Bigger than the previous 9px
// dot and pairs the color with a short word so a non-dev knows what
// they're looking at instead of squinting at a single grey pixel.
export function StatusDot({ status }: { status: LastRunStatus | null | undefined }) {
const c = useClaudeTokens();
const word = statusWord(status);
const dotColor = statusDotColor(status, c);
return (
<Tooltip title={status ? `Last run: ${word.toLowerCase()}` : 'This workflow has never run.'}>
<Box sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
height: 18, px: 0.6, borderRadius: 999,
bgcolor: status === 'failure' ? c.status.errorBg : status === 'ran_late' ? c.status.warningBg : status === 'success' ? c.status.successBg : c.bg.elevated,
border: `1px solid ${dotColor}55`,
flexShrink: 0,
}}>
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: dotColor, boxShadow: status === 'failure' ? `0 0 4px ${c.status.error}` : 'none' }} />
<Typography sx={{ fontSize: '0.66rem', fontWeight: 700, color: dotColor, letterSpacing: '0.02em' }}>
{word}
</Typography>
</Box>
</Tooltip>
);
}
// ---------- Pill chips ----------
function scheduleShort(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 ? `Daily ${time}` : `Every ${sched.repeat_every}d ${time}`;
}
if (sched.repeat_unit === 'month') {
return sched.repeat_every === 1 ? `Monthly ${time}` : `Every ${sched.repeat_every}mo ${time}`;
}
if (sched.on_days.length === 5 && [1, 2, 3, 4, 5].every((d) => sched.on_days.includes(d))) return `Weekdays ${time}`;
if (sched.on_days.length === 2 && [0, 6].every((d) => sched.on_days.includes(d))) return `Weekends ${time}`;
if (sched.on_days.length === 1) {
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return `${labels[sched.on_days[0]]} ${time}`;
}
if (sched.on_days.length === 0) return `Weekly ${time}`;
return `${sched.on_days.length}×/wk ${time}`;
}
// Weekday-dot strip "S M T W T F S" with active days filled. Rendered
// inline next to the chip when the schedule is weekly so users can
// pattern-match days without parsing prose. Active = filled accent dot.
export function WeekdayDots({ on_days }: { on_days: number[] }) {
const c = useClaudeTokens();
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35, ml: 0.5 }}>
{WEEKDAY_LABEL.map((lbl, idx) => {
const active = on_days.includes(idx);
return (
<Box key={`${lbl}-${idx}`} sx={{
width: 12, height: 12, borderRadius: '50%',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.6rem', fontWeight: 700,
color: active ? '#fff' : c.text.ghost,
bgcolor: active ? c.accent.primary : 'transparent',
border: `1px solid ${active ? c.accent.primary : c.border.subtle}`,
lineHeight: 1,
}}>
{lbl}
</Box>
);
})}
</Box>
);
}
function permIcon(kind: PermissionTier['kind'], size = 13) {
if (kind === 'text') return <SmsIcon sx={{ fontSize: size }} />;
if (kind === 'call') return <PhoneInTalkIcon sx={{ fontSize: size }} />;
return <NotificationsIcon sx={{ fontSize: size }} />;
}
// Compact "🔔 → 💬 → 📞" representation of the escalation chain. Hover
// shows the literal prose (notify, text, call, with delays).
export function PermissionChip({ workflow }: { workflow: Workflow }) {
const c = useClaudeTokens();
const tiers = workflow.permissions || [];
if (tiers.length === 0) return null;
const label = tiers.map((t) => {
if (t.kind === 'notify') return 'notify in app';
const unit = t.kind === 'call' ? 'h' : 'm';
return `${t.kind} after ${t.after_minutes}${unit}`;
}).join(' → ');
return (
<Tooltip title={label}>
<Box sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.35,
fontSize: '0.74rem', fontWeight: 500,
color: c.text.secondary,
bgcolor: c.bg.elevated,
border: `1px solid ${c.border.subtle}`,
px: 0.85, py: 0.3, borderRadius: 999,
}}>
{tiers.map((t, i) => (
<React.Fragment key={i}>
{permIcon(t.kind)}
{i < tiers.length - 1 && <Box sx={{ fontSize: '0.7rem', color: c.text.ghost, mx: 0.1 }}></Box>}
</React.Fragment>
))}
</Box>
</Tooltip>
);
}
export function ScheduleChip({ workflow }: { workflow: Workflow }) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const enabled = workflow.schedule.enabled;
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
// Inline edit: time + AM/PM only. Anything richer should open the
// full editor. Saves on change with optimistic updated_at If-Match.
const sched = workflow.schedule;
const patchSched = (patch: Partial<typeof sched>) => {
const next = { ...sched, ...patch };
dispatch(updateWorkflow({
id: workflow.id,
patch: { schedule: next as any },
ifMatch: workflow.updated_at || null,
}));
};
return (
<>
<Tooltip title={enabled ? `Click to tweak time. Full editor lives in the Edit tab.` : 'Not scheduled'}>
<Box
onClick={(e) => enabled && setAnchor(e.currentTarget as HTMLElement)}
role={enabled ? 'button' : undefined}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
fontSize: '0.74rem', fontWeight: 600,
color: enabled ? c.accent.primary : c.text.muted,
bgcolor: enabled ? c.accent.primary + '14' : c.bg.elevated,
border: `1px solid ${enabled ? c.accent.primary + '40' : c.border.subtle}`,
px: 0.85, py: 0.3, borderRadius: 999,
cursor: enabled ? 'pointer' : 'default',
'&:hover': enabled ? { bgcolor: c.accent.primary + '22' } : undefined,
}}>
<ScheduleIcon sx={{ fontSize: 13 }} />
{scheduleShort(workflow.schedule)}
{enabled && workflow.schedule.repeat_unit === 'week' && (
<WeekdayDots on_days={workflow.schedule.on_days} />
)}
</Box>
</Tooltip>
<Popover
open={Boolean(anchor)}
anchorEl={anchor}
onClose={() => setAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}>
<Box sx={{ p: 1, display: 'flex', flexDirection: 'column', gap: 0.5, minWidth: 220 }}>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em' }}>
QUICK TIME EDIT
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Select
size="small"
value={((sched.hour + 11) % 12) + 1}
onChange={(e) => {
const h12 = Number(e.target.value);
const isPm = sched.hour >= 12;
patchSched({ hour: (h12 % 12) + (isPm ? 12 : 0) });
}}
sx={{ fontSize: '0.78rem', '& .MuiSelect-select': { py: 0.4 } }}>
{Array.from({ length: 12 }, (_, i) => i + 1).map((h) => (
<MenuItem key={h} value={h}>{h}</MenuItem>
))}
</Select>
<Typography sx={{ fontSize: '0.85rem' }}>:</Typography>
<Select
size="small"
value={sched.minute}
onChange={(e) => patchSched({ minute: Number(e.target.value) })}
sx={{ fontSize: '0.78rem', '& .MuiSelect-select': { py: 0.4 } }}>
{[0, 15, 30, 45].map((m) => (
<MenuItem key={m} value={m}>{String(m).padStart(2, '0')}</MenuItem>
))}
</Select>
<Select
size="small"
value={sched.hour < 12 ? 'AM' : 'PM'}
onChange={(e) => {
const wasPm = sched.hour >= 12;
const willBePm = e.target.value === 'PM';
if (wasPm === willBePm) return;
patchSched({ hour: willBePm ? sched.hour + 12 : sched.hour - 12 });
}}
sx={{ fontSize: '0.78rem', '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="AM">AM</MenuItem>
<MenuItem value="PM">PM</MenuItem>
</Select>
</Box>
<Typography sx={{ fontSize: '0.68rem', color: c.text.ghost, mt: 0.25 }}>
Saved as you change.
</Typography>
</Box>
</Popover>
</>
);
}
// Classify a workflow's billing route based on its model id + the user's
// global connection mode. Mirrors the per-session logic in AgentChat so
// the workflow card tells the same story the chat header does. Returns
// 'metered' when the user pays per call (Anthropic/OpenAI/Gemini API
// keys, custom OpenAI-compatible) or 'subscription' when a flat-rate
// account is doing the work (Claude Pro/Max, ChatGPT Plus/Pro, Gemini
// Advanced, OpenSwarm Pro proxy). `subLabel` names the plan for tooltips.
export type RoutingKind = 'metered' | 'subscription';
export interface Routing {
kind: RoutingKind;
subLabel?: string;
}
export function routingFor(model: string, connectionMode: string | undefined): Routing {
const m = (model || '').toLowerCase();
if (m.endsWith('-api')) return { kind: 'metered' };
if (m.endsWith('-cc')) return { kind: 'subscription', subLabel: 'Claude Pro/Max' };
const isPlainAnthropic = m === 'sonnet' || m === 'opus' || m === 'haiku';
if (isPlainAnthropic && connectionMode === 'openswarm-pro') {
return { kind: 'subscription', subLabel: 'OpenSwarm Pro' };
}
if (isPlainAnthropic) return { kind: 'metered' };
if (m.startsWith('gpt-5') || m.startsWith('gpt-4') || m.startsWith('o1') || m.startsWith('o3') || m.startsWith('o4')) {
return { kind: 'subscription', subLabel: 'ChatGPT Plus/Pro' };
}
if (m.startsWith('gemini-')) {
return { kind: 'subscription', subLabel: 'Gemini Advanced' };
}
// Unknown model id, default to metered so we don't oversell "free."
return { kind: 'metered' };
}
export function CostChip({ workflow, connectionMode }: { workflow: Workflow; connectionMode?: string }) {
const c = useClaudeTokens();
const est = workflow.cost_estimate;
const route = routingFor(workflow.model, connectionMode);
// Subscription-routed workflows have no metered per-call cost. Surface
// a usage chip instead so the user knows runs are "free" under their
// existing plan but still sees the projected fire frequency.
if (route.kind === 'subscription') {
if (!est || est.fires_per_month === 0) {
return (
<Tooltip title={`Runs are covered by your ${route.subLabel} plan. No upcoming runs scheduled.`}>
<Box sx={chipSx(c)}>
<AllInclusiveIcon sx={{ fontSize: 12 }} />
{route.subLabel || 'Subscription'}
</Box>
</Tooltip>
);
}
return (
<Tooltip title={`Routed through your ${route.subLabel} plan; no per-run cost. About ${est.fires_per_month} runs per month at the current schedule.`}>
<Box sx={chipSx(c)}>
<AllInclusiveIcon sx={{ fontSize: 12 }} />
~{est.fires_per_month} runs/mo
</Box>
</Tooltip>
);
}
// Metered route: only render the cost chip once we actually have a
// last-run figure to project from. Avoids "$0.00/mo" gaslighting.
if (!est || est.fires_per_month === 0 || est.last_run_usd <= 0) return null;
const monthly = est.monthly_usd || 0;
return (
<Tooltip title={`About $${est.last_run_usd.toFixed(4)} per run, times ${est.fires_per_month} runs per month.`}>
<Box sx={chipSx(c)}>
<AttachMoneyIcon sx={{ fontSize: 12, ml: -0.25 }} />
{monthly < 0.01 ? '<0.01' : monthly.toFixed(2)}/mo
</Box>
</Tooltip>
);
}
function chipSx(c: ReturnType<typeof useClaudeTokens>) {
return {
display: 'inline-flex', alignItems: 'center', gap: 0.3,
fontSize: '0.74rem', fontWeight: 600,
color: c.text.secondary,
bgcolor: c.bg.elevated,
border: `1px solid ${c.border.subtle}`,
px: 0.75, py: 0.3, borderRadius: 999,
} as const;
}
// Compact "last fired" mini-label, used inside the Run-tab summary.
export function LastFiredHint({ workflow }: { workflow: Workflow }) {
const c = useClaudeTokens();
if (!workflow.last_run_at) return null;
const ms = Date.now() - new Date(workflow.last_run_at).getTime();
const ago = relTime(ms);
return (
<Typography sx={{ fontSize: '0.72rem', color: c.text.ghost }}>Last ran {ago}</Typography>
);
}
function relTime(ms: number): string {
if (ms < 0) return 'just now';
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 30) return `${d}d ago`;
const mo = Math.floor(d / 30);
return `${mo}mo ago`;
}
// ---------- Run history sparkline ----------
// 10-dot horizontal strip of last N runs colored by status. Easy "lately
// healthy?" check without opening the History tab. Tooltip names the
// pattern out loud so a non-dev knows the dots aren't decorative.
export function RunSparkline({ runs, max = 10 }: { runs: WorkflowRun[]; max?: number }) {
const c = useClaudeTokens();
if (!runs || runs.length === 0) return null;
const slice = runs.slice(0, max).reverse();
const successes = slice.filter((r) => r.status === 'success').length;
const failures = slice.filter((r) => r.status === 'failure').length;
const tooltip = `Last ${slice.length} run${slice.length === 1 ? '' : 's'}: ${successes} ok, ${failures} failed (oldest left → newest right). Green = success, red = failure, amber = ran late.`;
return (
<Tooltip title={tooltip}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.3, ml: 0.5 }}>
{slice.map((r) => (
<Box key={r.id} sx={{
width: 6, height: 6, borderRadius: '50%',
bgcolor: statusDotColor(r.status as LastRunStatus, c),
}} />
))}
</Box>
</Tooltip>
);
}
// ---------- Streak badge ----------
// Count consecutive successful runs at the head of the runs list.
// `runs[0]` is the most recent run, so we walk forward until we hit a
// non-success. Returns 0 when no streak is active.
export function successStreak(runs: WorkflowRun[] | undefined): number {
if (!runs || runs.length === 0) return 0;
let n = 0;
for (const r of runs) {
if (r.status === 'success' || r.status === 'ran_late') n += 1;
else break;
}
return n;
}
export function StreakBadge({ runs }: { runs: WorkflowRun[] | undefined }) {
const c = useClaudeTokens();
const n = successStreak(runs);
if (n < 3) return null;
return (
<Tooltip title={`${n} successful runs in a row.`}>
<Box sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.3,
fontSize: '0.72rem', fontWeight: 700,
color: c.status.warning || '#f59e0b',
bgcolor: (c.status.warningBg || c.bg.elevated),
border: `1px solid ${(c.status.warning || '#f59e0b') + '60'}`,
px: 0.7, py: 0.2, borderRadius: 999,
}}>
🔥 {n}
</Box>
</Tooltip>
);
}
// ---------- Step icon auto-classifier ----------
// Pick a glyph by keyword scan of the step text. Falls back to the
// step number when nothing matches. Same Roman-numeral simple heuristic
// the user sees: "summarize email" -> mail icon, "make notion page" ->
// article icon, etc.
const ICON_RULES: Array<{ pattern: RegExp; Icon: React.ElementType }> = [
{ pattern: /\b(email|inbox|gmail|outlook|mail)\b/i, Icon: EmailIcon },
{ pattern: /\b(calendar|schedule|event|meeting)\b/i, Icon: CalendarTodayIcon },
{ pattern: /\b(notion|doc|page|page template|document|article)\b/i, Icon: ArticleIcon },
{ pattern: /\b(text|sms|message|whatsapp|imessage)\b/i, Icon: SmsIcon },
{ pattern: /\b(call|phone|dial|ring)\b/i, Icon: PhoneInTalkIcon },
{ pattern: /\b(browser|web|website|url|fetch|visit|navigate)\b/i, Icon: LanguageIcon },
{ pattern: /\b(search|find|look up|google)\b/i, Icon: SearchIcon },
{ pattern: /\b(code|github|repo|script|bash|run)\b/i, Icon: CodeIcon },
{ pattern: /\b(read|review|summarize|summary)\b/i, Icon: ChromeReaderModeIcon },
{ pattern: /\b(chat|reply|respond|dm)\b/i, Icon: ChatBubbleOutlineIcon },
{ pattern: /\b(note|memo|journal|log)\b/i, Icon: EventNoteIcon },
];
export function stepIconFor(text: string): React.ElementType | null {
for (const rule of ICON_RULES) {
if (rule.pattern.test(text)) return rule.Icon;
}
return null;
}
// ---------- Step duration learner ----------
// Estimates per-step duration by averaging recent runs. Today we only
// have whole-run duration on each WorkflowRun (started_at -> finished_at),
// so the heuristic spreads it evenly across the step count. When per-step
// telemetry lands later, swap this for a per-step lookup.
export function estimateStepDuration(workflow: Workflow, runs: WorkflowRun[] | undefined, stepIdx: number): string | null {
if (!runs || runs.length === 0) return null;
const steps = workflow.steps?.length || 1;
const successful = runs.filter((r) => (r.status === 'success' || r.status === 'ran_late') && r.finished_at);
if (successful.length === 0) return null;
const durations = successful.slice(0, 10).map((r) => {
const start = new Date(r.started_at).getTime();
const end = new Date(r.finished_at!).getTime();
return Math.max(0, end - start);
});
const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
const perStepMs = avg / steps;
void stepIdx;
return humanDuration(perStepMs);
}
export function humanDuration(ms: number): string {
if (ms < 1000) return '<1s';
const s = Math.round(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
return rem > 0 && m < 5 ? `${m}m ${rem}s` : `${m}m`;
}
// ---------- Run-button breath logic ----------
// Returns true when the workflow hasn't been run in over 24h. Used by
// the Run tab to add a subtle CSS breathing animation so the button
// invites use without yelling.
export function isStaleSinceLastRun(workflow: Workflow): boolean {
if (!workflow.last_run_at) return false;
const age = Date.now() - new Date(workflow.last_run_at).getTime();
return age > 24 * 3600 * 1000;
}
@@ -7,9 +7,6 @@ const fetchSessionRejectedAction = createAction<
{ sessionId?: string; status?: number } | undefined
>('agents/fetchSession/rejected');
// Cascade workflow delete to layout so the "Make workflow" tether stops pointing at empty space.
const deleteWorkflowFulfilledAction = createAction<string>('workflows/delete/fulfilled');
const DASHBOARDS_API = `${API_BASE}/dashboards`;
export const DEFAULT_CARD_W = 480;
@@ -18,16 +15,12 @@ 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 = 480;
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 };
const GRID_COLS_FALLBACK = 4;
export type CardType = 'agent' | 'view' | 'browser' | 'note' | 'workflow';
export type CardType = 'agent' | 'view' | 'browser' | 'note';
export interface CardPosition {
session_id: string;
@@ -68,25 +61,6 @@ 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; only one Workflows Hub card open at a time. */
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 {
@@ -103,21 +77,10 @@ export interface NotePosition {
export const DEFAULT_NOTE_W = 240;
export const DEFAULT_NOTE_H = 200;
export interface ConfigurePanelPosition {
workflow_id: string;
x: number;
y: number;
width: number;
height: number;
}
export interface DashboardLayoutState {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
workflowCards: Record<string, WorkflowCardPosition>;
configurePanels: Record<string, ConfigurePanelPosition>;
workflowsHub: WorkflowsHubPosition | null;
notes: Record<string, NotePosition>;
closedCardPositions: Record<string, CardPosition>;
glowingBrowserCards: Record<string, { sourceId: string; fading: boolean; label?: string }>;
@@ -129,18 +92,12 @@ export interface DashboardLayoutState {
/** Transient: new browser card id; Dashboard pans/zooms to it then clears via clearPendingFocusBrowserId. */
pendingFocusBrowserId: string | null;
pendingFocusNoteId: string | null;
pendingFocusWorkflowId: string | null;
/** Transient: signals Dashboard to pan/zoom to the singleton Workflows Hub on open. */
pendingFocusWorkflowsHub: boolean;
}
const initialState: DashboardLayoutState = {
cards: {},
viewCards: {},
browserCards: {},
workflowCards: {},
configurePanels: {},
workflowsHub: null,
notes: {},
closedCardPositions: {},
glowingBrowserCards: {},
@@ -151,17 +108,12 @@ const initialState: DashboardLayoutState = {
initialized: false,
pendingFocusBrowserId: null,
pendingFocusNoteId: null,
pendingFocusWorkflowId: null,
pendingFocusWorkflowsHub: false,
};
interface LayoutPayload {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
workflowCards: Record<string, WorkflowCardPosition>;
configurePanels: Record<string, ConfigurePanelPosition>;
workflowsHub: WorkflowsHubPosition | null;
notes: Record<string, NotePosition>;
expandedSessionIds: string[];
}
@@ -194,9 +146,6 @@ export const fetchLayout = createAsyncThunk(
cards: (layout.cards ?? {}) as Record<string, CardPosition>,
viewCards: (layout.view_cards ?? {}) as Record<string, ViewCardPosition>,
browserCards: browserCards as Record<string, BrowserCardPosition>,
workflowCards: (layout.workflow_cards ?? {}) as Record<string, WorkflowCardPosition>,
configurePanels: (layout.configure_panels ?? {}) as Record<string, ConfigurePanelPosition>,
workflowsHub: (layout.workflows_hub ?? null) as WorkflowsHubPosition | null,
notes: (layout.notes ?? {}) as Record<string, NotePosition>,
expandedSessionIds: (layout.expanded_session_ids ?? []) as string[],
} satisfies LayoutPayload;
@@ -218,9 +167,6 @@ export const saveLayout = createAsyncThunk(
cards: payload.cards,
view_cards: payload.viewCards,
browser_cards: payload.browserCards,
workflow_cards: payload.workflowCards,
configure_panels: payload.configurePanels,
workflows_hub: payload.workflowsHub,
notes: payload.notes,
expanded_session_ids: payload.expandedSessionIds,
},
@@ -257,12 +203,6 @@ 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 });
}
@@ -418,7 +358,7 @@ const dashboardLayoutSlice = createSlice({
bringToFront(
state,
action: PayloadAction<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' | 'workflow' | 'workflows-hub' }>,
action: PayloadAction<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' }>,
) {
const { id, type } = action.payload;
// Compute the current top zOrder across ALL card types so we can
@@ -426,22 +366,17 @@ const dashboardLayoutSlice = createSlice({
// guard, every click on a card (which fires onPointerDownCapture +
// onClick + onDoubleClick) bumps zOrder and triggers a Redux
// mutation. That mutation cascades into a re-render that unmounts
// inputs mid-keystroke, causing the workflow card's title /
// description / step textareas to lose focus on every click.
// inputs mid-keystroke.
let maxZ = 0;
let currentZ = 0;
const tally = (z: number | undefined) => { if (typeof z === 'number' && z > maxZ) maxZ = z; };
for (const c of Object.values(state.cards)) tally(c.zOrder);
for (const c of Object.values(state.viewCards)) tally(c.zOrder);
for (const c of Object.values(state.browserCards)) tally(c.zOrder);
for (const c of Object.values(state.workflowCards)) tally(c.zOrder);
for (const n of Object.values(state.notes)) tally(n.zOrder);
if (state.workflowsHub) tally(state.workflowsHub.zOrder);
if (type === 'agent') currentZ = state.cards[id]?.zOrder ?? 0;
else if (type === 'view') currentZ = state.viewCards[id]?.zOrder ?? 0;
else if (type === 'note') currentZ = state.notes[id]?.zOrder ?? 0;
else if (type === 'workflow') currentZ = state.workflowCards[id]?.zOrder ?? 0;
else if (type === 'workflows-hub') currentZ = state.workflowsHub?.zOrder ?? 0;
else currentZ = state.browserCards[id]?.zOrder ?? 0;
if (currentZ >= maxZ) return; // Already on top: no-op.
@@ -455,11 +390,6 @@ 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;
@@ -515,15 +445,13 @@ const dashboardLayoutSlice = createSlice({
const agentCards = Object.values(state.cards);
const viewCards = Object.values(state.viewCards);
const bCards = Object.values(state.browserCards);
const wCards = Object.values(state.workflowCards);
const total = agentCards.length + viewCards.length + bCards.length + wCards.length;
const total = agentCards.length + viewCards.length + bCards.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);
@@ -548,9 +476,6 @@ 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; }
@@ -681,187 +606,6 @@ 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]) {
state.workflowCards[workflowId].zOrder = state.nextZOrder++;
state.pendingFocusWorkflowId = workflowId;
return;
}
// Fall back to persistedExpandedSessionIds when the caller didn't
// wire the live list through. Without it, collectOccupiedRects sees
// every chat at its stored (collapsed) height, and a workflow
// spawned from an open chat lands on top of the visibly-tall card.
const expanded = expandedSessionIds ?? state.persistedExpandedSessionIds;
const rects = collectOccupiedRects(state, expanded);
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<string>) {
delete state.workflowCards[action.payload];
},
// Rekey draft- id to the server-assigned id without visually hopping the card.
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 };
// Carry any open Action-Library panel along with the rekey so the
// popout doesn't disappear when a draft is saved.
const panel = state.configurePanels[oldId];
if (panel) {
delete state.configurePanels[oldId];
state.configurePanels[newId] = { ...panel, workflow_id: newId };
}
if (state.pendingFocusWorkflowId === oldId) state.pendingFocusWorkflowId = newId;
},
openConfigurePanel(
state,
action: PayloadAction<{ workflowId: string }>,
) {
const { workflowId } = action.payload;
// Anchor the panel just to the right of the workflow card.
const wfCard = state.workflowCards[workflowId];
const baseX = wfCard ? wfCard.x + wfCard.width + GRID_GAP * 6 : 600;
const baseY = wfCard ? wfCard.y : 200;
const existing = state.configurePanels[workflowId];
if (existing) {
existing.x = baseX;
existing.y = baseY;
return;
}
state.configurePanels[workflowId] = {
workflow_id: workflowId,
x: baseX,
y: baseY,
width: 580,
height: 600,
};
},
setConfigurePanelPosition(
state,
action: PayloadAction<{ workflowId: string; x: number; y: number }>,
) {
const { workflowId, x, y } = action.payload;
const p = state.configurePanels[workflowId];
if (p) { p.x = x; p.y = y; }
},
setConfigurePanelSize(
state,
action: PayloadAction<{ workflowId: string; width: number; height: number }>,
) {
const { workflowId, width, height } = action.payload;
const p = state.configurePanels[workflowId];
if (p) {
p.width = Math.max(360, width);
p.height = Math.max(280, height);
}
},
closeConfigurePanel(state, action: PayloadAction<string>) {
delete state.configurePanels[action.payload];
},
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<{
@@ -1010,7 +754,7 @@ const dashboardLayoutSlice = createSlice({
moveCards(
state,
action: PayloadAction<{
items: Array<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' | 'workflow' }>;
items: Array<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' }>;
dx: number;
dy: number;
}>,
@@ -1035,12 +779,6 @@ 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) {
@@ -1168,9 +906,6 @@ const dashboardLayoutSlice = createSlice({
state.cards = {};
state.viewCards = {};
state.browserCards = {};
state.workflowCards = {};
state.configurePanels = {};
state.workflowsHub = null;
state.notes = {};
state.closedCardPositions = {};
state.glowingBrowserCards = {};
@@ -1179,7 +914,6 @@ const dashboardLayoutSlice = createSlice({
state.nextZOrder = 1;
state.initialized = false;
state.pendingFocusNoteId = null;
state.pendingFocusWorkflowId = null;
},
},
@@ -1194,9 +928,6 @@ const dashboardLayoutSlice = createSlice({
state.cards = action.payload.cards;
state.viewCards = action.payload.viewCards;
state.browserCards = action.payload.browserCards;
state.workflowCards = action.payload.workflowCards || {};
state.configurePanels = action.payload.configurePanels || {};
state.workflowsHub = action.payload.workflowsHub || null;
state.notes = action.payload.notes || {};
state.persistedExpandedSessionIds = action.payload.expandedSessionIds;
@@ -1213,10 +944,6 @@ 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;
@@ -1236,11 +963,6 @@ const dashboardLayoutSlice = createSlice({
if (state.cards[id]) delete state.cards[id];
if (state.closedCardPositions[id]) delete state.closedCardPositions[id];
})
.addCase(deleteWorkflowFulfilledAction, (state, action) => {
const id = action.payload;
if (id && state.workflowCards[id]) delete state.workflowCards[id];
if (id && state.configurePanels[id]) delete state.configurePanels[id];
})
.addCase(launchAndSendFirstMessage.fulfilled, (state, action) => {
const { draftId, session } = action.payload;
const card = state.cards[draftId];
@@ -1288,21 +1010,6 @@ export const {
fadeGlowingAgentCard,
clearGlowingAgentCard,
clearPendingFocusBrowserId,
addWorkflowCard,
setWorkflowCardPosition,
setWorkflowCardSize,
removeWorkflowCard,
rekeyWorkflowCard,
openConfigurePanel,
closeConfigurePanel,
setConfigurePanelPosition,
setConfigurePanelSize,
clearPendingFocusWorkflowId,
openWorkflowsHub,
closeWorkflowsHub,
setWorkflowsHubPosition,
setWorkflowsHubSize,
clearPendingFocusWorkflowsHub,
addNote,
setNotePosition,
setNoteSize,
-2
View File
@@ -15,7 +15,6 @@ import updateReducer from './updateSlice';
import modelsReducer from './modelsSlice';
import interactionReducer from './interactionSlice';
import subscriptionsReducer from './subscriptionsSlice';
import workflowsReducer from './workflowsSlice';
import onboardingProgressReducer from '@/shared/state/onboardingProgressSlice';
export const store = configureStore({
@@ -36,7 +35,6 @@ export const store = configureStore({
models: modelsReducer,
interaction: interactionReducer,
subscriptions: subscriptionsReducer,
workflows: workflowsReducer,
onboardingProgress: onboardingProgressReducer,
},
// Disable Redux Toolkit's dev-mode invariant middleware (serializable +
-374
View File
@@ -1,374 +0,0 @@
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';
/** End conditions; null on both = forever. Scheduler auto-disables on threshold. */
ends_at: string | null;
max_runs: number | null;
runs_count: number;
}
export interface CostEstimate {
monthly_usd: number;
last_run_usd: number;
fires_per_month: number;
}
export interface ActiveRun {
workflow_id: string;
run_id: string;
title: string;
started_at: string | null;
}
export interface ActionsConfig {
prevent_unused: boolean;
freeze: boolean;
configured_sets: string[];
}
export interface WorkflowStep {
id: string;
text: string;
/** LLM-generated 3-6 word label shown when the step row is collapsed. The
* full `text` is what the agent actually runs; this is just the title. */
label?: string | null;
}
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' | 'skipped' | null;
last_run_id: string | null;
next_run_at: string | null;
cost_cap_usd_monthly: number | null;
cost_estimate?: CostEstimate;
/** Sticky session id for the Edit Agent embedded in the workflow card. */
edit_agent_session_id?: 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';
/** Live "what's the agent doing" subtitle while status is 'running'. */
last_tool_label?: string | null;
/** Currently-executing 0-based step index while status is 'running';
* freezes on the failed step when status flips to 'failure'. */
active_step_idx?: number | null;
}
/** Transient view-only state per card; position lives in dashboardLayoutSlice.workflowCards. */
export interface OpenCard {
workflowId: string;
sourceSessionId?: string | null;
draft?: Partial<Workflow> | null;
view:
| 'preview'
| 'saved'
| 'edit'
| 'history'
| 'history_detail'
| 'running'
| 'completed'
| 'failed'
| 'scheduling'
| 'edit_agent'
| 'fix_agent';
editFacet?: 'General' | 'Actions' | 'Schedule';
historyRunId?: string | null;
/** The run id currently surfaced by Running/Completed/Failed views. */
runId?: string | null;
/** When set, the workflow card is "linked" to a sibling session card via
* a labeled arrow chip, and the card footer shifts to Stop Watching /
* Stop Viewing / Force Stop. The session id points at the sibling agent. */
sidecarSessionId?: string | null;
sidecarKind?: 'watching' | 'viewing-completed' | 'viewing-error' | 'testing' | null;
/** Per-step expand state for ExpandedView. Stores step ids. */
expandedStepIds?: string[];
/** Pre-seed message for the Fix-with-Agent flow so the EditAgent composer
* knows which failure context to lead with. Cleared once consumed. */
fixSeed?: { runId: string; stepIdx: number; stepLabel: string; error: string } | null;
}
interface State {
items: Record<string, Workflow>;
runs: Record<string, WorkflowRun[]>;
openCards: Record<string, OpenCard>;
loaded: boolean;
loading: boolean;
paused: boolean;
active: ActiveRun[];
cloudSmsEnabled: boolean;
}
const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: 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<Workflow>) => {
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;
},
);
// Optimistic concurrency via If-Match: server 409s on stale writes; rejectWithValue lets FE distinguish.
export const updateWorkflow = createAsyncThunk<
Workflow,
{ id: string; patch: Partial<Workflow>; ifMatch?: string | null },
{ rejectValue: { kind: 'stale' | 'network' | 'server'; message: string; current_updated_at?: string } }
>(
'workflows/update',
async ({ id, patch, ifMatch }, { rejectWithValue }) => {
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (ifMatch) headers['If-Match'] = ifMatch;
const res = await fetch(`${API}/${id}`, {
method: 'PATCH',
headers,
body: JSON.stringify(patch),
});
if (res.status === 409) {
const data = await res.json().catch(() => ({}));
const detail = (data && (data.detail || data)) || {};
return rejectWithValue({
kind: 'stale',
message: detail.message || 'This workflow changed elsewhere. Reload and try again.',
current_updated_at: detail.current_updated_at,
});
}
if (!res.ok) {
return rejectWithValue({ kind: 'server', message: `Update failed (${res.status}).` });
}
return (await res.json()) as Workflow;
} catch (e) {
return rejectWithValue({ kind: 'network', message: (e as Error)?.message || 'Network error.' });
}
},
);
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,
status: (data.status || null) as string | null,
error: (data.error || null) as string | null,
};
});
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[] };
},
);
export const fetchPausedState = createAsyncThunk('workflows/paused', async () => {
const res = await fetch(`${API}/paused`);
const data = await res.json();
return Boolean(data.paused);
});
export const fetchActiveRuns = createAsyncThunk('workflows/active', async () => {
const res = await fetch(`${API}/active`);
const data = await res.json();
return (data.active || []) as ActiveRun[];
});
export const setPausedAll = createAsyncThunk('workflows/setPaused', async (paused: boolean) => {
const res = await fetch(`${API}/${paused ? 'pause-all' : 'resume-all'}`, { method: 'POST' });
if (!res.ok) throw new Error(`pause-all toggle failed ${res.status}`);
const data = await res.json();
return Boolean(data.paused);
});
export const ackRun = createAsyncThunk('workflows/ackRun', async (runId: string) => {
const res = await fetch(`${API}/runs/${encodeURIComponent(runId)}/ack`, { method: 'POST' });
if (!res.ok) throw new Error(`ack failed ${res.status}`);
return runId;
});
export const fetchCloudSmsStatus = createAsyncThunk('workflows/cloudSms', async () => {
try {
const res = await fetch(`${API}/cloud/sms/status`);
const data = await res.json();
return Boolean(data.enabled);
} catch {
return false;
}
});
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<OpenCard> } }) {
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);
const prev = idx >= 0 ? arr[idx] : null;
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;
}
// Auto-flip the card view on run state transitions so the user sees
// Running while it streams, Completed on success, Failed on failure.
// Only nudge from views that the user hasn't actively navigated away
// from (saved / running). Edit, history, scheduling etc. stay put.
const card = state.openCards[r.workflow_id];
if (card) {
const fromRunnable = card.view === 'saved' || card.view === 'running';
if (r.status === 'running' && fromRunnable) {
card.view = 'running';
card.runId = r.id;
} else if (prev && prev.status === 'running' && r.status === 'success' && (card.view === 'running' || card.view === 'saved')) {
card.view = 'completed';
card.runId = r.id;
} else if (prev && prev.status === 'running' && r.status === 'failure' && (card.view === 'running' || card.view === 'saved')) {
card.view = 'failed';
card.runId = r.id;
}
}
},
toggleExpandedStep(state, action: { payload: { workflowId: string; stepId: string } }) {
const card = state.openCards[action.payload.workflowId];
if (!card) return;
const arr = card.expandedStepIds || [];
const has = arr.includes(action.payload.stepId);
card.expandedStepIds = has ? arr.filter((x) => x !== action.payload.stepId) : [...arr, action.payload.stepId];
},
setCardSidecar(state, action: { payload: { workflowId: string; sessionId: string | null; kind: OpenCard['sidecarKind'] } }) {
const card = state.openCards[action.payload.workflowId];
if (!card) return;
card.sidecarSessionId = action.payload.sessionId;
card.sidecarKind = action.payload.kind;
},
clearFixSeed(state, action: { payload: string }) {
const card = state.openCards[action.payload];
if (card) card.fixSeed = null;
},
},
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;
})
.addCase(fetchPausedState.fulfilled, (state, action) => { state.paused = action.payload; })
.addCase(setPausedAll.fulfilled, (state, action) => { state.paused = action.payload; })
.addCase(fetchActiveRuns.fulfilled, (state, action) => { state.active = action.payload; })
.addCase(fetchCloudSmsStatus.fulfilled, (state, action) => { state.cloudSmsEnabled = action.payload; });
},
});
export const {
upsertRun,
openWorkflowCard,
updateWorkflowCard,
closeWorkflowCard,
rekeyOpenCard,
toggleExpandedStep,
setCardSidecar,
clearFixSeed,
} = slice.actions;
export default slice.reducer;
+1 -86
View File
@@ -23,9 +23,8 @@ import {
clearTurnLabel,
} from '../state/agentsSlice';
import { streamStart, streamDelta, streamEnd } from '../state/streamingSlice';
import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP, addWorkflowCard } from '../state/dashboardLayoutSlice';
import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice';
import { upsertOutput } from '../state/outputsSlice';
import { upsertRun, ackRun, runWorkflowNow, openWorkflowCard } from '../state/workflowsSlice';
import { getAuthToken } from '../config';
import { notifyAgentCompletion } from '../notifications';
@@ -704,65 +703,6 @@ 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 */ }
try {
const w: any = (window as any).openswarm;
if (w?.notify) {
// Seed by workflow id + current minute so multiple workflows pick different copy
// while a single workflow stays stable within a few minutes.
const seed = ((data.workflow_id || '').length + Math.floor(Date.now() / 60000)) | 0;
const SUCCESS_TITLES = [
`${data.workflow_title || 'Workflow'} — done`,
`${data.workflow_title || 'Workflow'} just wrapped up`,
`Heads up: ${data.workflow_title || 'Workflow'} finished`,
`${data.workflow_title || 'Workflow'} is ready`,
];
const FAILURE_TITLES = [
`${data.workflow_title || 'Workflow'} hit a snag`,
`${data.workflow_title || 'Workflow'} couldn't finish`,
`Something went sideways on ${data.workflow_title || 'Workflow'}`,
];
const LATE_TITLES = [
`${data.workflow_title || 'Workflow'} caught up late`,
`${data.workflow_title || 'Workflow'} ran late but made it`,
];
const pool = data.status === 'success' ? SUCCESS_TITLES
: data.status === 'failure' ? FAILURE_TITLES
: data.status === 'ran_late' ? LATE_TITLES
: [`${data.workflow_title || 'Workflow'}${data.status}`];
const title = pool[Math.abs(seed) % pool.length];
const isMac = (typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform));
const body = data.tier_kind && data.fallback
? `Would have ${data.tier_kind === 'call' ? 'called' : 'texted'} you. (Cloud SMS not wired yet.)`
: data.status === 'success'
? (isMac ? 'Tap to see what it did.' : 'Click to see what it did.')
: data.status === 'failure'
? (isMac ? 'Tap to see what went wrong.' : 'Click to see what went wrong.')
: (isMac ? 'Tap to open the run.' : 'Click to open the run.');
const deepLink = data.workflow_id ? `openswarm://workflow/${data.workflow_id}/run/${data.run_id || ''}` : undefined;
const actions = [
{ text: 'Looks good', outcome: 'ack' },
{ text: 'Re-run', outcome: 'rerun' },
{ text: 'Adjust', outcome: 'edit' },
];
w.notify({ title, body, deepLink, runId: data.run_id, workflowId: data.workflow_id, actions });
}
} catch { /* native notif optional */ }
break;
case 'dashboard:browser_card_added':
if (data.browser_card) {
store.dispatch(addBrowserCardFromBackend(data.browser_card));
@@ -860,31 +800,6 @@ class WebSocketManager {
import { WS_BASE } from '@/shared/config';
// Bridge native-notification button actions to workflow actions. Subscribe at module
// import time so we never miss an early callback fired before any component mounts.
(() => {
try {
const w: any = (typeof window !== 'undefined') ? (window as any).openswarm : null;
if (!w?.onNotificationAction) return;
w.onNotificationAction(({ outcome, runId, workflowId }: { outcome: string; runId?: string; workflowId?: string }) => {
if (!workflowId) return;
if (outcome === 'ack' && runId) {
store.dispatch(ackRun(runId));
return;
}
if (outcome === 'rerun') {
store.dispatch(runWorkflowNow(workflowId));
return;
}
if (outcome === 'edit' || outcome === 'open') {
store.dispatch(addWorkflowCard({ workflowId }));
store.dispatch(openWorkflowCard({ workflowId, view: outcome === 'edit' ? 'edit' : 'saved', editFacet: outcome === 'edit' ? 'Schedule' : undefined }));
return;
}
});
} catch { /* native notifications optional */ }
})();
export const dashboardWs = new WebSocketManager(`${WS_BASE}/ws/dashboard`, { skipStreamEvents: true });
// Per-session high-water mark for the resume protocol. Survives across