[eric] workflows as canvas-resident cards + hub + schedule UX pass, unstable version

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