mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-10 03:37:44 +02:00
[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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "openswarm",
|
||||
"version": "1.1.63",
|
||||
"version": "1.1.64",
|
||||
"description": "OpenSwarm — AI Agent Orchestrator",
|
||||
"author": "openswarm-ai",
|
||||
"main": "main.js",
|
||||
|
||||
@@ -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'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'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'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 search↔schedule 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 }}>
|
||||
"{existing.title}" 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'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,
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user