mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 20:27:44 +02:00
[aidan] feat/workflows: launch-time scheduling UX and workflow-card polish (#101)
* [aidan] feat/schedule-list: lazy-load list view via scroll sentinel * [aidan] feat/missed-runs: launch toast with per-workflow counts and pan-to-card * [aidan] fix/dashboard-tethers: keep watching line anchored on canvas zoom * [aidan] feat/scheduled-tasks: review missed runs at launch instead of auto-firing on_missed * [aidan] refactor/workflow-cards: use radius and status design tokens, polish card chrome * [aidan] ux/agent-card: keep convert-to-workflow visible during runs with mid-turn toast * [aidan] ux/mcp-bubble: drop redundant verb label when a workflow label is shown * [aidan] chore/backend: remove stale explanatory comments * [aidan] fix/workflows-hub: load workflows on hub mount so calendar fills at launch * [aidan] feat/workflows: generate title, description, step labels at convert time * [aidan] fix/tidy-layout: include workflows hub in tidy and fit-to-view * [aidan] feat/schedule-list: window long list via measured-height virtualizer * [aidan] ux/workflows-hub: remove time-saved badge from calendar header * [aidan] fix/types: add missing semantic-type labels and drop stray fade arg
This commit is contained in:
@@ -13,8 +13,6 @@ import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Soft per-session throttle so the integration suggestion can fire on any turn
|
||||
# without nagging every message; only stamped when a suggestion actually emits.
|
||||
MCP_SUGGEST_COOLDOWN_S = 300.0
|
||||
p_mcp_suggest_cooldown: dict[str, float] = {}
|
||||
|
||||
@@ -80,7 +78,6 @@ async def send_message(session_id: str, body: dict):
|
||||
raise HTTPException(status_code=400, detail="prompt is required")
|
||||
|
||||
# Run MCP-suggestion classifier in parallel with the agent launch; fails open.
|
||||
# Fires on any turn, but a per-session cooldown keeps it from nagging every message.
|
||||
try:
|
||||
last_suggested = p_mcp_suggest_cooldown.get(session_id, 0.0)
|
||||
if time.monotonic() - last_suggested >= MCP_SUGGEST_COOLDOWN_S:
|
||||
|
||||
@@ -116,11 +116,6 @@ class AgentSession(BaseModel):
|
||||
dashboard_id: Optional[str] = None
|
||||
browser_id: Optional[str] = None
|
||||
parent_session_id: Optional[str] = None
|
||||
# For workflow Test Agent sessions: "running" while the test drives the
|
||||
# steps, then "complete"/"error" when it finishes. Drives the test card's
|
||||
# footer (red Force Stop -> green "workflow complete, close"). None for
|
||||
# ordinary sessions. A dedicated signal because per-turn status oscillates
|
||||
# completed/running between steps, so it can't mark "the whole test done".
|
||||
workflow_test_state: Optional[Literal["running", "complete", "error"]] = None
|
||||
# Browser memory signals, drive the subtle "remembered/learned" card chip so
|
||||
# the user feels the agent getting smarter without lifting a finger.
|
||||
|
||||
@@ -53,10 +53,6 @@ class NotePosition(BaseModel):
|
||||
|
||||
|
||||
class DashboardLayout(BaseModel):
|
||||
# Accept whatever the FE serialises (workflow_cards, configure_panels,
|
||||
# workflows_hub etc). Pydantic was silently stripping these because
|
||||
# they weren't declared, which made the dashboard re-render WITHOUT
|
||||
# the workflow card the user just placed.
|
||||
model_config = ConfigDict(extra="allow")
|
||||
cards: dict[str, CardPosition] = Field(default_factory=dict)
|
||||
view_cards: dict[str, ViewCardPosition] = Field(default_factory=dict)
|
||||
|
||||
@@ -299,7 +299,6 @@ class DismissMcpSuggestionPayload(BaseModel):
|
||||
|
||||
@settings.router.put("/dismiss-mcp-suggestion")
|
||||
async def put_dismiss_mcp_suggestion(body: DismissMcpSuggestionPayload):
|
||||
"""MERGE dismissed integration suggestions; the general PUT replaces the whole object and would blank secrets."""
|
||||
current = load_settings()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
for tool_id in body.ids:
|
||||
|
||||
@@ -31,7 +31,6 @@ class ScheduleConfig(BaseModel):
|
||||
# "local" to the host zone in memory; we leave it on disk until the
|
||||
# user's next save so backup/sync tools don't see spurious churn.
|
||||
timezone: str = "local"
|
||||
on_missed: Literal["skip", "run_once", "run_all"] = "skip"
|
||||
# Optional end conditions. None = forever / unbounded. Schedule auto-
|
||||
# disables once either is satisfied; scheduler._tick zeroes out
|
||||
# next_run_at and flips enabled=False so the UI reflects reality.
|
||||
@@ -185,6 +184,17 @@ class WorkflowRun(BaseModel):
|
||||
paused: bool = False
|
||||
|
||||
|
||||
class MissedRun(BaseModel):
|
||||
# A single scheduled fire that elapsed while OpenSwarm was closed. Captured
|
||||
# at startup and surfaced in the launch-time review card; leaves this store
|
||||
# only when the user runs it (becomes a ran_late run) or dismisses it
|
||||
# (becomes a skipped run). scheduled_for is the instant it should have fired.
|
||||
id: str = Field(default_factory=lambda: uuid4().hex)
|
||||
workflow_id: str
|
||||
scheduled_for: datetime
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
|
||||
|
||||
class WorkflowCreate(BaseModel):
|
||||
title: str = "Untitled workflow"
|
||||
auto_named: bool = True
|
||||
@@ -206,6 +216,20 @@ class WorkflowCreate(BaseModel):
|
||||
provider: Optional[str] = None
|
||||
cost_cap_usd_monthly: Optional[float] = None
|
||||
tested_signature: Optional[str] = None
|
||||
# The FE already named + described + labeled this at preview time; skip the
|
||||
# backend aux call so we don't double-spend or change the title under the user.
|
||||
metadata_generated: bool = False
|
||||
|
||||
|
||||
class GenerateMetadataRequest(BaseModel):
|
||||
steps: list[WorkflowStep] = Field(default_factory=list)
|
||||
model: Optional[str] = None
|
||||
|
||||
|
||||
class GenerateMetadataResponse(BaseModel):
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
step_labels: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WorkflowUpdate(BaseModel):
|
||||
@@ -227,6 +251,10 @@ class WorkflowUpdate(BaseModel):
|
||||
step_tool_usage: Optional[dict[str, dict[str, bool]]] = None
|
||||
|
||||
|
||||
class MissedRunAction(BaseModel):
|
||||
ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DraftCommitBody(BaseModel):
|
||||
# The model the user settled on in the Edit Agent picker, applied to the
|
||||
# workflow's run model only on Save (save-gated; Discard drops it).
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
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.
|
||||
startup we walk persisted workflows once and capture every fire that
|
||||
elapsed while the app was closed as a pending MissedRun, so the launch-time
|
||||
review card can let the user run or dismiss each one.
|
||||
|
||||
Schedule semantics:
|
||||
unit=minute: fires every repeat_every minutes (15 is the enforced floor)
|
||||
@@ -29,11 +30,19 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from backend.apps.workflows.models import Workflow, ScheduleConfig
|
||||
from backend.apps.workflows.models import Workflow, ScheduleConfig, WorkflowRun, MissedRun
|
||||
from backend.apps.workflows import storage, executor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How many recent missed fires we keep reviewable per workflow. Older ones
|
||||
# collapse into a single summarizing "skipped" run so a 15-minute schedule
|
||||
# that was off for days doesn't flood the card or the run history.
|
||||
PER_WORKFLOW_MISSED_CAP = 20
|
||||
# Bound on the per-workflow enumeration walk at startup. 480 covers ~5 days of
|
||||
# a 15-minute schedule; past that the exact count stops mattering.
|
||||
MISSED_ENUM_CAP = 480
|
||||
|
||||
|
||||
_loop_task: Optional[asyncio.Task] = None
|
||||
_wake = asyncio.Event()
|
||||
@@ -349,14 +358,66 @@ def _mark_stuck_runs_failed() -> None:
|
||||
)
|
||||
|
||||
|
||||
def reconcile_on_startup() -> None:
|
||||
"""Walk persisted workflows once and resolve missed fires per policy.
|
||||
def record_skipped(wf: Workflow, scheduled_for: datetime, error: str) -> WorkflowRun:
|
||||
"""Log a missed fire as a 'skipped' run so it leaves a trace in history.
|
||||
|
||||
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
|
||||
Used both for over-cap fires at startup and for fires the user dismisses
|
||||
from the review card. Updates the workflow's last_run_* summary so the
|
||||
card's status dot reflects reality.
|
||||
"""
|
||||
now = datetime.now()
|
||||
run = WorkflowRun(
|
||||
workflow_id=wf.id,
|
||||
status="skipped",
|
||||
scheduled_for=scheduled_for,
|
||||
started_at=now,
|
||||
finished_at=now,
|
||||
triggered_by="schedule",
|
||||
error=error,
|
||||
)
|
||||
storage.record_run(run)
|
||||
wf.last_run_at = now
|
||||
wf.last_run_status = "skipped"
|
||||
wf.last_run_id = run.id
|
||||
storage.save_workflow(wf)
|
||||
return run
|
||||
|
||||
|
||||
def _capture_missed(wf: Workflow, missed: list[datetime]) -> None:
|
||||
if not missed:
|
||||
return
|
||||
recent = missed[-PER_WORKFLOW_MISSED_CAP:]
|
||||
older = missed[: len(missed) - len(recent)]
|
||||
if older:
|
||||
suffix = "+" if len(missed) >= MISSED_ENUM_CAP else ""
|
||||
record_skipped(
|
||||
wf,
|
||||
older[0],
|
||||
f"Skipped {len(older)}{suffix} earlier missed runs while OpenSwarm was closed",
|
||||
)
|
||||
for sf in recent:
|
||||
storage.add_missed(MissedRun(workflow_id=wf.id, scheduled_for=sf))
|
||||
|
||||
|
||||
async def run_missed_sequence(wf: Workflow, scheduled_fors: list[datetime]) -> None:
|
||||
"""Run a workflow once per missed fire, sequentially.
|
||||
|
||||
Sequential because the executor refuses concurrent runs of the same
|
||||
workflow; firing them all at once would skip all but the first.
|
||||
"""
|
||||
for sf in scheduled_fors:
|
||||
try:
|
||||
await executor.execute(wf, triggered_by="schedule", scheduled_for=sf)
|
||||
except Exception:
|
||||
logger.exception("missed-run fire failed for workflow=%s", wf.id)
|
||||
|
||||
|
||||
def reconcile_on_startup() -> None:
|
||||
"""Walk persisted workflows once and capture fires missed while closed.
|
||||
|
||||
No auto-firing here anymore: each missed fire becomes a pending MissedRun
|
||||
the user reviews on launch. We roll next_run_at forward to a future slot so
|
||||
a dev hot-reload re-running this won't re-enumerate the same misses.
|
||||
"""
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
for wf in storage.list_workflows():
|
||||
@@ -373,17 +434,12 @@ def reconcile_on_startup() -> None:
|
||||
_disable_schedule(wf)
|
||||
continue
|
||||
|
||||
nra = _as_utc(wf.next_run_at)
|
||||
missed = bool(nra and nra <= now_utc)
|
||||
if missed and wf.schedule.on_missed in ("run_once", "run_all"):
|
||||
# Keep next_run_at <= now_utc so the very next tick fires it.
|
||||
# Normalize to a UTC-aware value so future comparisons don't
|
||||
# trip on naive legacy datetimes.
|
||||
wf.next_run_at = nra
|
||||
storage.save_workflow(wf)
|
||||
else:
|
||||
wf.next_run_at = _next_fire_after(wf.schedule, now_utc)
|
||||
storage.save_workflow(wf)
|
||||
anchor = _as_utc(wf.next_run_at)
|
||||
if anchor is not None and anchor <= now_utc:
|
||||
_capture_missed(wf, occurrences_between(wf, anchor, now_utc, cap=MISSED_ENUM_CAP))
|
||||
|
||||
wf.next_run_at = _next_fire_after(wf.schedule, now_utc)
|
||||
storage.save_workflow(wf)
|
||||
|
||||
|
||||
async def start() -> None:
|
||||
|
||||
@@ -15,15 +15,22 @@ from threading import Lock
|
||||
from typing import Optional
|
||||
|
||||
from backend.config.paths import DATA_ROOT
|
||||
from backend.apps.workflows.models import Workflow, WorkflowRun
|
||||
from backend.apps.workflows.models import Workflow, WorkflowRun, MissedRun
|
||||
|
||||
DATA_DIR = os.path.join(DATA_ROOT, "workflows")
|
||||
RUNS_DIR = os.path.join(DATA_DIR, "runs")
|
||||
PAUSED_FILE = os.path.join(DATA_DIR, "paused.json")
|
||||
MISSED_FILE = os.path.join(DATA_DIR, "missed.json")
|
||||
|
||||
# Hard ceiling on pending missed fires kept on disk. The review card only
|
||||
# shows 50; this just stops the file growing without bound if the user keeps
|
||||
# quitting without acting on the card.
|
||||
MAX_MISSED = 200
|
||||
|
||||
_io_lock = Lock()
|
||||
_workflow_cache: dict[str, Workflow] = {}
|
||||
_runs_cache: dict[str, list[WorkflowRun]] = {}
|
||||
_missed_cache: list[MissedRun] = []
|
||||
_cache_loaded = False
|
||||
_paused = False
|
||||
|
||||
@@ -97,6 +104,13 @@ def _load_all_from_disk() -> None:
|
||||
_paused = bool(json.load(f).get("paused", False))
|
||||
except Exception:
|
||||
_paused = False
|
||||
_missed_cache.clear()
|
||||
if os.path.exists(MISSED_FILE):
|
||||
try:
|
||||
with open(MISSED_FILE) as f:
|
||||
_missed_cache.extend(MissedRun(**m) for m in json.load(f))
|
||||
except Exception:
|
||||
_missed_cache.clear()
|
||||
_cache_loaded = True
|
||||
|
||||
|
||||
@@ -137,6 +151,9 @@ def delete_workflow(wid: str) -> bool:
|
||||
rf = _runs_path(wid)
|
||||
if os.path.exists(rf):
|
||||
os.remove(rf)
|
||||
if any(m.workflow_id == wid for m in _missed_cache):
|
||||
_missed_cache[:] = [m for m in _missed_cache if m.workflow_id != wid]
|
||||
_write_missed()
|
||||
return existed
|
||||
|
||||
|
||||
@@ -192,6 +209,51 @@ def set_paused(value: bool) -> bool:
|
||||
return _paused
|
||||
|
||||
|
||||
def _write_missed() -> None:
|
||||
with open(MISSED_FILE, "w") as f:
|
||||
json.dump([m.model_dump(mode="json") for m in _missed_cache], f, indent=2)
|
||||
|
||||
|
||||
def list_missed() -> list[MissedRun]:
|
||||
if not _cache_loaded:
|
||||
init()
|
||||
return list(_missed_cache)
|
||||
|
||||
|
||||
def add_missed(run: MissedRun) -> MissedRun:
|
||||
if not _cache_loaded:
|
||||
init()
|
||||
with _io_lock:
|
||||
_ensure_dirs()
|
||||
_missed_cache.append(run)
|
||||
# Keep the newest MAX_MISSED by scheduled_for so a never-acked card
|
||||
# can't grow the file forever across repeated launches.
|
||||
if len(_missed_cache) > MAX_MISSED:
|
||||
_missed_cache.sort(key=lambda m: m.scheduled_for)
|
||||
del _missed_cache[: len(_missed_cache) - MAX_MISSED]
|
||||
_write_missed()
|
||||
return run
|
||||
|
||||
|
||||
def remove_missed(ids: list[str]) -> None:
|
||||
if not _cache_loaded:
|
||||
init()
|
||||
drop = set(ids)
|
||||
with _io_lock:
|
||||
_ensure_dirs()
|
||||
_missed_cache[:] = [m for m in _missed_cache if m.id not in drop]
|
||||
_write_missed()
|
||||
|
||||
|
||||
def clear_missed() -> None:
|
||||
if not _cache_loaded:
|
||||
init()
|
||||
with _io_lock:
|
||||
_ensure_dirs()
|
||||
_missed_cache.clear()
|
||||
_write_missed()
|
||||
|
||||
|
||||
def update_run(run_id: str, **fields) -> Optional[WorkflowRun]:
|
||||
if not _cache_loaded:
|
||||
init()
|
||||
|
||||
@@ -14,6 +14,9 @@ from backend.apps.workflows.models import (
|
||||
WorkflowRun,
|
||||
WorkflowStep,
|
||||
DraftCommitBody,
|
||||
MissedRunAction,
|
||||
GenerateMetadataRequest,
|
||||
GenerateMetadataResponse,
|
||||
)
|
||||
from backend.apps.workflows import storage, scheduler, executor, audit, escalation
|
||||
|
||||
@@ -264,22 +267,25 @@ async def create_workflow(body: WorkflowCreate):
|
||||
# leaving stale session names ("Inbox check") as titles. Step labels
|
||||
# are the 3-6 word at-a-glance headlines surfaced in StepList; without
|
||||
# them the UI falls back to truncated raw prompts.
|
||||
try:
|
||||
title, description, labels = await _generate_workflow_metadata(wf)
|
||||
# Respect a user-supplied title (auto_named=False); only auto-fill the
|
||||
# name + description while the workflow is still auto-named. Labels are
|
||||
# always safe to fill since they don't override a user's title.
|
||||
if wf.auto_named:
|
||||
if title:
|
||||
wf.title = title
|
||||
if description:
|
||||
wf.description = description
|
||||
if labels and len(labels) == len(wf.steps):
|
||||
for i, lab in enumerate(labels):
|
||||
if lab:
|
||||
wf.steps[i].label = lab
|
||||
except Exception:
|
||||
pass
|
||||
# When the FE already generated metadata at preview time it ships the title,
|
||||
# description, and per-step labels on the body, so we skip the aux call here.
|
||||
if not body.metadata_generated:
|
||||
try:
|
||||
title, description, labels = await _generate_workflow_metadata(wf)
|
||||
# Respect a user-supplied title (auto_named=False); only auto-fill the
|
||||
# name + description while the workflow is still auto-named. Labels are
|
||||
# always safe to fill since they don't override a user's title.
|
||||
if wf.auto_named:
|
||||
if title:
|
||||
wf.title = title
|
||||
if description:
|
||||
wf.description = description
|
||||
if labels and len(labels) == len(wf.steps):
|
||||
for i, lab in enumerate(labels):
|
||||
if lab:
|
||||
wf.steps[i].label = lab
|
||||
except Exception:
|
||||
pass
|
||||
storage.save_workflow(wf)
|
||||
scheduler.kick()
|
||||
enriched = _enriched(wf)
|
||||
@@ -294,6 +300,15 @@ async def create_workflow(body: WorkflowCreate):
|
||||
return enriched
|
||||
|
||||
|
||||
@workflows.router.post("/generate-metadata")
|
||||
async def generate_workflow_metadata(body: GenerateMetadataRequest) -> GenerateMetadataResponse:
|
||||
# Preview-time naming for the convert-to-workflow draft. Generates without
|
||||
# persisting so the card can show a real title before the user saves.
|
||||
wf = Workflow(steps=body.steps, model=body.model or "sonnet")
|
||||
title, description, labels = await _generate_workflow_metadata(wf)
|
||||
return GenerateMetadataResponse(title=title, description=description, step_labels=labels)
|
||||
|
||||
|
||||
async def _generate_workflow_metadata(wf: Workflow) -> tuple[str, str, list[str]]:
|
||||
"""Single aux-model call returning (title, description, step_labels).
|
||||
|
||||
@@ -593,6 +608,66 @@ async def list_all_runs(limit: int = 200):
|
||||
return {"runs": [r.model_dump(mode="json") for r in runs]}
|
||||
|
||||
|
||||
@workflows.router.get("/missed")
|
||||
async def list_missed_runs(limit: int = 50):
|
||||
"""Pending fires that elapsed while the app was closed, newest-first.
|
||||
Backs the launch-time review card."""
|
||||
missed = sorted(storage.list_missed(), key=lambda m: m.scheduled_for, reverse=True)
|
||||
out: list[dict] = []
|
||||
for m in missed[:limit]:
|
||||
wf = storage.get_workflow(m.workflow_id)
|
||||
if not wf:
|
||||
continue
|
||||
out.append({
|
||||
"id": m.id,
|
||||
"workflow_id": m.workflow_id,
|
||||
"workflow_title": wf.title,
|
||||
"workflow_icon": wf.icon,
|
||||
"scheduled_for": m.scheduled_for.isoformat() if isinstance(m.scheduled_for, datetime) else m.scheduled_for,
|
||||
})
|
||||
return {"missed": out}
|
||||
|
||||
|
||||
@workflows.router.post("/missed/run")
|
||||
async def run_missed_runs(body: MissedRunAction):
|
||||
"""Run the selected missed fires now. Each lands in History as ran_late.
|
||||
Fires of the same workflow run sequentially (the executor blocks
|
||||
concurrent runs of one workflow)."""
|
||||
wanted = set(body.ids)
|
||||
selected = [m for m in storage.list_missed() if m.id in wanted]
|
||||
if not selected:
|
||||
return {"started": 0}
|
||||
storage.remove_missed([m.id for m in selected])
|
||||
by_wf: dict[str, list[datetime]] = {}
|
||||
for m in sorted(selected, key=lambda m: m.scheduled_for):
|
||||
by_wf.setdefault(m.workflow_id, []).append(m.scheduled_for)
|
||||
started = 0
|
||||
for wid, fors in by_wf.items():
|
||||
wf = storage.get_workflow(wid)
|
||||
if not wf:
|
||||
continue
|
||||
started += len(fors)
|
||||
asyncio.create_task(scheduler.run_missed_sequence(wf, fors))
|
||||
return {"started": started}
|
||||
|
||||
|
||||
@workflows.router.post("/missed/dismiss")
|
||||
async def dismiss_missed_runs(body: MissedRunAction):
|
||||
"""Drop the selected missed fires, logging each as a skipped run so the
|
||||
workflow's history still shows it happened."""
|
||||
wanted = set(body.ids)
|
||||
selected = [m for m in storage.list_missed() if m.id in wanted]
|
||||
storage.remove_missed([m.id for m in selected])
|
||||
dismissed = 0
|
||||
for m in selected:
|
||||
wf = storage.get_workflow(m.workflow_id)
|
||||
if not wf:
|
||||
continue
|
||||
scheduler.record_skipped(wf, m.scheduled_for, "You dismissed this missed run")
|
||||
dismissed += 1
|
||||
return {"dismissed": dismissed}
|
||||
|
||||
|
||||
@workflows.router.get("/calendar")
|
||||
async def list_calendar_events(
|
||||
from_: str = Query(..., alias="from"),
|
||||
|
||||
@@ -31,8 +31,10 @@ def isolated_data_dir(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(_storage, "DATA_DIR", str(tmp_path / "workflows"))
|
||||
monkeypatch.setattr(_storage, "RUNS_DIR", str(tmp_path / "workflows" / "runs"))
|
||||
monkeypatch.setattr(_storage, "PAUSED_FILE", str(tmp_path / "workflows" / "paused.json"))
|
||||
monkeypatch.setattr(_storage, "MISSED_FILE", str(tmp_path / "workflows" / "missed.json"))
|
||||
monkeypatch.setattr(_storage, "_workflow_cache", {})
|
||||
monkeypatch.setattr(_storage, "_runs_cache", {})
|
||||
monkeypatch.setattr(_storage, "_missed_cache", [])
|
||||
monkeypatch.setattr(_storage, "_cache_loaded", False)
|
||||
monkeypatch.setattr(_storage, "_paused", False)
|
||||
monkeypatch.setattr(_audit, "AUDIT_DIR", str(tmp_path / "workflows" / "audit"))
|
||||
@@ -154,33 +156,101 @@ async def test_paused_state_blocks_all_fires(monkeypatch):
|
||||
storage.set_paused(False)
|
||||
|
||||
|
||||
async def test_reconcile_skip_rolls_past_missed(monkeypatch):
|
||||
"""on_missed='skip' + a missed next_run_at => startup rolls forward
|
||||
to the next future fire without queuing a catch-up."""
|
||||
async def test_reconcile_captures_missed_fires(monkeypatch):
|
||||
"""A daily workflow whose next_run_at elapsed while the app was closed =>
|
||||
startup captures the missed fires as pending MissedRuns and rolls
|
||||
next_run_at forward (no auto-firing)."""
|
||||
from backend.apps.workflows import storage, scheduler
|
||||
wf = _make_wf()
|
||||
wf.schedule.on_missed = "skip"
|
||||
# Stash a missed fire 6 hours ago.
|
||||
wf.next_run_at = datetime.now(timezone.utc) - timedelta(hours=6)
|
||||
# created_at must predate the missed window; occurrences_between never
|
||||
# enumerates fires from before the workflow existed.
|
||||
wf = _make_wf(created_at=datetime.now(timezone.utc) - timedelta(days=10))
|
||||
wf.next_run_at = datetime.now(timezone.utc) - timedelta(days=3)
|
||||
storage.save_workflow(wf)
|
||||
scheduler.reconcile_on_startup()
|
||||
missed = [m for m in storage.list_missed() if m.workflow_id == wf.id]
|
||||
assert len(missed) >= 1
|
||||
assert all(m.scheduled_for < datetime.now(timezone.utc) for m in missed)
|
||||
after = storage.get_workflow(wf.id)
|
||||
assert after.next_run_at is not None
|
||||
assert after.next_run_at > datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def test_reconcile_run_once_keeps_missed(monkeypatch):
|
||||
"""on_missed='run_once' => startup leaves next_run_at in the past so
|
||||
the first tick fires a catch-up."""
|
||||
async def test_reconcile_no_missed_when_future(monkeypatch):
|
||||
"""next_run_at in the future => nothing missed, nothing captured."""
|
||||
from backend.apps.workflows import storage, scheduler
|
||||
wf = _make_wf()
|
||||
wf.schedule.on_missed = "run_once"
|
||||
missed = datetime.now(timezone.utc) - timedelta(hours=6)
|
||||
wf.next_run_at = missed
|
||||
wf = _make_wf(created_at=datetime.now(timezone.utc) - timedelta(days=10))
|
||||
wf.next_run_at = datetime.now(timezone.utc) + timedelta(hours=6)
|
||||
storage.save_workflow(wf)
|
||||
scheduler.reconcile_on_startup()
|
||||
after = storage.get_workflow(wf.id)
|
||||
assert after.next_run_at <= datetime.now(timezone.utc)
|
||||
assert [m for m in storage.list_missed() if m.workflow_id == wf.id] == []
|
||||
|
||||
|
||||
async def test_reconcile_over_cap_collapses_to_skipped(monkeypatch):
|
||||
"""A 15-minute schedule off for days => only the cap is kept reviewable;
|
||||
the rest collapse into a single skipped run in history."""
|
||||
from backend.apps.workflows import storage, scheduler
|
||||
from backend.apps.workflows.models import ScheduleConfig
|
||||
wf = _make_wf(
|
||||
created_at=datetime.now(timezone.utc) - timedelta(days=30),
|
||||
schedule=ScheduleConfig(
|
||||
enabled=True, repeat_unit="minute", repeat_every=15,
|
||||
timezone="America/Los_Angeles",
|
||||
),
|
||||
)
|
||||
wf.next_run_at = datetime.now(timezone.utc) - timedelta(days=2)
|
||||
storage.save_workflow(wf)
|
||||
scheduler.reconcile_on_startup()
|
||||
missed = [m for m in storage.list_missed() if m.workflow_id == wf.id]
|
||||
assert len(missed) == scheduler.PER_WORKFLOW_MISSED_CAP
|
||||
skipped = [r for r in storage.list_runs(wf.id, limit=50) if r.status == "skipped"]
|
||||
assert len(skipped) == 1
|
||||
|
||||
|
||||
async def test_dismiss_missed_records_skipped(monkeypatch):
|
||||
"""Dismissing a missed run drops it from pending and leaves a skipped
|
||||
run in history."""
|
||||
from backend.apps.workflows import storage
|
||||
from backend.apps.workflows.models import MissedRun, MissedRunAction
|
||||
from backend.apps.workflows.workflows import dismiss_missed_runs
|
||||
wf = _make_wf()
|
||||
storage.save_workflow(wf)
|
||||
m = MissedRun(workflow_id=wf.id, scheduled_for=datetime.now(timezone.utc) - timedelta(hours=2))
|
||||
storage.add_missed(m)
|
||||
res = await dismiss_missed_runs(MissedRunAction(ids=[m.id]))
|
||||
assert res["dismissed"] == 1
|
||||
assert storage.list_missed() == []
|
||||
skipped = [r for r in storage.list_runs(wf.id, limit=10) if r.status == "skipped"]
|
||||
assert len(skipped) == 1
|
||||
|
||||
|
||||
async def test_run_missed_runs_clears_pending_and_fires(monkeypatch):
|
||||
"""Running selected missed fires removes them from pending and invokes
|
||||
the executor once per fire, sequentially."""
|
||||
from backend.apps.workflows import storage, executor, scheduler
|
||||
from backend.apps.workflows.models import MissedRun, MissedRunAction
|
||||
from backend.apps.workflows.workflows import run_missed_runs
|
||||
|
||||
calls: list = []
|
||||
|
||||
async def fake_execute(wf, triggered_by="schedule", scheduled_for=None):
|
||||
calls.append(scheduled_for)
|
||||
from backend.apps.workflows.models import WorkflowRun
|
||||
return WorkflowRun(workflow_id=wf.id, status="ran_late", scheduled_for=scheduled_for)
|
||||
|
||||
monkeypatch.setattr(executor, "execute", fake_execute)
|
||||
|
||||
wf = _make_wf()
|
||||
storage.save_workflow(wf)
|
||||
m1 = MissedRun(workflow_id=wf.id, scheduled_for=datetime.now(timezone.utc) - timedelta(hours=3))
|
||||
m2 = MissedRun(workflow_id=wf.id, scheduled_for=datetime.now(timezone.utc) - timedelta(hours=2))
|
||||
storage.add_missed(m1)
|
||||
storage.add_missed(m2)
|
||||
res = await run_missed_runs(MissedRunAction(ids=[m1.id, m2.id]))
|
||||
assert res["started"] == 2
|
||||
assert storage.list_missed() == []
|
||||
# The endpoint spawns the sequence as a background task; give it a beat.
|
||||
await asyncio.sleep(0.05)
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
async def test_create_workflow_schedules_next_fire():
|
||||
|
||||
@@ -98,6 +98,8 @@ export function appendSelectedElements(trimmed: string, selectedEls: SelectedEle
|
||||
'tool-group': 'Tool Group',
|
||||
'view-card': 'App Card',
|
||||
'browser-card': 'Browser Card',
|
||||
'workflow-card': 'Workflow Card',
|
||||
'workflows-hub-card': 'Workflows Hub',
|
||||
'dom-element': 'Element',
|
||||
}[el.semanticType] || el.semanticType;
|
||||
lines.push(`${i + 1}. [${typeLabel}] ${el.semanticLabel || ''}`);
|
||||
|
||||
@@ -58,6 +58,7 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
|
||||
const inputSummary = mcpInfo.isMcp ? getMcpInputSummary(input, mcpInfo.action, mcpInfo.serverSlug) : '';
|
||||
const visibleSummary = resultSummary || inputSummary;
|
||||
const canToggleDetails = !!visibleSummary;
|
||||
const hideVerbLabel = !!workflowLabel && !!visibleSummary;
|
||||
const ServiceIcon = mcpInfo.isMcp && mcpInfo.service
|
||||
? <GoogleServiceIcon service={mcpInfo.service} size={14} />
|
||||
: null;
|
||||
@@ -78,20 +79,22 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
|
||||
}}
|
||||
>
|
||||
{ServiceIcon}
|
||||
<Typography
|
||||
sx={{
|
||||
color: c.accent.primary,
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{serviceLabel}
|
||||
</Typography>
|
||||
{!hideVerbLabel && (
|
||||
<Typography
|
||||
sx={{
|
||||
color: c.accent.primary,
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{serviceLabel}
|
||||
</Typography>
|
||||
)}
|
||||
{visibleSummary && !isError && (
|
||||
<Typography
|
||||
sx={{
|
||||
color: c.text.secondary,
|
||||
color: hideVerbLabel ? c.text.primary : c.text.secondary,
|
||||
fontSize: '0.74rem',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
|
||||
@@ -6,6 +6,7 @@ import BrowserCard from '../cards/BrowserCard';
|
||||
import NoteCard from '../cards/NoteCard';
|
||||
import WorkflowCard from '@/app/pages/Workflows/WorkflowCard';
|
||||
import WorkflowsHubCard from '@/app/pages/Workflows/WorkflowsHubCard';
|
||||
import MissedRunsCard from '@/app/pages/Workflows/MissedRunsCard';
|
||||
import ConfigurePanelCard from '@/app/pages/Workflows/ConfigurePanelCard';
|
||||
import {
|
||||
EXPANDED_CARD_MIN_H,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
type WorkflowsHubPosition,
|
||||
type ConfigurePanelPosition,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import type { Output } from '@/shared/state/outputsSlice';
|
||||
import type { CardType, useDashboardSelection } from '../hooks/state/useDashboardSelection';
|
||||
|
||||
@@ -100,6 +102,9 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
onBranch,
|
||||
onMeasuredHeight,
|
||||
}) => {
|
||||
// Ephemeral singleton, not part of the saved layout, so read it straight
|
||||
// from the store rather than threading it through the selector chain.
|
||||
const missedRunsCard = useAppSelector((s) => s.dashboardLayout.missedRunsCard);
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
@@ -286,6 +291,26 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
onBringToFront={onBringToFront}
|
||||
/>
|
||||
)}
|
||||
{missedRunsCard && (
|
||||
<MissedRunsCard
|
||||
cardX={missedRunsCard.x}
|
||||
cardY={missedRunsCard.y}
|
||||
cardWidth={missedRunsCard.width}
|
||||
cardHeight={missedRunsCard.height}
|
||||
cardZOrder={missedRunsCard.zOrder ?? 0}
|
||||
zoom={zoom}
|
||||
panX={panX}
|
||||
panY={panY}
|
||||
isSelected={selection.isSelected('missed-runs')}
|
||||
isHighlighted={highlightedCardId === 'missed-runs'}
|
||||
multiDragDelta={selection.isSelected('missed-runs') ? multiDragDelta : null}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onBringToFront={onBringToFront}
|
||||
/>
|
||||
)}
|
||||
{Object.values(workflowCards).map((wc) => (
|
||||
<WorkflowCard
|
||||
key={`workflow-${wc.workflow_id}`}
|
||||
|
||||
@@ -6,6 +6,7 @@ import CanvasControls from '../controls/CanvasControls';
|
||||
import CardSearchPalette from '../controls/CardSearchPalette';
|
||||
import DirectionHints from '../controls/DirectionHints';
|
||||
import WorkflowRunningToast from '@/app/pages/Workflows/WorkflowRunningToast';
|
||||
import MissedRunsToast from '@/app/pages/Workflows/MissedRunsToast';
|
||||
import type { AgentSession } from '@/shared/state/agentsSlice';
|
||||
import type {
|
||||
CardPosition,
|
||||
@@ -151,6 +152,9 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
|
||||
|
||||
{/* Scheduled-run nudge: "your {workflow} is running now" + jump-to-canvas */}
|
||||
<WorkflowRunningToast />
|
||||
|
||||
{/* Launch nudge when scheduled runs elapsed while the app was closed */}
|
||||
<MissedRunsToast />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ import { useDashboardActive } from '@/shared/hooks/useDashboardActive';
|
||||
import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough';
|
||||
import { useStreamingMessage } from '@/shared/state/streamingSlice';
|
||||
import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canvasInteractionState';
|
||||
import { openWorkflowCard, setCardSidecar, type Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { openWorkflowCard, updateWorkflowCard, generateWorkflowMetadata, applyGeneratedMetadata, setCardSidecar, type Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { addWorkflowCard, setWorkflowCardPosition, setWorkflowCardSize } from '@/shared/state/dashboardLayoutSlice';
|
||||
import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined';
|
||||
import { getAgentWorkTime, fmtSeconds } from '@/shared/agentWorkTime';
|
||||
@@ -432,8 +432,11 @@ const AgentCard: React.FC<Props> = ({
|
||||
workflowId: draftId,
|
||||
sourceSessionId: session.id,
|
||||
view: 'preview',
|
||||
metaLoading: true,
|
||||
draft: {
|
||||
title: session.name || 'New workflow',
|
||||
// Empty so the header shows its calm "New workflow" placeholder while
|
||||
// naming runs, instead of flashing the stale chat name.
|
||||
title: '',
|
||||
description: '',
|
||||
steps,
|
||||
source_session_id: session.id,
|
||||
@@ -443,6 +446,15 @@ const AgentCard: React.FC<Props> = ({
|
||||
suggested_cadence: workflowSuggestion?.cadence || undefined,
|
||||
} as Partial<Workflow>,
|
||||
}));
|
||||
const genModel = defaultModel || session.model;
|
||||
dispatch(generateWorkflowMetadata({ steps: steps.map((s) => ({ id: s.id, text: s.text })), model: genModel }))
|
||||
.then((r) => {
|
||||
if (generateWorkflowMetadata.fulfilled.match(r)) {
|
||||
dispatch(applyGeneratedMetadata({ workflowId: draftId, meta: r.payload }));
|
||||
} else {
|
||||
dispatch(updateWorkflowCard({ workflowId: draftId, patch: { metaLoading: false } }));
|
||||
}
|
||||
});
|
||||
}, [
|
||||
cardHeight,
|
||||
cardWidth,
|
||||
@@ -494,7 +506,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
suggestionPulseRef.current = key;
|
||||
}
|
||||
setSuggestGlowCycle((n) => n + 1);
|
||||
dispatch(fadeGlowingAgentCard(session.id, 3200));
|
||||
dispatch(fadeGlowingAgentCard(session.id));
|
||||
}, [workflowSuggestion, canConvertToWorkflow, dispatch, session.id]);
|
||||
|
||||
// When the agent schedules a workflow from this chat, pop its card open
|
||||
|
||||
@@ -74,35 +74,6 @@ function rectCenter(r: CanvasRect): { x: number; y: number } {
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||
}
|
||||
|
||||
function selectCardElement(content: HTMLElement, type: 'agent-card' | 'workflow-card', id: string): HTMLElement | null {
|
||||
const candidates = content.querySelectorAll<HTMLElement>(`[data-select-type="${type}"]`);
|
||||
for (const el of Array.from(candidates)) {
|
||||
if (el.dataset.selectId === id) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function measuredCanvasRect(
|
||||
contentRef: RefObject<HTMLElement>,
|
||||
zoom: number,
|
||||
type: 'agent-card' | 'workflow-card',
|
||||
id: string,
|
||||
): CanvasRect | null {
|
||||
const content = contentRef.current;
|
||||
if (!content) return null;
|
||||
const el = selectCardElement(content, type, id);
|
||||
if (!el) return null;
|
||||
const contentRect = content.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const z = zoom || 1;
|
||||
return {
|
||||
x: (elRect.left - contentRect.left) / z,
|
||||
y: (elRect.top - contentRect.top) / z,
|
||||
width: elRect.width / z,
|
||||
height: elRect.height / z,
|
||||
};
|
||||
}
|
||||
|
||||
interface UseTethersArgs {
|
||||
glowingAgentCards: Record<string, GlowingAgentCard>;
|
||||
glowingBrowserCards: Record<string, GlowingBrowserCard>;
|
||||
@@ -116,8 +87,6 @@ interface UseTethersArgs {
|
||||
liveDragInfo: LiveDragInfo | null;
|
||||
measuredHeightsRef: RefObject<Record<string, number>>;
|
||||
measuredHeightsTick: number;
|
||||
contentRef: RefObject<HTMLElement>;
|
||||
zoom: number;
|
||||
sessionList: AgentSession[];
|
||||
}
|
||||
|
||||
@@ -134,8 +103,6 @@ export function useTethers({
|
||||
liveDragInfo,
|
||||
measuredHeightsRef,
|
||||
measuredHeightsTick,
|
||||
contentRef,
|
||||
zoom,
|
||||
sessionList,
|
||||
}: UseTethersArgs): Tether[] {
|
||||
return useMemo(() => {
|
||||
@@ -395,10 +362,8 @@ export function useTethers({
|
||||
? Math.max(EXPANDED_CARD_MIN_H, sidecar.height)
|
||||
: sidecar.height);
|
||||
const wcH = wfHeight(wc);
|
||||
const measuredWorkflow = measuredCanvasRect(contentRef, zoom, 'workflow-card', wc.workflow_id);
|
||||
const measuredSidecar = measuredCanvasRect(contentRef, zoom, 'agent-card', sidecarId);
|
||||
const workflowRect = measuredWorkflow ?? { x: srcX, y: srcY, width: wc.width, height: wcH };
|
||||
const sidecarRect = measuredSidecar ?? { x: dstX, y: dstY, width: sidecar.width, height: dstH };
|
||||
const workflowRect = { x: srcX, y: srcY, width: wc.width, height: wcH };
|
||||
const sidecarRect = { x: dstX, y: dstY, width: sidecar.width, height: dstH };
|
||||
const srcCenter = rectCenter(workflowRect);
|
||||
const dstCenter = rectCenter(sidecarRect);
|
||||
const a = borderPoint(workflowRect.x, workflowRect.y, workflowRect.width, workflowRect.height, dstCenter.x, dstCenter.y);
|
||||
@@ -471,5 +436,5 @@ export function useTethers({
|
||||
// measuredHeightsTick re-runs the memo once ResizeObserver reports a new
|
||||
// height after a collapse (the ref read is invisible to the dep checker).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, configurePanels, expandedSessionIds, liveDragInfo, measuredHeightsTick, contentRef, zoom, sessionList]);
|
||||
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, configurePanels, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]);
|
||||
}
|
||||
|
||||
@@ -123,7 +123,10 @@ export function useDashboardCardActions({
|
||||
dispatch(tidyLayout({ expandedSessionIds: currentExpanded }));
|
||||
|
||||
const expandedSet = new Set(currentExpanded);
|
||||
const { cards: tidied, viewCards: tidiedViews, browserCards: tidiedBrowsers } = store.getState().dashboardLayout;
|
||||
const {
|
||||
cards: tidied, viewCards: tidiedViews, browserCards: tidiedBrowsers,
|
||||
workflowCards: tidiedWorkflows, workflowsHub: tidiedHub,
|
||||
} = store.getState().dashboardLayout;
|
||||
const allRects = [
|
||||
...Object.values(tidied).map((c) => ({
|
||||
x: c.x, y: c.y, width: c.width,
|
||||
@@ -131,6 +134,8 @@ export function useDashboardCardActions({
|
||||
})),
|
||||
...Object.values(tidiedViews).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
...Object.values(tidiedBrowsers).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
...Object.values(tidiedWorkflows).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
...(tidiedHub ? [{ x: tidiedHub.x, y: tidiedHub.y, width: tidiedHub.width, height: tidiedHub.height }] : []),
|
||||
];
|
||||
canvasActions.fitToCards(allRects);
|
||||
}, [dispatch, canvasActions]);
|
||||
|
||||
@@ -17,17 +17,23 @@ import {
|
||||
clearPendingFocusBrowserId,
|
||||
clearPendingFocusWorkflowId,
|
||||
clearPendingFocusWorkflowsHub,
|
||||
clearPendingFocusMissedRuns,
|
||||
type ViewCardPosition,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { fetchOutputs, type Output } from '@/shared/state/outputsSlice';
|
||||
import { generateDashboardName } from '@/shared/state/dashboardsSlice';
|
||||
import { fetchWorkflows } from '@/shared/state/workflowsSlice';
|
||||
import { fetchMissedRuns } from '@/shared/state/missedRunsSlice';
|
||||
import { dashboardWs } from '@/shared/ws/WebSocketManager';
|
||||
import { initBrowserCommandHandler } from '@/shared/browserCommandHandler';
|
||||
import { clearPendingBrowserUrl, clearPendingFocusAgentId } from '@/shared/state/tempStateSlice';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import type { CanvasActions } from '../interaction/useCanvasControls';
|
||||
|
||||
// Module-level so the missed-runs review pops exactly once per app launch,
|
||||
// not again on every dashboard switch.
|
||||
let missedRunsCheckedThisSession = false;
|
||||
|
||||
interface UseDashboardLifecycleArgs {
|
||||
isActive: boolean;
|
||||
dashboardId: string;
|
||||
@@ -64,8 +70,18 @@ export function useDashboardLifecycle({
|
||||
const pendingFocusAgentId = useAppSelector((state) => state.tempState.pendingFocusAgentId);
|
||||
const pendingFocusBrowserId = useAppSelector((state) => state.dashboardLayout.pendingFocusBrowserId);
|
||||
const pendingFocusWorkflowId = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowId);
|
||||
const pendingFocusMissedRuns = useAppSelector((state) => state.dashboardLayout.pendingFocusMissedRuns);
|
||||
const pendingFocusWorkflowsHub = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowsHub);
|
||||
|
||||
// Once per app launch: if scheduled fires elapsed while we were closed, fetch
|
||||
// them. The slice flips its toast flag on fulfilled, so a bottom-left nudge
|
||||
// shows instead of a card popping unrequested; the user opens the card from it.
|
||||
useEffect(() => {
|
||||
if (!isActive || missedRunsCheckedThisSession) return;
|
||||
missedRunsCheckedThisSession = true;
|
||||
dispatch(fetchMissedRuns());
|
||||
}, [isActive, dispatch]);
|
||||
|
||||
// Track dashboard engagement time
|
||||
useEffect(() => {
|
||||
if (!dashboardId) return;
|
||||
@@ -248,6 +264,24 @@ export function useDashboardLifecycle({
|
||||
}, 200);
|
||||
}, [isActive, pendingFocusWorkflowId, layoutInitialized, dispatch, canvasActions, handleHighlightCard]);
|
||||
|
||||
// Same pan/highlight choreography when the missed-runs card opens from its toast.
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
if (!pendingFocusMissedRuns || !layoutInitialized) return;
|
||||
dispatch(clearPendingFocusMissedRuns());
|
||||
setTimeout(() => {
|
||||
const card = store.getState().dashboardLayout.missedRunsCard;
|
||||
if (card) {
|
||||
canvasActions.fitToCards(
|
||||
[{ x: card.x, y: card.y, width: card.width, height: card.height }],
|
||||
1.15,
|
||||
true,
|
||||
);
|
||||
handleHighlightCard('missed-runs');
|
||||
}
|
||||
}, 200);
|
||||
}, [isActive, pendingFocusMissedRuns, layoutInitialized, dispatch, canvasActions, handleHighlightCard]);
|
||||
|
||||
// Pan/zoom to Workflows Hub on Expand; chained rAFs ensure fit runs after the hub div lands at its new coords.
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
|
||||
@@ -288,8 +288,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
liveDragInfo,
|
||||
measuredHeightsRef,
|
||||
measuredHeightsTick,
|
||||
contentRef: canvas.contentRef,
|
||||
zoom: canvas.zoom,
|
||||
sessionList,
|
||||
});
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ export default function EditAgentView({ workflow, steps, isFixMode = false, onEd
|
||||
title={canSave ? undefined : 'Add at least one step before saving'}
|
||||
sx={{
|
||||
fontSize: '0.8rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary,
|
||||
px: 1.2, py: 0.35, borderRadius: 999, cursor: canSave ? 'pointer' : 'not-allowed',
|
||||
px: 1.2, py: 0.35, borderRadius: c.radius.full, cursor: canSave ? 'pointer' : 'not-allowed',
|
||||
opacity: canSave ? 1 : 0.45,
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import {
|
||||
closeMissedRunsCard,
|
||||
setMissedRunsCardPosition,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import {
|
||||
runMissedRuns,
|
||||
dismissMissedRuns,
|
||||
type MissedRunItem,
|
||||
} from '@/shared/state/missedRunsSlice';
|
||||
|
||||
// Above this many selected, "Run" asks once before firing: each missed run is
|
||||
// a real agent run, so a fat-fingered Run-all shouldn't quietly spend money.
|
||||
const CONFIRM_THRESHOLD = 10;
|
||||
|
||||
interface Props {
|
||||
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: 'missed_runs', shiftKey: boolean) => void;
|
||||
onDragStart?: (id: string, type: 'missed_runs') => void;
|
||||
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
|
||||
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
|
||||
onBringToFront?: (id: string, type: 'missed_runs') => void;
|
||||
}
|
||||
|
||||
function formatWhen(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString(undefined, {
|
||||
weekday: 'short', month: 'short', day: 'numeric',
|
||||
hour: 'numeric', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
const MissedRunsCard: React.FC<Props> = ({
|
||||
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
|
||||
zoom = 1, panX = 0, panY = 0,
|
||||
isSelected = false, isHighlighted = false, multiDragDelta = null,
|
||||
onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const items = useAppSelector((s) => s.missedRuns.items);
|
||||
|
||||
// Unchecked ids; default is everything checked. Run acts on the checked set.
|
||||
const [unchecked, setUnchecked] = useState<Set<string>>(new Set());
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const selectedIds = useMemo(
|
||||
() => items.filter((m) => !unchecked.has(m.id)).map((m) => m.id),
|
||||
[items, unchecked],
|
||||
);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const by = new Map<string, { title: string; runs: MissedRunItem[] }>();
|
||||
for (const m of items) {
|
||||
const g = by.get(m.workflow_id) || { title: m.workflow_title, runs: [] };
|
||||
g.runs.push(m);
|
||||
by.set(m.workflow_id, g);
|
||||
}
|
||||
return Array.from(by.values());
|
||||
}, [items]);
|
||||
|
||||
// Once everything has been run or dismissed, the card has nothing left to say.
|
||||
useEffect(() => {
|
||||
if (items.length === 0) dispatch(closeMissedRunsCard());
|
||||
}, [items.length, dispatch]);
|
||||
|
||||
const toggle = useCallback((id: string) => {
|
||||
setConfirming(false);
|
||||
setUnchecked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const runSelected = useCallback(() => {
|
||||
if (selectedIds.length === 0) return;
|
||||
if (selectedIds.length > CONFIRM_THRESHOLD && !confirming) {
|
||||
setConfirming(true);
|
||||
return;
|
||||
}
|
||||
setConfirming(false);
|
||||
dispatch(runMissedRuns(selectedIds));
|
||||
}, [dispatch, selectedIds, confirming]);
|
||||
|
||||
// Closing means "I'm done": drop whatever's still listed, logged as skipped.
|
||||
const closeAndDismissRest = useCallback(() => {
|
||||
const rest = items.map((m) => m.id);
|
||||
if (rest.length) dispatch(dismissMissedRuns(rest));
|
||||
dispatch(closeMissedRunsCard());
|
||||
}, [dispatch, items]);
|
||||
|
||||
// ---- Card drag via header (mirrors WorkflowsHubCard) ----
|
||||
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 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')) 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);
|
||||
onDragStart?.('missed-runs', 'missed_runs');
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, [cardX, cardY, onDragStart]);
|
||||
|
||||
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;
|
||||
const dx = rawDx / z - panDx;
|
||||
const dy = rawDy / z - panDy;
|
||||
setLocalDragPos({ x: dragState.current.origX + dx, y: dragState.current.origY + dy });
|
||||
onDragMove?.(dx, dy, e.clientX, e.clientY);
|
||||
}, [onDragMove]);
|
||||
|
||||
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) {
|
||||
justDraggedRef.current = true;
|
||||
setTimeout(() => { justDraggedRef.current = false; }, 0);
|
||||
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(setMissedRunsCardPosition({ x: finalX, y: finalY }));
|
||||
}
|
||||
onDragEnd?.(dx, dy, didDrag.current);
|
||||
dragState.current = null;
|
||||
didDrag.current = false;
|
||||
setLocalDragPos(null);
|
||||
setIsDragging(false);
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [dispatch, onDragEnd]);
|
||||
|
||||
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
|
||||
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
|
||||
const dx = (localDragPos?.x ?? cardX) + mdDx;
|
||||
const dy = (localDragPos?.y ?? cardY) + mdDy;
|
||||
const border = isHighlighted
|
||||
? `2px solid ${c.accent.primary}`
|
||||
: isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.strong}`;
|
||||
const shadow = isDragging ? c.shadow.lg : isSelected ? `0 0 0 1px #3b82f6, ${c.shadow.md}` : c.shadow.sm;
|
||||
|
||||
const runLabel = confirming
|
||||
? `Run ${selectedIds.length} now?`
|
||||
: selectedIds.length === items.length
|
||||
? `Run all ${items.length}`
|
||||
: `Run ${selectedIds.length} selected`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-select-type="missed-runs-card"
|
||||
data-select-id="missed-runs"
|
||||
onPointerDownCapture={(e: React.PointerEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-no-drag]')) return;
|
||||
onBringToFront?.('missed-runs', 'missed_runs');
|
||||
}}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (justDraggedRef.current) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-no-drag]')) return;
|
||||
onCardSelect?.('missed-runs', 'missed_runs', e.shiftKey);
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
contain: 'layout style',
|
||||
willChange: 'transform',
|
||||
left: dx,
|
||||
top: dy,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
bgcolor: c.bg.surface,
|
||||
border,
|
||||
borderRadius: 3,
|
||||
boxShadow: shadow,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: isDragging ? 999999 : cardZOrder,
|
||||
transition: isDragging ? 'none' : 'box-shadow 0.3s ease, border-color 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{/* Title strip (drag handle) */}
|
||||
<Box
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
onPointerMove={onHeaderPointerMove}
|
||||
onPointerUp={onHeaderPointerUp}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 0.6,
|
||||
px: 1.5, py: 0.7,
|
||||
borderBottom: `1px solid ${c.border.subtle}`,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
touchAction: 'none', userSelect: 'none', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<HistoryRoundedIcon sx={{ fontSize: 17, color: c.accent.primary }} />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '0.95rem', color: c.text.primary }}>Missed while you were away</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>
|
||||
{items.length} run{items.length === 1 ? '' : 's'} didn't fire. Run the ones you still want.
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
data-no-drag
|
||||
onClick={(e) => { e.stopPropagation(); closeAndDismissRest(); }}
|
||||
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>
|
||||
|
||||
{/* Scrollable list grouped by workflow */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 1, py: 0.5 }}>
|
||||
{groups.map((g) => (
|
||||
<Box key={g.title + g.runs[0].workflow_id} sx={{ mb: 0.75 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 0.75, py: 0.4 }}>
|
||||
<Typography sx={{ fontSize: '0.82rem', fontWeight: 700, color: c.accent.primary, flexShrink: 0 }}>{g.runs.length}</Typography>
|
||||
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.secondary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{g.title}</Typography>
|
||||
</Box>
|
||||
{g.runs.map((m) => (
|
||||
<Box
|
||||
key={m.id}
|
||||
data-no-drag
|
||||
onClick={() => toggle(m.id)}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 0.4,
|
||||
pl: 1, pr: 0.75, py: 0.15, ml: 1.5,
|
||||
borderRadius: `${c.radius.sm}px`, cursor: 'pointer',
|
||||
'&:hover': { bgcolor: c.bg.elevated },
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={!unchecked.has(m.id)}
|
||||
onChange={() => toggle(m.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
sx={{ p: 0.25, color: c.text.muted, '&.Mui-checked': { color: c.accent.primary } }}
|
||||
/>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary }}>{formatWhen(m.scheduled_for)}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Footer actions */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, px: 1.25, py: 0.85, borderTop: `1px solid ${c.border.subtle}`, flexShrink: 0 }}>
|
||||
{confirming && (
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, flex: 1 }}>
|
||||
That's {selectedIds.length} real runs.
|
||||
</Typography>
|
||||
)}
|
||||
{!confirming && <Box sx={{ flex: 1 }} />}
|
||||
<Box
|
||||
data-no-drag
|
||||
role="button"
|
||||
onClick={confirming ? () => setConfirming(false) : closeAndDismissRest}
|
||||
sx={{ fontSize: '0.78rem', color: c.text.muted, cursor: 'pointer', px: 1, py: 0.5, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
{confirming ? 'Cancel' : 'Skip the rest'}
|
||||
</Box>
|
||||
<Box
|
||||
data-no-drag
|
||||
role="button"
|
||||
onClick={runSelected}
|
||||
sx={{
|
||||
fontSize: '0.78rem', fontWeight: 600,
|
||||
color: selectedIds.length === 0 ? c.text.ghost : '#fff',
|
||||
bgcolor: selectedIds.length === 0 ? c.bg.secondary : c.accent.primary,
|
||||
cursor: selectedIds.length === 0 ? 'default' : 'pointer',
|
||||
px: 1.25, py: 0.5, borderRadius: `${c.radius.md}px`,
|
||||
'&:hover': { bgcolor: selectedIds.length === 0 ? c.bg.secondary : c.accent.hover },
|
||||
}}
|
||||
>
|
||||
{runLabel}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MissedRunsCard;
|
||||
@@ -0,0 +1,63 @@
|
||||
// Bottom-left nudge shown on launch when scheduled runs elapsed while the app
|
||||
// was closed. It stays put until the user acts (no auto-hide): Review opens and
|
||||
// pans the canvas to the missed-runs card; clicking away or the X dismisses it.
|
||||
|
||||
import React from 'react';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Button from '@mui/material/Button';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { hideMissedRunsToast } from '@/shared/state/missedRunsSlice';
|
||||
import { openMissedRunsCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
|
||||
export default function MissedRunsToast() {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const open = useAppSelector((s) => s.missedRuns.toastOpen);
|
||||
const count = useAppSelector((s) => s.missedRuns.items.length);
|
||||
|
||||
const onReview = React.useCallback(() => {
|
||||
dispatch(openMissedRunsCard(undefined));
|
||||
dispatch(hideMissedRunsToast());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={open && count > 0}
|
||||
autoHideDuration={null}
|
||||
onClose={() => dispatch(hideMissedRunsToast())}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
>
|
||||
<Alert
|
||||
icon={false}
|
||||
severity="info"
|
||||
sx={{
|
||||
bgcolor: c.bg.surface,
|
||||
color: c.text.primary,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
'& .MuiAlert-action': { alignItems: 'center', pt: 0 },
|
||||
}}
|
||||
action={
|
||||
<>
|
||||
<Button size="small" onClick={onReview} sx={{ color: c.accent.primary, fontWeight: 700 }}>
|
||||
Review
|
||||
</Button>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Dismiss"
|
||||
onClick={() => dispatch(hideMissedRunsToast())}
|
||||
sx={{ color: c.text.muted, ml: 0.25, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{`${count} scheduled run${count === 1 ? '' : 's'} ${count === 1 ? 'was' : 'were'} missed while you were away`}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
@@ -12,6 +12,7 @@ import type { Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { runWorkflowNow, deleteWorkflow, updateWorkflow, openWorkflowCard } from '@/shared/state/workflowsSlice';
|
||||
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { WEEKDAY_FULL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, formatTime, formatHourLabel, stepsSignature } from './scheduleUtils';
|
||||
import { useWindowedList } from '@/shared/hooks/useWindowedList';
|
||||
|
||||
interface Props {
|
||||
view: 'Week' | 'Month' | 'List';
|
||||
@@ -25,11 +26,24 @@ interface Props {
|
||||
// starting hour. The scroll container caps the visible window.
|
||||
const HOURS_24 = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
// At/above this many list rows (day headers + event rows), window the list so
|
||||
// only near-viewport rows stay mounted. Below it, render whole; spacers aren't
|
||||
// worth the churn on a short list.
|
||||
const LIST_WINDOW_MIN_ROWS = 60;
|
||||
|
||||
interface CalendarEvent {
|
||||
workflow_id: string;
|
||||
fire_at: string;
|
||||
}
|
||||
|
||||
// One flattened list row. Windowing unmounts at this granularity, so a dense
|
||||
// single day no longer mounts all ~96 of its rows just for being near the
|
||||
// viewport: only the rows actually in view (plus buffer) stay in the DOM.
|
||||
type ListRow =
|
||||
| { kind: 'header'; id: string; date: Date; isToday: boolean }
|
||||
| { kind: 'event'; id: string; ev: { workflow: Workflow; date: Date } }
|
||||
| { kind: 'empty'; id: string };
|
||||
|
||||
export default function ScheduleCalendar({ view, density, onSelectWorkflow, refDate }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -106,18 +120,32 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
const rangeEndExclusive = useMemo(() => addDays(rangeStart, range), [rangeStart, range]);
|
||||
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
|
||||
const [calendarFetchKey, setCalendarFetchKey] = useState('');
|
||||
// Key off only the fields that change which occurrences exist. Deliberately
|
||||
// NOT updated_at: the scheduler bumps it every tick (recomputing next_run_at)
|
||||
// and pushes a workflow:updated over the socket, which would churn this key
|
||||
// and blank the calendar (the eventsByDay gate) until the next fetch lands.
|
||||
const workflowScheduleKey = workflows
|
||||
.map((w) => `${w.id}:${w.updated_at}:${w.schedule.enabled}:${w.schedule.timezone}:${w.schedule.repeat_unit}:${w.schedule.repeat_every}:${w.schedule.hour}:${w.schedule.minute}:${w.schedule.on_days.join(',')}:${w.schedule.ends_at || ''}:${w.schedule.max_runs ?? ''}:${w.schedule.runs_count}`)
|
||||
.map((w) => `${w.id}:${w.schedule.enabled}:${w.schedule.timezone}:${w.schedule.repeat_unit}:${w.schedule.repeat_every}:${w.schedule.hour}:${w.schedule.minute}:${w.schedule.on_days.join(',')}:${w.schedule.ends_at || ''}:${w.schedule.max_runs ?? ''}:${w.schedule.runs_count}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
const fromIso = rangeStart.toISOString();
|
||||
const toIso = rangeEndExclusive.toISOString();
|
||||
const calendarRequestKey = `${view}:${fromIso}:${toIso}:${workflowScheduleKey}`;
|
||||
// The visible window alone decides whether shown events are even plausible.
|
||||
// Gating on this (not the full request key) means a schedule edit refetches
|
||||
// without blanking the calendar first: we keep the current events until the
|
||||
// fresh ones land. Only a view/date change, where old events are for the
|
||||
// wrong window, clears them.
|
||||
const calendarWindowKey = `${view}:${fromIso}:${toIso}`;
|
||||
|
||||
useEffect(() => {
|
||||
// No AbortController: the global fetch interceptor (shared/config) dedupes
|
||||
// GETs by URL onto ONE underlying request, so aborting on cleanup (which
|
||||
// fires when this effect re-runs as workflows hydrate) rejects the shared
|
||||
// request and the re-fired fetch with it, leaving the calendar empty on
|
||||
// first load. The `cancelled` guard already stops stale state writes.
|
||||
let cancelled = false;
|
||||
const ctrl = new AbortController();
|
||||
fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}`, { signal: ctrl.signal })
|
||||
fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`calendar failed ${res.status}`);
|
||||
return res.json();
|
||||
@@ -125,22 +153,20 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setCalendarEvents((data.events || []) as CalendarEvent[]);
|
||||
setCalendarFetchKey(calendarRequestKey);
|
||||
setCalendarFetchKey(calendarWindowKey);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setCalendarEvents([]);
|
||||
setCalendarFetchKey(calendarRequestKey);
|
||||
setCalendarFetchKey(calendarWindowKey);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
ctrl.abort();
|
||||
};
|
||||
}, [fromIso, toIso, calendarRequestKey]);
|
||||
|
||||
const eventsByDay = useMemo(() => {
|
||||
const map = new Map<string, { workflow: Workflow; date: Date }[]>();
|
||||
if (calendarFetchKey !== calendarRequestKey) {
|
||||
if (calendarFetchKey !== calendarWindowKey) {
|
||||
return { map, start: rangeStart, end: rangeEndExclusive, key: calendarFetchKey };
|
||||
}
|
||||
const workflowById = new Map(workflows.map((wf) => [wf.id, wf]));
|
||||
@@ -158,7 +184,51 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
arr.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
}
|
||||
return { map, start: rangeStart, end: rangeEndExclusive, key: calendarFetchKey };
|
||||
}, [calendarEvents, calendarFetchKey, calendarRequestKey, workflows, rangeStart, rangeEndExclusive]);
|
||||
}, [calendarEvents, calendarFetchKey, calendarWindowKey, workflows, rangeStart, rangeEndExclusive]);
|
||||
|
||||
// List view can fan out to ~1300 rows for a dense schedule (every 15 min over
|
||||
// 14 days). Flatten days into rows and window at the row level so off-screen
|
||||
// rows unmount instead of weighing the whole app down. Computed up here (not
|
||||
// in the List branch) so the windowing hook runs before the Week/Month early
|
||||
// returns.
|
||||
const upcoming = useMemo(() => {
|
||||
const out: { date: Date; events: { workflow: Workflow; date: Date }[]; isToday: boolean }[] = [];
|
||||
for (let i = 0; i < 14; i += 1) {
|
||||
const day = addDays(today, i);
|
||||
const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`;
|
||||
const arr = eventsByDay.map.get(key) || [];
|
||||
const isToday = sameDay(day, today);
|
||||
if (arr.length || isToday) out.push({ date: day, events: arr, isToday });
|
||||
}
|
||||
return out;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [eventsByDay, dayKey]);
|
||||
const rows = useMemo<ListRow[]>(() => {
|
||||
const out: ListRow[] = [];
|
||||
for (const day of upcoming) {
|
||||
const iso = day.date.toISOString();
|
||||
out.push({ kind: 'header', id: `h:${iso}`, date: day.date, isToday: day.isToday });
|
||||
if (day.events.length === 0) {
|
||||
out.push({ kind: 'empty', id: `x:${iso}` });
|
||||
} else {
|
||||
for (const ev of day.events) {
|
||||
out.push({ kind: 'event', id: `${iso}#${ev.workflow.id}#${ev.date.getTime()}`, ev });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [upcoming]);
|
||||
const rowIds = useMemo(() => rows.map((r) => r.id), [rows]);
|
||||
const estimateRowHeight = useCallback((index: number) => {
|
||||
const r = rows[index];
|
||||
if (!r) return 41;
|
||||
return r.kind === 'header' ? 52 : r.kind === 'empty' ? 36 : 41;
|
||||
}, [rows]);
|
||||
const windowing = useWindowedList({
|
||||
ids: rowIds,
|
||||
estimateHeight: estimateRowHeight,
|
||||
enabled: view === 'List' && rows.length >= LIST_WINDOW_MIN_ROWS,
|
||||
});
|
||||
|
||||
const SLOT_H = compact ? 32 : 44;
|
||||
const ROW_LABEL = compact ? '0.7rem' : '0.74rem';
|
||||
@@ -337,69 +407,81 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
);
|
||||
}
|
||||
|
||||
// Apple-Calendar-style list: big day number + weekday on the left, a
|
||||
// vertical colored bar separating it from events on the right. Today
|
||||
// renders even with no events (shows a "No events today" placeholder)
|
||||
// so the list doesn't feel empty for new users.
|
||||
const upcoming: { date: Date; events: { workflow: Workflow; date: Date }[]; isToday: boolean }[] = [];
|
||||
for (let i = 0; i < 14; i += 1) {
|
||||
const day = addDays(today, i);
|
||||
const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`;
|
||||
const arr = eventsByDay.map.get(key) || [];
|
||||
const isToday = sameDay(day, now);
|
||||
if (arr.length || isToday) upcoming.push({ date: day, events: arr, isToday });
|
||||
}
|
||||
// Apple-Calendar-style list: each day is a stacked group with the date as a
|
||||
// header and its events listed underneath, so a busy day stays readable top
|
||||
// to bottom instead of crammed beside a date column. Today renders even with
|
||||
// no events (shows a "No events today" placeholder)
|
||||
// so the list doesn't feel empty for new users. Off-screen day groups
|
||||
// unmount (useWindowedList) and leave a measured-height spacer behind, so a
|
||||
// dense schedule stays light no matter how far down you scroll.
|
||||
const accent = c.accent.primary;
|
||||
const visibleRows = rows.slice(windowing.start, windowing.end);
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.lg}px`, overflow: 'hidden', bgcolor: c.bg.surface }}>
|
||||
{upcoming.length === 0 && (
|
||||
<Box
|
||||
ref={windowing.setScrollEl}
|
||||
onScroll={windowing.onScroll}
|
||||
sx={{ display: 'flex', flexDirection: 'column', maxHeight: '100%', overflow: 'auto', overflowAnchor: 'auto', bgcolor: c.bg.surface }}>
|
||||
{rows.length === 0 && (
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.muted, textAlign: 'center', py: 3 }}>No scheduled</Typography>
|
||||
)}
|
||||
{upcoming.map(({ date, events, isToday }, rowIdx) => (
|
||||
<Box
|
||||
key={date.toISOString()}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'stretch',
|
||||
borderTop: rowIdx === 0 ? 'none' : `1px dashed ${c.border.subtle}`,
|
||||
minHeight: 64,
|
||||
}}>
|
||||
<Box sx={{ width: 96, flexShrink: 0, display: 'flex', alignItems: 'center', gap: 0.75, pl: 2, pr: 1.25 }}>
|
||||
<Typography sx={{ fontSize: '1.55rem', fontWeight: 600, color: isToday ? accent : c.text.primary, lineHeight: 1, letterSpacing: '-0.01em' }}>
|
||||
{date.getDate()}
|
||||
</Typography>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: isToday ? accent : c.text.secondary, fontWeight: 500, lineHeight: 1.2 }}>
|
||||
{date.toLocaleString('en', { month: 'short' })}
|
||||
{windowing.topSpacer > 0 && (
|
||||
<Box aria-hidden sx={{ height: windowing.topSpacer, flexShrink: 0, overflowAnchor: 'none' }} />
|
||||
)}
|
||||
{visibleRows.map((row, i) => {
|
||||
const rowIdx = windowing.start + i;
|
||||
if (row.kind === 'header') {
|
||||
return (
|
||||
<Box
|
||||
key={row.id}
|
||||
data-wl-id={row.id}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'baseline', gap: 0.75,
|
||||
px: 2, pt: rowIdx === 0 ? 1.5 : 2, pb: 0.5,
|
||||
borderTop: rowIdx === 0 ? 'none' : `1px dashed ${c.border.subtle}`,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '1.15rem', fontWeight: 700, color: row.isToday ? accent : c.text.primary, lineHeight: 1, letterSpacing: '-0.01em' }}>
|
||||
{row.date.getDate()}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.2 }}>{WEEKDAY_FULL[date.getDay()]}</Typography>
|
||||
<Typography sx={{ fontSize: '0.85rem', fontWeight: 600, color: row.isToday ? accent : c.text.secondary, lineHeight: 1 }}>
|
||||
{WEEKDAY_FULL[row.date.getDay()]}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1 }}>
|
||||
{row.date.toLocaleString('en', { month: 'short' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (row.kind === 'empty') {
|
||||
return (
|
||||
<Box key={row.id} data-wl-id={row.id} sx={{ px: 2, pb: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.ghost }}>No events today</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const e = row.ev;
|
||||
return (
|
||||
<Box
|
||||
key={row.id}
|
||||
data-wl-id={row.id}
|
||||
onClick={() => onSelectWorkflow?.(e.workflow.id)}
|
||||
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1.25,
|
||||
px: 2, py: 0.4,
|
||||
color: c.text.secondary, cursor: 'pointer',
|
||||
'&:hover .ev-title': { color: accent },
|
||||
}}>
|
||||
<Box sx={{ width: 3, alignSelf: 'stretch', minHeight: 22, bgcolor: accent, borderRadius: c.radius.sm, flexShrink: 0 }} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography className="ev-title" sx={{ fontSize: '0.9rem', fontWeight: 500, color: c.text.primary, lineHeight: 1.3 }}>{e.workflow.title}</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.3 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', py: 1, pr: 2 }}>
|
||||
{events.length === 0 && (
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.ghost }}>No events today</Typography>
|
||||
)}
|
||||
{events.map((e, idx) => (
|
||||
<Tooltip key={`${e.workflow.id}-${idx}`} title={<EventTooltipBody event={e} />} placement="right" arrow>
|
||||
<Box
|
||||
onClick={() => onSelectWorkflow?.(e.workflow.id)}
|
||||
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1.25,
|
||||
py: 0.4,
|
||||
fontSize: '0.88rem', color: c.text.secondary, cursor: 'pointer',
|
||||
'&:hover .ev-title': { color: accent },
|
||||
}}>
|
||||
<Box sx={{ width: 3, alignSelf: 'stretch', minHeight: 22, bgcolor: accent, borderRadius: c.radius.sm, flexShrink: 0 }} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography className="ev-title" sx={{ fontSize: '0.9rem', fontWeight: 500, color: c.text.primary, lineHeight: 1.3 }}>{e.workflow.title}</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.3 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{windowing.bottomSpacer > 0 && (
|
||||
<Box aria-hidden sx={{ height: windowing.bottomSpacer, flexShrink: 0, overflowAnchor: 'none' }} />
|
||||
)}
|
||||
{ctxMenuEl}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -365,7 +365,7 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
|
||||
const ends = new Date(s.ends_at).getTime();
|
||||
if (!Number.isNaN(ends) && ends <= Date.now()) {
|
||||
return (
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, pl: 12 }}>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning, pl: 12 }}>
|
||||
This date is in the past. The schedule will turn itself off.
|
||||
</Typography>
|
||||
);
|
||||
@@ -373,24 +373,13 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
|
||||
}
|
||||
if (endKind === 'after_n' && s.max_runs != null && s.runs_count >= s.max_runs) {
|
||||
return (
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, pl: 12 }}>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning, pl: 12 }}>
|
||||
This workflow has already run {s.runs_count}× (limit {s.max_runs}). Raise the number or reset the counter to re-arm.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>If missed</Typography>
|
||||
<Select
|
||||
size="small"
|
||||
value={s.on_missed === 'run_all' ? 'run_once' : s.on_missed}
|
||||
onChange={(e) => setSched({ on_missed: e.target.value as ScheduleConfig['on_missed'] })}
|
||||
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
|
||||
<MenuItem value="skip">Skip the missed run</MenuItem>
|
||||
<MenuItem value="run_once">Run once after I wake the app</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Section: What can the agent do? */}
|
||||
@@ -445,11 +434,11 @@ function AppOpenStatusBadge({ info, hour, minute, frequent, onFix }: { info: App
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1, pl: 0.25,
|
||||
bgcolor: good ? c.status.successBg : (c.status.warningBg || c.bg.elevated),
|
||||
border: `1px solid ${good ? c.status.success + '60' : (c.status.warning || c.text.muted) + '60'}`,
|
||||
bgcolor: good ? c.status.successBg : c.status.warningBg,
|
||||
border: `1px solid ${good ? c.status.success + '60' : c.status.warning + '60'}`,
|
||||
borderRadius: `${c.radius.md}px`, px: 1, py: 0.5,
|
||||
}}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: good ? c.status.success : (c.status.warning || c.text.muted) }} />
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: good ? c.status.success : c.status.warning }} />
|
||||
<Typography sx={{ flex: 1, fontSize: HINT_FS, color: c.text.primary }}>
|
||||
{good ? 'Will run even if you close OpenSwarm.' : (frequent ? 'OpenSwarm must be open for this to run.' : `OpenSwarm must be open at ${fmt} for this to run.`)}
|
||||
</Typography>
|
||||
@@ -514,7 +503,7 @@ function PermissionRow({ idx, tier, cloudSmsEnabled, onChange, onRemove }: {
|
||||
)}
|
||||
</Box>
|
||||
{!cloudSmsEnabled && (
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, fontStyle: 'italic' }}>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning, fontStyle: 'italic' }}>
|
||||
Coming soon. Until cloud SMS ships, this tier falls back to an in-app notify with a "fallback" badge.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -139,7 +139,7 @@ export default function SchedulePopover({
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12, ease: 'easeOut' }}
|
||||
transition={{ duration: 0.14, ease: 'easeOut' }}
|
||||
style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{mode === 'search' && (
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
|
||||
@@ -179,8 +179,8 @@ export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sess
|
||||
display: 'flex', flexDirection: 'column', gap: 0.4,
|
||||
px: 1, py: 0.75, mb: 0.75,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: c.status.warningBg || c.bg.elevated,
|
||||
border: `1px solid ${(c.status.warning || c.text.muted) + '60'}`,
|
||||
bgcolor: c.status.warningBg,
|
||||
border: `1px solid ${c.status.warning + '60'}`,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.text.primary }}>
|
||||
This chat is already scheduled.
|
||||
|
||||
@@ -390,7 +390,8 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
const result = await dispatch(createWorkflow({
|
||||
title: (d.title as string) || 'New workflow',
|
||||
description: (d.description as string) || '',
|
||||
steps: draftSteps.map((s) => ({ id: s.id, text: s.text })),
|
||||
steps: draftSteps.map((s) => ({ id: s.id, text: s.text, label: s.label })),
|
||||
metadata_generated: card?.metaGenerated === true,
|
||||
source_session_id: (d.source_session_id as string | undefined) || card?.sourceSessionId || null,
|
||||
use_synced_prompt: true,
|
||||
model: defaultModel || (d.model as string),
|
||||
@@ -472,7 +473,7 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
? '2px solid #3b82f6'
|
||||
: isRunning
|
||||
? `1px solid ${c.accent.primary}80`
|
||||
: `1px solid ${c.border.subtle}`;
|
||||
: `1px solid ${c.border.strong}`;
|
||||
|
||||
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`
|
||||
@@ -480,7 +481,7 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
? c.shadow.lg
|
||||
: isSelected
|
||||
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
|
||||
: c.shadow.md;
|
||||
: c.shadow.sm;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -514,7 +515,7 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
width: displayW,
|
||||
height: autoHeight ? 'auto' : displayH,
|
||||
maxHeight: autoHeight ? 'min(82vh, 760px)' : undefined,
|
||||
borderRadius: '14px',
|
||||
borderRadius: 3,
|
||||
border,
|
||||
bgcolor: c.bg.surface,
|
||||
boxShadow: shadow,
|
||||
@@ -541,7 +542,7 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
onPointerUp={handleDragPointerUp}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
px: 2, py: 1.4,
|
||||
px: 2, pt: 2, pb: 1.4,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
touchAction: 'none', userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
@@ -549,7 +550,7 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<DragIndicatorIcon sx={{ fontSize: 18, color: c.text.muted }} />
|
||||
<DragIndicatorIcon sx={{ fontSize: 16, color: c.text.ghost, mr: -0.5 }} />
|
||||
{isDraft ? (
|
||||
// Draft state: title is inline-editable. Patches the openCard's
|
||||
// draft.title so PreviewView picks it up on Save. Saved cards
|
||||
@@ -558,12 +559,14 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
data-no-drag
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
value={(card?.draft?.title as string) || ''}
|
||||
placeholder="New workflow"
|
||||
placeholder={card?.metaLoading ? 'Naming workflow' : 'New workflow'}
|
||||
onChange={(e) => dispatch(updateWorkflowCard({ workflowId, patch: { draft: { ...(card?.draft || {}), title: e.target.value } } }))}
|
||||
sx={{
|
||||
flex: 1, fontWeight: 600, fontSize: '0.95rem', color: c.text.primary,
|
||||
letterSpacing: '-0.005em',
|
||||
'& input::placeholder': { color: c.text.muted, opacity: 1 },
|
||||
animation: card?.metaLoading ? 'wfTitlePulse 1.2s ease-in-out infinite' : 'none',
|
||||
'@keyframes wfTitlePulse': { '0%, 100%': { opacity: 0.55 }, '50%': { opacity: 1 } },
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -610,9 +613,9 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
data-no-drag
|
||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ p: 0.5, color: c.text.secondary, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}
|
||||
sx={{ p: 0.5, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 17 }} />
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
@@ -633,7 +636,6 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
workflow={null}
|
||||
runs={null}
|
||||
fallbackModel={card?.draft?.model}
|
||||
fallbackMode={card?.draft?.mode}
|
||||
fallbackSourceSessionId={card?.draft?.source_session_id}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
@@ -842,7 +844,7 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
<Box sx={{
|
||||
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
|
||||
fontSize: '1.4rem', fontWeight: 700, color: c.accent.primary,
|
||||
bgcolor: c.bg.surface, px: 1.2, py: 0.5, borderRadius: 999,
|
||||
bgcolor: c.bg.surface, px: 1.2, py: 0.5, borderRadius: c.radius.full,
|
||||
boxShadow: c.shadow.md,
|
||||
animation: 'first-success-pop 1.4s ease-out forwards',
|
||||
'@keyframes first-success-pop': {
|
||||
@@ -944,11 +946,10 @@ function EditAgentSubtitle({ session }: { session: import('@/shared/state/agents
|
||||
);
|
||||
}
|
||||
|
||||
function SubtitleRow({ workflow, runs, fallbackModel, fallbackMode, fallbackSourceSessionId }: {
|
||||
function SubtitleRow({ workflow, runs, fallbackModel, fallbackSourceSessionId }: {
|
||||
workflow: Workflow | null;
|
||||
runs: import('@/shared/state/workflowsSlice').WorkflowRun[] | null;
|
||||
fallbackModel?: string;
|
||||
fallbackMode?: string;
|
||||
fallbackSourceSessionId?: string | null;
|
||||
}) {
|
||||
const c = useClaudeTokens();
|
||||
@@ -957,8 +958,8 @@ function SubtitleRow({ workflow, runs, fallbackModel, fallbackMode, fallbackSour
|
||||
// model/mode and use its work time for the "28s" so the subtitle reads the
|
||||
// same as the source chat card did.
|
||||
const sourceSession = useAppSelector((s) => fallbackSourceSessionId ? s.agents.sessions[fallbackSourceSessionId] : undefined);
|
||||
// Match Image #34/#35/#36/#38/#40: "Claude Opus 4.6 agent 28s".
|
||||
// Spaces between fields, all in muted text.
|
||||
// Reads "Claude Opus 4.6 28s": model then work time, spaces between,
|
||||
// all muted. Mode is omitted to match the chat card's subtitle.
|
||||
const effModel = workflow?.model || fallbackModel || '';
|
||||
const modelLabel = React.useMemo(() => {
|
||||
if (!effModel) return '';
|
||||
@@ -969,7 +970,6 @@ function SubtitleRow({ workflow, runs, fallbackModel, fallbackMode, fallbackSour
|
||||
}
|
||||
return effModel;
|
||||
}, [effModel, modelsByProvider]);
|
||||
const modeLabel = workflow?.mode || fallbackMode || '';
|
||||
const duration = React.useMemo(() => {
|
||||
const finished = (runs || []).find((r) => r.finished_at);
|
||||
if (finished && finished.finished_at) {
|
||||
@@ -989,7 +989,6 @@ function SubtitleRow({ workflow, runs, fallbackModel, fallbackMode, fallbackSour
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1.25, fontSize: '0.82rem', color: c.text.muted, minWidth: 0, overflow: 'hidden' }}>
|
||||
{modelLabel && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{modelLabel}</Box>}
|
||||
{modeLabel && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{modeLabel}</Box>}
|
||||
{duration && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{duration}</Box>}
|
||||
</Box>
|
||||
);
|
||||
@@ -1030,7 +1029,7 @@ function RunningHeader({ workflowId }: { workflowId: string }) {
|
||||
onClick={controlsDisabled ? undefined : onStop}
|
||||
role="button"
|
||||
aria-disabled={controlsDisabled}
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35, fontSize: '0.82rem', fontWeight: 600, px: 1, py: 0.4, color: c.text.secondary, cursor: controlsDisabled ? 'default' : 'pointer', borderRadius: 999, opacity: controlsDisabled && !stopPending ? 0.55 : 1, '&:hover': controlsDisabled ? {} : { color: c.text.primary, bgcolor: c.bg.elevated } }}>
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35, fontSize: '0.82rem', fontWeight: 600, px: 1, py: 0.4, color: c.text.secondary, cursor: controlsDisabled ? 'default' : 'pointer', borderRadius: c.radius.full, opacity: controlsDisabled && !stopPending ? 0.55 : 1, '&:hover': controlsDisabled ? {} : { color: c.text.primary, bgcolor: c.bg.elevated } }}>
|
||||
{stopPending ? <CircularProgress size={14} thickness={5} sx={{ color: c.text.secondary }} /> : <StopRounded sx={{ fontSize: 15 }} />}
|
||||
Stop
|
||||
</Box>
|
||||
@@ -1042,7 +1041,7 @@ function RunningHeader({ workflowId }: { workflowId: string }) {
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.35,
|
||||
fontSize: '0.82rem', fontWeight: 700,
|
||||
px: 1.1, py: 0.4, borderRadius: 999,
|
||||
px: 1.1, py: 0.4, borderRadius: c.radius.full,
|
||||
bgcolor: c.accent.primary, color: '#fff', cursor: controlsDisabled ? 'default' : 'pointer',
|
||||
opacity: controlsDisabled && !pausePending ? 0.55 : 1,
|
||||
'&:hover': controlsDisabled ? {} : { filter: 'brightness(1.05)' },
|
||||
|
||||
@@ -85,7 +85,7 @@ export function ActionBtn({ label, tone, disabled, onClick, icon }: { label: str
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
fontSize: '0.78rem', fontWeight: 600,
|
||||
px: 1, py: 0.35,
|
||||
borderRadius: 999,
|
||||
borderRadius: c.radius.full,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
color: palette.color,
|
||||
bgcolor: palette.bg,
|
||||
@@ -169,7 +169,8 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
const result = await dispatch(createWorkflow({
|
||||
title,
|
||||
description,
|
||||
steps: steps.map((s) => ({ id: s.id, text: s.text })),
|
||||
steps: steps.map((s) => ({ id: s.id, text: s.text, label: s.label })),
|
||||
metadata_generated: card?.metaGenerated === true,
|
||||
source_session_id: sourceSessionId,
|
||||
use_synced_prompt: true,
|
||||
// The user's configured default wins over whatever model the source chat
|
||||
@@ -184,7 +185,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
const wf = result.payload as Workflow;
|
||||
if (wf?.id) return wf;
|
||||
return null;
|
||||
}, [canSave, dispatch, title, description, steps, sourceSessionId, liveDraft, defaultModel, defaultMode]);
|
||||
}, [canSave, dispatch, title, description, steps, sourceSessionId, liveDraft, defaultModel, defaultMode, card]);
|
||||
|
||||
const onIgnore = useCallback(async () => {
|
||||
if (busy) return;
|
||||
@@ -267,7 +268,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
fontSize: '0.88rem', fontWeight: 700,
|
||||
px: 1.75, py: 0.6, borderRadius: 999,
|
||||
px: 1.75, py: 0.6, borderRadius: c.radius.full,
|
||||
color: '#fff', bgcolor: c.accent.primary,
|
||||
cursor: busy ? 'wait' : canSave ? 'pointer' : 'not-allowed',
|
||||
opacity: busy || !canSave ? 0.6 : 1,
|
||||
@@ -300,7 +301,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
role="button"
|
||||
onClick={canSave ? onSaveDraft : undefined}
|
||||
title={canSave ? undefined : 'Add at least one step before saving'}
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary, borderRadius: 999, cursor: busy ? 'wait' : canSave ? 'pointer' : 'not-allowed', px: 1.5, py: 0.6, opacity: busy || !canSave ? 0.6 : 1, '&:hover': { filter: 'brightness(1.06)' } }}>
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary, borderRadius: c.radius.full, cursor: busy ? 'wait' : canSave ? 'pointer' : 'not-allowed', px: 1.5, py: 0.6, opacity: busy || !canSave ? 0.6 : 1, '&:hover': { filter: 'brightness(1.06)' } }}>
|
||||
Save
|
||||
</Box>
|
||||
</DialogActions>
|
||||
@@ -466,7 +467,7 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
fontSize: '0.88rem', fontWeight: 700,
|
||||
px: 1.75, py: 0.6, borderRadius: 999,
|
||||
px: 1.75, py: 0.6, borderRadius: c.radius.full,
|
||||
color: '#fff', bgcolor: c.accent.primary,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: c.accent.primary, filter: 'brightness(1.06)' },
|
||||
@@ -499,12 +500,12 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.45,
|
||||
fontSize: '0.82rem', fontWeight: 600,
|
||||
px: 1.25, py: 0.5,
|
||||
borderRadius: 999,
|
||||
borderRadius: c.radius.full,
|
||||
cursor: 'pointer',
|
||||
color: c.text.secondary,
|
||||
bgcolor: 'transparent',
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
'&:hover': { bgcolor: c.bg.elevated, borderColor: c.border.strong || c.border.medium, color: c.text.primary },
|
||||
'&:hover': { bgcolor: c.bg.elevated, borderColor: c.border.strong, color: c.text.primary },
|
||||
}}>
|
||||
<EditOutlined sx={{ fontSize: 15 }} />
|
||||
Edit
|
||||
@@ -596,7 +597,7 @@ function AuditTraceLink({ workflowId }: { workflowId: string }) {
|
||||
<Box onClick={open} role="button" sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.3,
|
||||
fontSize: '0.7rem', color: c.text.muted, cursor: 'pointer',
|
||||
px: 0.5, py: 0.25, borderRadius: 0.75,
|
||||
px: 0.5, py: 0.25, borderRadius: c.radius.sm,
|
||||
'&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated },
|
||||
}}>
|
||||
<HistoryIcon sx={{ fontSize: 12 }} />
|
||||
@@ -712,7 +713,7 @@ export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleF
|
||||
color: filter === k ? c.accent.primary : c.text.muted,
|
||||
bgcolor: filter === k ? c.accent.primary + '14' : 'transparent',
|
||||
border: `1px solid ${filter === k ? c.accent.primary + '40' : c.border.subtle}`,
|
||||
px: 0.7, py: 0.2, borderRadius: 999, cursor: 'pointer',
|
||||
px: 0.75, py: 0.3, borderRadius: c.radius.full, cursor: 'pointer',
|
||||
'&:hover': { color: c.accent.primary },
|
||||
}}>
|
||||
{k === 'all' ? 'All' : k === 'success' ? 'Success' : k === 'failure' ? 'Failures' : 'Skipped'}
|
||||
@@ -733,8 +734,8 @@ export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleF
|
||||
<Box key={r.id}>
|
||||
<Box
|
||||
onClick={() => setExpandedId(expanded ? null : r.id)}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1.25, py: 0.6, px: 0.5, cursor: 'pointer', borderRadius: 0.75, '&:hover': { bgcolor: c.bg.elevated } }}>
|
||||
<Box sx={{ fontSize: '0.72rem', fontWeight: 700, color: statusColor(r.status, c), bgcolor: statusBg(r.status, c), px: 0.8, py: 0.3, borderRadius: 0.75, minWidth: 64, textAlign: 'center' }}>
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1.25, py: 0.6, px: 0.5, cursor: 'pointer', borderRadius: c.radius.sm, '&: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: c.radius.sm, minWidth: 64, textAlign: 'center' }}>
|
||||
{labelForStatus(r.status)}
|
||||
</Box>
|
||||
{showWorkflow && workflowTitleFor ? (
|
||||
@@ -753,7 +754,8 @@ export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleF
|
||||
<Box sx={{ fontSize: '0.7rem', color: c.text.ghost, transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s ease' }}>▾</Box>
|
||||
</Box>
|
||||
{expanded && (
|
||||
<Box sx={{ ml: 8, mt: 0.25, mb: 0.75, px: 1, py: 0.75, bgcolor: c.bg.elevated, borderRadius: 0.75, border: `1px solid ${c.border.subtle}`, display: 'flex', alignItems: 'center' }}>
|
||||
<Box sx={{ ml: 8, mt: 0.25, mb: 0.75, px: 1, py: 0.75, bgcolor: c.bg.elevated, borderRadius: c.radius.sm, border: `1px solid ${c.border.subtle}`, display: 'flex', alignItems: 'center' }}>
|
||||
|
||||
{r.error ? (
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.status.error, lineHeight: 1.4 }}>{r.error}</Typography>
|
||||
) : r.session_id ? (
|
||||
@@ -781,11 +783,11 @@ export function HistoryDetail({ run, onBack }: { run: WorkflowRun | null; onBack
|
||||
<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>
|
||||
<Box sx={{ fontSize: '0.72rem', fontWeight: 700, color: statusColor(run.status, c), bgcolor: statusBg(run.status, c), px: 0.8, py: 0.3, borderRadius: c.radius.sm }}>{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.status.error, bgcolor: c.status.errorBg, p: 1, borderRadius: c.radius.sm }}>{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 && (
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 Fade from '@mui/material/Fade';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
setWorkflowsHubPosition,
|
||||
setWorkflowsHubSize,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { openWorkflowCard, createWorkflow, fetchPausedState, fetchWorkflows, setPausedAll, updateWorkflow, deleteWorkflow, runWorkflowNow } from '@/shared/state/workflowsSlice';
|
||||
import { openWorkflowCard, createWorkflow, fetchWorkflows, fetchPausedState, setPausedAll, updateWorkflow, deleteWorkflow, runWorkflowNow } from '@/shared/state/workflowsSlice';
|
||||
import type { Workflow } from '@/shared/state/workflowsSlice';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
@@ -56,7 +57,7 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
|
||||
];
|
||||
|
||||
interface Props {
|
||||
dashboardId?: string;
|
||||
dashboardId: string;
|
||||
cardX: number;
|
||||
cardY: number;
|
||||
cardWidth: number;
|
||||
@@ -77,54 +78,6 @@ interface Props {
|
||||
|
||||
type CalendarView = 'Week' | 'Month' | 'List';
|
||||
|
||||
// Small badge in the hub header that adds up successful scheduled runs
|
||||
// across all workflows and renders an approximate "time saved" figure.
|
||||
// Heuristic: 3 minutes saved per scheduled run that the user would have
|
||||
// otherwise done by hand. Not precise — meant as a quiet "you got back
|
||||
// X hours" affirmation, not an audit number.
|
||||
function TimeSavedBadge() {
|
||||
const c = useClaudeTokens();
|
||||
const runsByWorkflow = useAppSelector((s) => s.workflows.runs);
|
||||
const items = useAppSelector((s) => s.workflows.items);
|
||||
let count = 0;
|
||||
for (const arr of Object.values(runsByWorkflow)) {
|
||||
for (const r of arr) {
|
||||
if (r.triggered_by === 'schedule' && (r.status === 'success' || r.status === 'ran_late')) count += 1;
|
||||
}
|
||||
}
|
||||
// Fallback: if no runs are loaded yet (cards never opened), use
|
||||
// last_run_status as a coarse proxy so brand-new users don't see 0.
|
||||
if (count === 0) {
|
||||
for (const w of Object.values(items)) {
|
||||
if (w.last_run_status === 'success' || w.last_run_status === 'ran_late') count += 1;
|
||||
}
|
||||
}
|
||||
if (count === 0) return null;
|
||||
const totalMin = count * 3;
|
||||
const hours = totalMin / 60;
|
||||
// Show "X done · ~Y hrs" so the user gets both the run count and a
|
||||
// sense of time. Dot-separator reads quieter than the old green pill.
|
||||
const timeLabel = hours >= 1 ? `~${hours.toFixed(1)} hrs` : `~${totalMin} min`;
|
||||
return (
|
||||
<Tooltip title={`${count} workflow runs completed for you. Rough estimate of ~3 min saved per run vs. doing it by hand.`}>
|
||||
<Box sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
ml: 1, px: 0.85, py: 0.2,
|
||||
fontSize: '0.74rem', fontWeight: 600,
|
||||
color: c.text.secondary,
|
||||
bgcolor: 'transparent',
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: 999,
|
||||
}}>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 14, height: 14, borderRadius: '50%', bgcolor: (c.status.success || c.accent.primary) + '22', color: c.status.success || c.accent.primary, fontSize: 9, fontWeight: 800 }}>✓</Box>
|
||||
<span style={{ color: c.text.primary }}>{count}</span>
|
||||
<span style={{ color: c.text.muted }}>·</span>
|
||||
<span style={{ color: c.text.secondary }}>{timeLabel} back</span>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const WorkflowsHubCard: React.FC<Props> = ({
|
||||
dashboardId,
|
||||
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
|
||||
@@ -138,10 +91,12 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
const paused = useAppSelector((s) => s.workflows.paused);
|
||||
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchPausedState());
|
||||
dispatch(fetchWorkflows(dashboardId));
|
||||
}, [dispatch, dashboardId]);
|
||||
useEffect(() => { dispatch(fetchPausedState()); }, [dispatch]);
|
||||
// The hub is the signal "user is looking at workflows now", so load them
|
||||
// eagerly here instead of waiting on the dashboard's deferred idle fetch
|
||||
// (which left the calendar blank for ~2s). The thunk's !loading condition
|
||||
// dedups against that idle dispatch.
|
||||
useEffect(() => { dispatch(fetchWorkflows(dashboardId)); }, [dashboardId, dispatch]);
|
||||
|
||||
const togglePaused = useCallback(() => {
|
||||
dispatch(setPausedAll(!paused));
|
||||
@@ -356,7 +311,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
? c.shadow.lg
|
||||
: isSelected
|
||||
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
|
||||
: c.shadow.md;
|
||||
: c.shadow.sm;
|
||||
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
|
||||
|
||||
return (
|
||||
@@ -385,7 +340,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
height: dh,
|
||||
bgcolor: c.bg.surface,
|
||||
border,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
borderRadius: 3,
|
||||
boxShadow: shadow,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
@@ -415,15 +370,15 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
points right, matching the Workflows brand mark. */}
|
||||
<CallSplitRoundedIcon sx={{ fontSize: 16, transform: 'rotate(90deg)' }} />
|
||||
</Box>
|
||||
<Typography sx={{ flex: 1, fontWeight: 700, fontSize: '0.88rem', color: c.text.primary }}>Workflows</Typography>
|
||||
<Typography sx={{ flex: 1, fontWeight: 600, fontSize: '0.95rem', 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 } }}
|
||||
sx={{ p: 0.5, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 15 }} />
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
@@ -455,11 +410,11 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
data-no-drag
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4, ml: 0.5,
|
||||
fontSize: '0.8rem', fontWeight: 600,
|
||||
color: paused ? c.status.warning || c.accent.primary : c.text.secondary,
|
||||
bgcolor: paused ? (c.status.warningBg || c.bg.elevated) : 'transparent',
|
||||
border: `1px solid ${paused ? (c.status.warning || c.accent.primary) + '60' : c.border.subtle}`,
|
||||
px: 0.85, py: 0.3, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
|
||||
fontSize: '0.82rem', fontWeight: 600,
|
||||
color: c.text.secondary,
|
||||
bgcolor: paused ? c.bg.elevated : 'transparent',
|
||||
border: `1px solid ${paused ? c.border.medium : c.border.subtle}`,
|
||||
px: 1, py: 0.35, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
|
||||
'&:hover': { color: c.text.primary, borderColor: c.border.medium },
|
||||
}}>
|
||||
<Switch size="small" checked={paused} sx={{ pointerEvents: 'none', mr: -0.5, ml: -0.5 }} />
|
||||
@@ -475,13 +430,12 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
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',
|
||||
px: 1, py: 0.35, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
|
||||
'&:hover': { color: c.text.primary, borderColor: c.border.medium },
|
||||
}}>Today</Box>
|
||||
<IconButton size="small" data-no-drag onClick={() => setRefDate(addDays(refDate, view === 'Month' ? -28 : -7))} sx={{ p: 0.3 }}><ChevronLeftIcon sx={{ fontSize: 18 }} /></IconButton>
|
||||
<IconButton size="small" data-no-drag onClick={() => setRefDate(addDays(refDate, view === 'Month' ? 28 : 7))} sx={{ p: 0.3 }}><ChevronRightIcon sx={{ fontSize: 18 }} /></IconButton>
|
||||
<Typography sx={{ fontSize: '0.92rem', fontWeight: 600, color: c.text.primary }}>{monthLabel}</Typography>
|
||||
<TimeSavedBadge />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
@@ -499,7 +453,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
{view}
|
||||
<KeyboardArrowDownIcon sx={{ fontSize: 16 }} />
|
||||
</Box>
|
||||
{viewOpen && (
|
||||
<Fade in={viewOpen} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
|
||||
<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 }}>
|
||||
{(['List', 'Week', 'Month'] as const).map((v) => (
|
||||
<Box
|
||||
@@ -511,7 +465,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Fade>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -690,7 +644,7 @@ function SidebarSection({ title, items, onPick, scheduled, onContext, onSchedule
|
||||
<Box
|
||||
onClick={(e) => toggleEnabled(w, e)}
|
||||
sx={{
|
||||
width: 14, height: 14, borderRadius: '3px', flexShrink: 0,
|
||||
width: 14, height: 14, borderRadius: c.radius.sm, flexShrink: 0,
|
||||
border: `1.5px solid ${w.schedule.enabled ? c.accent.primary : c.border.medium}`,
|
||||
bgcolor: w.schedule.enabled ? c.accent.primary : 'transparent',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -706,7 +660,7 @@ function SidebarSection({ title, items, onPick, scheduled, onContext, onSchedule
|
||||
<Box
|
||||
onClick={(e) => { e.stopPropagation(); onSchedule?.(w, e.currentTarget); }}
|
||||
sx={{
|
||||
width: 16, height: 16, borderRadius: '4px', flexShrink: 0,
|
||||
width: 16, height: 16, borderRadius: c.radius.sm, flexShrink: 0,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: c.text.muted, cursor: 'pointer',
|
||||
'&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated },
|
||||
@@ -724,7 +678,7 @@ function SidebarSection({ title, items, onPick, scheduled, onContext, onSchedule
|
||||
)}
|
||||
</Typewriter>
|
||||
{scheduled && (!w.schedule.enabled || allPaused) && (
|
||||
<Box sx={{ flexShrink: 0, px: 0.6, py: 0.1, borderRadius: '3px', bgcolor: c.bg.elevated, color: c.text.muted, fontSize: '0.62rem', fontWeight: 600, lineHeight: 1.5, letterSpacing: '0.02em' }}>Paused</Box>
|
||||
<Box sx={{ flexShrink: 0, px: 0.6, py: 0.1, borderRadius: c.radius.sm, bgcolor: c.bg.elevated, color: c.text.muted, fontSize: '0.62rem', fontWeight: 600, lineHeight: 1.5, letterSpacing: '0.02em' }}>Paused</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -18,7 +18,6 @@ export function defaultSchedule(): ScheduleConfig {
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
timezone: tz,
|
||||
on_missed: 'skip',
|
||||
ends_at: null,
|
||||
max_runs: null,
|
||||
runs_count: 0,
|
||||
|
||||
@@ -36,7 +36,7 @@ export function ActionBtn({ label, tone, disabled, onClick, icon }: { label: str
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.45,
|
||||
fontSize: LABEL_FS, fontWeight: 600, px: 1.25, py: 0.5,
|
||||
borderRadius: 999,
|
||||
borderRadius: c.radius.full,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
color: palette.color,
|
||||
bgcolor: palette.bg,
|
||||
|
||||
@@ -48,7 +48,7 @@ export type LastRunStatus = NonNullable<Workflow['last_run_status']>;
|
||||
export function statusDotColor(status: LastRunStatus | null | undefined, c: ReturnType<typeof useClaudeTokens>) {
|
||||
switch (status) {
|
||||
case 'success': return c.status.success;
|
||||
case 'ran_late': return c.status.warning || '#f59e0b';
|
||||
case 'ran_late': return c.status.warning;
|
||||
case 'failure': return c.status.error;
|
||||
case 'running': return c.accent.primary;
|
||||
case 'skipped': return c.text.muted;
|
||||
@@ -75,7 +75,7 @@ export function StatusDot({ status }: { status: LastRunStatus | null | undefined
|
||||
<Tooltip title={status ? `Last run: ${word.toLowerCase()}` : 'This workflow has never run.'}>
|
||||
<Box sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
height: 18, px: 0.6, borderRadius: 999,
|
||||
height: 18, px: 0.75, borderRadius: c.radius.full,
|
||||
bgcolor: status === 'failure' ? c.status.errorBg : status === 'ran_late' ? c.status.warningBg : status === 'success' ? c.status.successBg : c.bg.elevated,
|
||||
border: `1px solid ${dotColor}55`,
|
||||
flexShrink: 0,
|
||||
@@ -163,7 +163,7 @@ export function PermissionChip({ workflow }: { workflow: Workflow }) {
|
||||
color: c.text.secondary,
|
||||
bgcolor: c.bg.elevated,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
px: 0.85, py: 0.3, borderRadius: 999,
|
||||
px: 0.75, py: 0.3, borderRadius: c.radius.full,
|
||||
}}>
|
||||
{tiers.map((t, i) => (
|
||||
<React.Fragment key={i}>
|
||||
@@ -204,7 +204,7 @@ export function ScheduleChip({ workflow }: { workflow: Workflow }) {
|
||||
color: enabled ? c.accent.primary : c.text.muted,
|
||||
bgcolor: enabled ? c.accent.primary + '14' : c.bg.elevated,
|
||||
border: `1px solid ${enabled ? c.accent.primary + '40' : c.border.subtle}`,
|
||||
px: 0.85, py: 0.3, borderRadius: 999,
|
||||
px: 0.75, py: 0.3, borderRadius: c.radius.full,
|
||||
cursor: enabled ? 'pointer' : 'default',
|
||||
'&:hover': enabled ? { bgcolor: c.accent.primary + '22' } : undefined,
|
||||
}}>
|
||||
@@ -354,7 +354,7 @@ function chipSx(c: ReturnType<typeof useClaudeTokens>) {
|
||||
color: c.text.secondary,
|
||||
bgcolor: c.bg.elevated,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
px: 0.75, py: 0.3, borderRadius: 999,
|
||||
px: 0.75, py: 0.3, borderRadius: c.radius.full,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -432,10 +432,10 @@ export function StreakBadge({ runs }: { runs: WorkflowRun[] | undefined }) {
|
||||
<Box sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.3,
|
||||
fontSize: '0.72rem', fontWeight: 700,
|
||||
color: c.status.warning || '#f59e0b',
|
||||
bgcolor: (c.status.warningBg || c.bg.elevated),
|
||||
border: `1px solid ${(c.status.warning || '#f59e0b') + '60'}`,
|
||||
px: 0.7, py: 0.2, borderRadius: 999,
|
||||
color: c.status.success,
|
||||
bgcolor: c.status.successBg,
|
||||
border: `1px solid ${c.status.success + '60'}`,
|
||||
px: 0.75, py: 0.3, borderRadius: c.radius.full,
|
||||
}}>
|
||||
🔥 {n}
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
// Generic list windowing, lifted from AgentChat's transcript virtualizer so a
|
||||
// long schedule list mounts only the rows near the viewport (off-screen rows
|
||||
// unmount, replaced by measured-height spacers). Rows can be any height; the
|
||||
// hook measures them once on screen and estimates the rest. Top-anchored: the
|
||||
// list reads from the top, no bottom-following like the chat does.
|
||||
|
||||
// Keep this many screens of real content mounted on EACH side of the viewport.
|
||||
const BUFFER_SCREENS_PER_SIDE = 3;
|
||||
// Floor on mounted count so one very tall row can't strand an empty window.
|
||||
const MIN_BUFFER_ITEMS = 2;
|
||||
|
||||
// Pure solver: given scroll position and a per-index height accessor (measured
|
||||
// where known, estimated otherwise), return the [start, end) slice to mount.
|
||||
// Buffer is in PIXELS (N screens per side), so a few tall rows can't blow the
|
||||
// mounted set up to the whole list.
|
||||
export function computeDesiredWindow(
|
||||
scrollTop: number,
|
||||
clientHeight: number,
|
||||
total: number,
|
||||
heightOf: (index: number) => number,
|
||||
bufferPx: number,
|
||||
): { start: number; end: number } {
|
||||
if (total <= 0) return { start: 0, end: 0 };
|
||||
const keepTop = scrollTop - bufferPx;
|
||||
const keepBottom = scrollTop + clientHeight + bufferPx;
|
||||
let offset = 0;
|
||||
let start = -1;
|
||||
let end = total;
|
||||
for (let i = 0; i < total; i++) {
|
||||
const h = heightOf(i);
|
||||
const itemTop = offset;
|
||||
const itemBottom = offset + h;
|
||||
if (start === -1 && itemBottom > keepTop) start = i;
|
||||
if (itemTop < keepBottom) {
|
||||
end = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
offset += h;
|
||||
}
|
||||
if (start === -1) start = Math.max(0, total - 1);
|
||||
end = Math.min(total, Math.max(end, start + 1));
|
||||
if (end - start < MIN_BUFFER_ITEMS) {
|
||||
start = Math.max(0, Math.min(start, end - MIN_BUFFER_ITEMS));
|
||||
}
|
||||
return { start: Math.max(0, start), end };
|
||||
}
|
||||
|
||||
interface UseWindowedListArgs {
|
||||
// Stable id per row, in render order. Heights are cached by id so a measured
|
||||
// row keeps its height across re-renders even as the window slides.
|
||||
ids: string[];
|
||||
estimateHeight: (index: number) => number;
|
||||
// Off below this gates windowing entirely: render all, no spacers. Short
|
||||
// lists don't benefit and the spacer recompute just fights the scrollbar.
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface UseWindowedListResult {
|
||||
setScrollEl: (el: HTMLDivElement | null) => void;
|
||||
onScroll: () => void;
|
||||
start: number;
|
||||
end: number;
|
||||
topSpacer: number;
|
||||
bottomSpacer: number;
|
||||
}
|
||||
|
||||
export function useWindowedList({ ids, estimateHeight, enabled }: UseWindowedListArgs): UseWindowedListResult {
|
||||
const total = ids.length;
|
||||
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const heightsRef = useRef<Map<string, number>>(new Map());
|
||||
const [heightVersion, setHeightVersion] = useState(0);
|
||||
|
||||
const [start, setStart] = useState(0);
|
||||
const [end, setEnd] = useState(total);
|
||||
const startRef = useRef(0);
|
||||
const endRef = useRef(total);
|
||||
|
||||
const idsRef = useRef(ids);
|
||||
idsRef.current = ids;
|
||||
const estimateRef = useRef(estimateHeight);
|
||||
estimateRef.current = estimateHeight;
|
||||
|
||||
const heightOf = useCallback((index: number): number => {
|
||||
const id = idsRef.current[index];
|
||||
if (id == null) return 0;
|
||||
const measured = heightsRef.current.get(id);
|
||||
if (measured != null) return measured;
|
||||
return estimateRef.current(index);
|
||||
}, []);
|
||||
|
||||
const applyWindow = useCallback(() => {
|
||||
const el = scrollEl;
|
||||
if (!el || !enabled) return;
|
||||
const count = idsRef.current.length;
|
||||
const clientHeight = Math.max(1, el.clientHeight);
|
||||
const tightPx = BUFFER_SCREENS_PER_SIDE * clientHeight;
|
||||
const loosePx = tightPx + clientHeight;
|
||||
const tight = computeDesiredWindow(el.scrollTop, clientHeight, count, heightOf, tightPx);
|
||||
const loose = computeDesiredWindow(el.scrollTop, clientHeight, count, heightOf, loosePx);
|
||||
const curStart = startRef.current;
|
||||
const curEnd = endRef.current;
|
||||
// Hysteresis: must-mount the tight band, but keep already-mounted edges
|
||||
// until they drift past the looser band, so rows on the boundary don't
|
||||
// flip-flop mount/unmount on every scroll tick.
|
||||
let next = Math.max(loose.start, Math.min(curStart, tight.start));
|
||||
let nextEnd = Math.min(loose.end, Math.max(curEnd, tight.end));
|
||||
next = Math.max(0, Math.min(next, Math.max(0, nextEnd - 1)));
|
||||
if (next === curStart && nextEnd === curEnd) return;
|
||||
startRef.current = next;
|
||||
endRef.current = nextEnd;
|
||||
setStart(next);
|
||||
setEnd(nextEnd);
|
||||
}, [scrollEl, enabled, heightOf]);
|
||||
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const onScroll = useCallback(() => {
|
||||
if (rafRef.current != null) return;
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
applyWindow();
|
||||
});
|
||||
}, [applyWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollEl) return;
|
||||
applyWindow();
|
||||
const obs = new ResizeObserver(() => applyWindow());
|
||||
obs.observe(scrollEl);
|
||||
return () => {
|
||||
obs.disconnect();
|
||||
if (rafRef.current != null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [scrollEl, enabled, total, heightVersion, applyWindow]);
|
||||
|
||||
// Measure mounted rows after paint; a real height replaces its estimate and
|
||||
// nudges the window + spacers to the truth on the next frame.
|
||||
useLayoutEffect(() => {
|
||||
if (!scrollEl) return;
|
||||
let changed = false;
|
||||
scrollEl.querySelectorAll<HTMLElement>('[data-wl-id]').forEach((node) => {
|
||||
const id = node.dataset.wlId;
|
||||
if (!id) return;
|
||||
const h = node.offsetHeight;
|
||||
if (h <= 0) return;
|
||||
const prev = heightsRef.current.get(id);
|
||||
if (prev === undefined || Math.abs(prev - h) > 1) {
|
||||
heightsRef.current.set(id, h);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) setHeightVersion((v) => v + 1);
|
||||
});
|
||||
|
||||
const safeStart = enabled ? Math.min(Math.max(0, start), Math.max(0, total - 1)) : 0;
|
||||
const safeEnd = enabled ? Math.min(Math.max(safeStart + 1, end), total) : total;
|
||||
|
||||
const { topSpacer, bottomSpacer } = useMemo(() => {
|
||||
if (!enabled) return { topSpacer: 0, bottomSpacer: 0 };
|
||||
let top = 0;
|
||||
for (let i = 0; i < safeStart; i++) top += heightOf(i);
|
||||
let bottom = 0;
|
||||
for (let i = safeEnd; i < total; i++) bottom += heightOf(i);
|
||||
return { topSpacer: top, bottomSpacer: bottom };
|
||||
// heightVersion: spacers depend on the measured-height map (a ref the dep
|
||||
// checker can't see), recompute when a measurement lands.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, safeStart, safeEnd, total, heightVersion, heightOf]);
|
||||
|
||||
return { setScrollEl, onScroll, start: safeStart, end: safeEnd, topSpacer, bottomSpacer };
|
||||
}
|
||||
@@ -22,12 +22,14 @@ export const DEFAULT_WORKFLOW_CARD_W = 480;
|
||||
export const DEFAULT_WORKFLOW_CARD_H = 520;
|
||||
export const DEFAULT_WORKFLOWS_HUB_W = 1200;
|
||||
export const DEFAULT_WORKFLOWS_HUB_H = 640;
|
||||
export const DEFAULT_MISSED_RUNS_W = 460;
|
||||
export const DEFAULT_MISSED_RUNS_H = 420;
|
||||
export const EXPANDED_CARD_MIN_H = 620;
|
||||
export const GRID_GAP = 24;
|
||||
const GRID_ORIGIN = { x: 40, y: 100 };
|
||||
const GRID_COLS_FALLBACK = 4;
|
||||
|
||||
export type CardType = 'agent' | 'view' | 'browser' | 'note' | 'workflow' | 'workflows-hub';
|
||||
export type CardType = 'agent' | 'view' | 'browser' | 'note' | 'workflow' | 'workflows-hub' | 'missed_runs';
|
||||
|
||||
export interface CardPosition {
|
||||
session_id: string;
|
||||
@@ -89,6 +91,17 @@ export interface WorkflowsHubPosition {
|
||||
zOrder: number;
|
||||
}
|
||||
|
||||
// Ephemeral launch-time card listing scheduled fires missed while the app was
|
||||
// closed. Singleton like workflowsHub, but deliberately NOT persisted to the
|
||||
// saved layout: the launch hook decides each session whether to show it.
|
||||
export interface MissedRunsCardPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
zOrder: number;
|
||||
}
|
||||
|
||||
export type NoteColor = 'yellow' | 'pink' | 'blue' | 'green' | 'purple' | 'gray';
|
||||
|
||||
export interface NotePosition {
|
||||
@@ -120,6 +133,7 @@ export interface DashboardLayoutState {
|
||||
workflowCards: Record<string, WorkflowCardPosition>;
|
||||
configurePanels: Record<string, ConfigurePanelPosition>;
|
||||
workflowsHub: WorkflowsHubPosition | null;
|
||||
missedRunsCard: MissedRunsCardPosition | null;
|
||||
notes: Record<string, NotePosition>;
|
||||
closedCardPositions: Record<string, CardPosition>;
|
||||
glowingBrowserCards: Record<string, { sourceId: string; fading: boolean; label?: string }>;
|
||||
@@ -138,6 +152,8 @@ export interface DashboardLayoutState {
|
||||
/** Transient: id of the view card the user has clicked into; preload stops forwarding canvas gestures while set. */
|
||||
activeViewCardId: string | null;
|
||||
pendingFocusWorkflowId: string | null;
|
||||
/** Transient: signals Dashboard to pan/zoom to the missed-runs card on open. */
|
||||
pendingFocusMissedRuns: boolean;
|
||||
/** Transient: signals Dashboard to pan/zoom to the singleton Workflows Hub on open. */
|
||||
pendingFocusWorkflowsHub: boolean;
|
||||
}
|
||||
@@ -149,6 +165,7 @@ const initialState: DashboardLayoutState = {
|
||||
workflowCards: {},
|
||||
configurePanels: {},
|
||||
workflowsHub: null,
|
||||
missedRunsCard: null,
|
||||
notes: {},
|
||||
closedCardPositions: {},
|
||||
glowingBrowserCards: {},
|
||||
@@ -163,6 +180,7 @@ const initialState: DashboardLayoutState = {
|
||||
endingBrowserCards: {},
|
||||
activeViewCardId: null,
|
||||
pendingFocusWorkflowId: null,
|
||||
pendingFocusMissedRuns: false,
|
||||
pendingFocusWorkflowsHub: false,
|
||||
};
|
||||
|
||||
@@ -278,6 +296,9 @@ function collectOccupiedRects(
|
||||
if (state.workflowsHub) {
|
||||
rects.push({ x: state.workflowsHub.x, y: state.workflowsHub.y, w: state.workflowsHub.width, h: state.workflowsHub.height });
|
||||
}
|
||||
if (state.missedRunsCard) {
|
||||
rects.push({ x: state.missedRunsCard.x, y: state.missedRunsCard.y, w: state.missedRunsCard.width, h: state.missedRunsCard.height });
|
||||
}
|
||||
for (const n of Object.values(state.notes)) {
|
||||
rects.push({ x: n.x, y: n.y, w: n.width, h: n.height });
|
||||
}
|
||||
@@ -454,7 +475,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
|
||||
bringToFront(
|
||||
state,
|
||||
action: PayloadAction<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' | 'workflow' | 'workflows-hub' }>,
|
||||
action: PayloadAction<{ id: string; type: CardType }>,
|
||||
) {
|
||||
const { id, type } = action.payload;
|
||||
// Compute the current top zOrder across ALL card types so we can
|
||||
@@ -473,11 +494,13 @@ const dashboardLayoutSlice = createSlice({
|
||||
for (const c of Object.values(state.workflowCards)) tally(c.zOrder);
|
||||
for (const n of Object.values(state.notes)) tally(n.zOrder);
|
||||
if (state.workflowsHub) tally(state.workflowsHub.zOrder);
|
||||
if (state.missedRunsCard) tally(state.missedRunsCard.zOrder);
|
||||
if (type === 'agent') currentZ = state.cards[id]?.zOrder ?? 0;
|
||||
else if (type === 'view') currentZ = state.viewCards[id]?.zOrder ?? 0;
|
||||
else if (type === 'note') currentZ = state.notes[id]?.zOrder ?? 0;
|
||||
else if (type === 'workflow') currentZ = state.workflowCards[id]?.zOrder ?? 0;
|
||||
else if (type === 'workflows-hub') currentZ = state.workflowsHub?.zOrder ?? 0;
|
||||
else if (type === 'missed_runs') currentZ = state.missedRunsCard?.zOrder ?? 0;
|
||||
else currentZ = state.browserCards[id]?.zOrder ?? 0;
|
||||
if (currentZ >= maxZ) return; // Already on top: no-op.
|
||||
|
||||
@@ -496,6 +519,8 @@ const dashboardLayoutSlice = createSlice({
|
||||
if (card) card.zOrder = z;
|
||||
} else if (type === 'workflows-hub') {
|
||||
if (state.workflowsHub) state.workflowsHub.zOrder = z;
|
||||
} else if (type === 'missed_runs') {
|
||||
if (state.missedRunsCard) state.missedRunsCard.zOrder = z;
|
||||
} else {
|
||||
const card = state.browserCards[id];
|
||||
if (card) card.zOrder = z;
|
||||
@@ -552,7 +577,8 @@ const dashboardLayoutSlice = createSlice({
|
||||
const viewCards = Object.values(state.viewCards);
|
||||
const bCards = Object.values(state.browserCards);
|
||||
const wCards = Object.values(state.workflowCards);
|
||||
const total = agentCards.length + viewCards.length + bCards.length + wCards.length;
|
||||
const hub = state.workflowsHub;
|
||||
const total = agentCards.length + viewCards.length + bCards.length + wCards.length + (hub ? 1 : 0);
|
||||
if (total === 0) return;
|
||||
|
||||
const allItems = [
|
||||
@@ -560,6 +586,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
...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 })),
|
||||
...(hub ? [{ kind: 'workflows-hub' as const, id: 'workflows-hub', x: hub.x, y: hub.y, storedW: hub.width, storedH: hub.height }] : []),
|
||||
];
|
||||
allItems.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
|
||||
@@ -587,6 +614,8 @@ const dashboardLayoutSlice = createSlice({
|
||||
} else if (item.kind === 'workflow') {
|
||||
const card = state.workflowCards[item.id];
|
||||
if (card) { card.x = pos.x; card.y = pos.y; }
|
||||
} else if (item.kind === 'workflows-hub') {
|
||||
if (state.workflowsHub) { state.workflowsHub.x = pos.x; state.workflowsHub.y = pos.y; }
|
||||
} else {
|
||||
const card = state.browserCards[item.id];
|
||||
if (card) { card.x = pos.x; card.y = pos.y; }
|
||||
@@ -915,6 +944,37 @@ const dashboardLayoutSlice = createSlice({
|
||||
state.pendingFocusWorkflowsHub = false;
|
||||
},
|
||||
|
||||
openMissedRunsCard(state, action: PayloadAction<{ expandedSessionIds?: string[] } | undefined>) {
|
||||
state.pendingFocusMissedRuns = true;
|
||||
if (state.missedRunsCard) {
|
||||
state.missedRunsCard.zOrder = state.nextZOrder++;
|
||||
return;
|
||||
}
|
||||
const rects = collectOccupiedRects(state, action.payload?.expandedSessionIds);
|
||||
const pos = findOpenGridCell(rects, DEFAULT_MISSED_RUNS_W, DEFAULT_MISSED_RUNS_H);
|
||||
state.missedRunsCard = {
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: DEFAULT_MISSED_RUNS_W,
|
||||
height: DEFAULT_MISSED_RUNS_H,
|
||||
zOrder: state.nextZOrder++,
|
||||
};
|
||||
},
|
||||
|
||||
clearPendingFocusMissedRuns(state) {
|
||||
state.pendingFocusMissedRuns = false;
|
||||
},
|
||||
|
||||
closeMissedRunsCard(state) {
|
||||
state.missedRunsCard = null;
|
||||
},
|
||||
|
||||
setMissedRunsCardPosition(state, action: PayloadAction<{ x: number; y: number }>) {
|
||||
if (!state.missedRunsCard) return;
|
||||
state.missedRunsCard.x = action.payload.x;
|
||||
state.missedRunsCard.y = action.payload.y;
|
||||
},
|
||||
|
||||
closeWorkflowsHub(state) {
|
||||
state.workflowsHub = null;
|
||||
},
|
||||
@@ -1116,6 +1176,11 @@ const dashboardLayoutSlice = createSlice({
|
||||
state.workflowsHub.x += dx;
|
||||
state.workflowsHub.y += dy;
|
||||
}
|
||||
} else if (item.type === 'missed_runs') {
|
||||
if (state.missedRunsCard) {
|
||||
state.missedRunsCard.x += dx;
|
||||
state.missedRunsCard.y += dy;
|
||||
}
|
||||
} else {
|
||||
const card = state.browserCards[item.id];
|
||||
if (card) {
|
||||
@@ -1246,6 +1311,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
state.workflowCards = {};
|
||||
state.configurePanels = {};
|
||||
state.workflowsHub = null;
|
||||
state.missedRunsCard = null;
|
||||
state.notes = {};
|
||||
state.closedCardPositions = {};
|
||||
state.glowingBrowserCards = {};
|
||||
@@ -1257,6 +1323,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
state.suspendedBrowserCards = {};
|
||||
state.endingBrowserCards = {};
|
||||
state.pendingFocusWorkflowId = null;
|
||||
state.pendingFocusMissedRuns = false;
|
||||
},
|
||||
|
||||
},
|
||||
@@ -1417,6 +1484,10 @@ export const {
|
||||
setWorkflowsHubPosition,
|
||||
setWorkflowsHubSize,
|
||||
clearPendingFocusWorkflowsHub,
|
||||
openMissedRunsCard,
|
||||
clearPendingFocusMissedRuns,
|
||||
closeMissedRunsCard,
|
||||
setMissedRunsCardPosition,
|
||||
addNote,
|
||||
setNotePosition,
|
||||
setNoteSize,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const API = `${API_BASE}/workflows`;
|
||||
|
||||
export interface MissedRunItem {
|
||||
id: string;
|
||||
workflow_id: string;
|
||||
workflow_title: string;
|
||||
workflow_icon: string;
|
||||
/** ISO instant the fire was supposed to happen. */
|
||||
scheduled_for: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
items: MissedRunItem[];
|
||||
loading: boolean;
|
||||
toastOpen: boolean;
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
items: [],
|
||||
loading: false,
|
||||
toastOpen: false,
|
||||
};
|
||||
|
||||
export const fetchMissedRuns = createAsyncThunk('missedRuns/fetch', async () => {
|
||||
const res = await fetch(`${API}/missed`);
|
||||
const data = await res.json();
|
||||
return data.missed as MissedRunItem[];
|
||||
});
|
||||
|
||||
export const runMissedRuns = createAsyncThunk('missedRuns/run', async (ids: string[]) => {
|
||||
await fetch(`${API}/missed/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
return ids;
|
||||
});
|
||||
|
||||
export const dismissMissedRuns = createAsyncThunk('missedRuns/dismiss', async (ids: string[]) => {
|
||||
await fetch(`${API}/missed/dismiss`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
return ids;
|
||||
});
|
||||
|
||||
const missedRunsSlice = createSlice({
|
||||
name: 'missedRuns',
|
||||
initialState,
|
||||
reducers: {
|
||||
hideMissedRunsToast(state) {
|
||||
state.toastOpen = false;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchMissedRuns.pending, (state) => {
|
||||
state.loading = true;
|
||||
})
|
||||
.addCase(fetchMissedRuns.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.items = action.payload || [];
|
||||
state.toastOpen = (action.payload?.length ?? 0) > 0;
|
||||
})
|
||||
.addCase(fetchMissedRuns.rejected, (state) => {
|
||||
state.loading = false;
|
||||
});
|
||||
// Both run and dismiss remove the acted-on ids from the list.
|
||||
for (const thunk of [runMissedRuns, dismissMissedRuns]) {
|
||||
builder.addCase(thunk.fulfilled, (state, action) => {
|
||||
const gone = new Set(action.payload);
|
||||
state.items = state.items.filter((m) => !gone.has(m.id));
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const { hideMissedRunsToast } = missedRunsSlice.actions;
|
||||
|
||||
export default missedRunsSlice.reducer;
|
||||
@@ -16,6 +16,7 @@ import modelsReducer from './modelsSlice';
|
||||
import interactionReducer from './interactionSlice';
|
||||
import subscriptionsReducer from './subscriptionsSlice';
|
||||
import workflowsReducer from './workflowsSlice';
|
||||
import missedRunsReducer from './missedRunsSlice';
|
||||
import onboardingProgressReducer from '@/shared/state/onboardingProgressSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
@@ -37,6 +38,7 @@ export const store = configureStore({
|
||||
interaction: interactionReducer,
|
||||
subscriptions: subscriptionsReducer,
|
||||
workflows: workflowsReducer,
|
||||
missedRuns: missedRunsReducer,
|
||||
onboardingProgress: onboardingProgressReducer,
|
||||
},
|
||||
// Disable Redux Toolkit's dev-mode invariant middleware (serializable +
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface ScheduleConfig {
|
||||
hour: number;
|
||||
minute: number;
|
||||
timezone: string;
|
||||
on_missed: 'skip' | 'run_once' | 'run_all';
|
||||
/** End conditions; null on both = forever. Scheduler auto-disables on threshold. */
|
||||
ends_at: string | null;
|
||||
max_runs: number | null;
|
||||
@@ -163,6 +162,12 @@ export interface OpenCard {
|
||||
/** Pre-seed message for the Fix-with-Agent flow so the EditAgent composer
|
||||
* knows which failure context to lead with. Cleared once consumed. */
|
||||
fixSeed?: { runId: string; stepIdx: number; stepLabel: string; error: string } | null;
|
||||
/** True while the preview-time aux naming call is in flight; drives the
|
||||
* header's subtle pulse on a just-converted draft. */
|
||||
metaLoading?: boolean;
|
||||
/** True once preview-time naming filled a real title, so save trusts the
|
||||
* draft's metadata instead of regenerating it server-side. */
|
||||
metaGenerated?: boolean;
|
||||
}
|
||||
|
||||
export interface RunningToast {
|
||||
@@ -264,7 +269,7 @@ export const fetchWorkflows = createAsyncThunk(
|
||||
|
||||
export const createWorkflow = createAsyncThunk(
|
||||
'workflows/create',
|
||||
async (body: Partial<Workflow>) => {
|
||||
async (body: Partial<Workflow> & { metadata_generated?: boolean }) => {
|
||||
const res = await fetch(`${API}/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -275,6 +280,54 @@ export const createWorkflow = createAsyncThunk(
|
||||
},
|
||||
);
|
||||
|
||||
export interface GeneratedMetadata {
|
||||
title: string;
|
||||
description: string;
|
||||
step_labels: string[];
|
||||
}
|
||||
|
||||
export const generateWorkflowMetadata = createAsyncThunk(
|
||||
'workflows/generateMetadata',
|
||||
async (arg: { steps: Array<{ id: string; text: string }>; model?: string }) => {
|
||||
const res = await fetch(`${API}/generate-metadata`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(arg),
|
||||
});
|
||||
if (!res.ok) throw new Error(`metadata failed ${res.status}`);
|
||||
return (await res.json()) as GeneratedMetadata;
|
||||
},
|
||||
);
|
||||
|
||||
// Merge preview-time generated metadata into a draft card, filling only the
|
||||
// fields the user hasn't typed into so a rename mid-flight survives.
|
||||
export const applyGeneratedMetadata = createAsyncThunk(
|
||||
'workflows/applyGeneratedMetadata',
|
||||
async (arg: { workflowId: string; meta: GeneratedMetadata }, { getState, dispatch }) => {
|
||||
const state = getState() as { workflows: State };
|
||||
const card = state.workflows.openCards[arg.workflowId];
|
||||
if (!card) return;
|
||||
const draft = (card.draft || {}) as Partial<Workflow>;
|
||||
const { meta } = arg;
|
||||
const steps = draft.steps || [];
|
||||
const nextDraft: Partial<Workflow> = { ...draft };
|
||||
let changed = false;
|
||||
if (meta.step_labels && meta.step_labels.length === steps.length) {
|
||||
nextDraft.steps = steps.map((s, i) => (meta.step_labels[i] ? { ...s, label: meta.step_labels[i] } : s));
|
||||
changed = true;
|
||||
}
|
||||
const hasTitle = Boolean(meta.title && meta.title.trim());
|
||||
if (hasTitle && !(draft.title || '').trim()) { nextDraft.title = meta.title; changed = true; }
|
||||
if (meta.description && meta.description.trim() && !(draft.description || '').trim()) {
|
||||
nextDraft.description = meta.description;
|
||||
changed = true;
|
||||
}
|
||||
const patch: Partial<OpenCard> = { metaLoading: false, metaGenerated: hasTitle };
|
||||
if (changed) patch.draft = nextDraft;
|
||||
dispatch(updateWorkflowCard({ workflowId: arg.workflowId, patch }));
|
||||
},
|
||||
);
|
||||
|
||||
// Optimistic concurrency via If-Match: server 409s on stale writes; rejectWithValue lets FE distinguish.
|
||||
export const updateWorkflow = createAsyncThunk<
|
||||
Workflow,
|
||||
|
||||
Reference in New Issue
Block a user