From 6229463ce4c433588e767c7a2d3175f928f84152 Mon Sep 17 00:00:00 2001 From: Aidan Date: Fri, 19 Jun 2026 20:10:34 -0700 Subject: [PATCH] [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 --- backend/apps/agents/agents.py | 3 - backend/apps/agents/core/models.py | 5 - backend/apps/dashboards/models.py | 4 - backend/apps/settings/settings.py | 1 - backend/apps/workflows/models.py | 30 +- backend/apps/workflows/scheduler.py | 98 ++++-- backend/apps/workflows/storage.py | 64 +++- backend/apps/workflows/workflows.py | 107 +++++- backend/tests/test_schedule_e2e.py | 102 +++++- .../pages/AgentChat/ChatInput/sendHelpers.ts | 2 + .../tool-bubbles/CompactMcpBubble.tsx | 25 +- .../Dashboard/canvas/DashboardCardLayer.tsx | 25 ++ .../Dashboard/canvas/DashboardOverlays.tsx | 4 + .../app/pages/Dashboard/cards/AgentCard.tsx | 18 +- .../Dashboard/geometry/dashboardTethers.ts | 41 +-- .../lifecycle/useDashboardCardActions.ts | 7 +- .../hooks/lifecycle/useDashboardLifecycle.ts | 34 ++ .../hooks/state/useDashboardController.ts | 2 - .../src/app/pages/Workflows/EditAgentView.tsx | 2 +- .../app/pages/Workflows/MissedRunsCard.tsx | 331 ++++++++++++++++++ .../app/pages/Workflows/MissedRunsToast.tsx | 63 ++++ .../app/pages/Workflows/ScheduleCalendar.tsx | 214 +++++++---- .../src/app/pages/Workflows/ScheduleFacet.tsx | 23 +- .../app/pages/Workflows/SchedulePopover.tsx | 2 +- .../pages/Workflows/ScheduleThisPopover.tsx | 4 +- .../src/app/pages/Workflows/WorkflowCard.tsx | 37 +- .../pages/Workflows/WorkflowCardSubviews.tsx | 32 +- .../app/pages/Workflows/WorkflowsHubCard.tsx | 96 ++--- .../src/app/pages/Workflows/scheduleUtils.ts | 1 - .../pages/Workflows/workflowEditCommon.tsx | 2 +- .../app/pages/Workflows/workflowVisuals.tsx | 18 +- frontend/src/shared/hooks/useWindowedList.ts | 177 ++++++++++ .../src/shared/state/dashboardLayoutSlice.ts | 77 +++- frontend/src/shared/state/missedRunsSlice.ts | 84 +++++ frontend/src/shared/state/store.ts | 2 + frontend/src/shared/state/workflowsSlice.ts | 57 ++- 36 files changed, 1463 insertions(+), 331 deletions(-) create mode 100644 frontend/src/app/pages/Workflows/MissedRunsCard.tsx create mode 100644 frontend/src/app/pages/Workflows/MissedRunsToast.tsx create mode 100644 frontend/src/shared/hooks/useWindowedList.ts create mode 100644 frontend/src/shared/state/missedRunsSlice.ts diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index d4265849..1933f463 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -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: diff --git a/backend/apps/agents/core/models.py b/backend/apps/agents/core/models.py index 3aa704af..00313ee0 100644 --- a/backend/apps/agents/core/models.py +++ b/backend/apps/agents/core/models.py @@ -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. diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py index b62c608f..bf307bd2 100644 --- a/backend/apps/dashboards/models.py +++ b/backend/apps/dashboards/models.py @@ -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) diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index 7c8294ef..6eb983fa 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -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: diff --git a/backend/apps/workflows/models.py b/backend/apps/workflows/models.py index c4748189..4814e583 100644 --- a/backend/apps/workflows/models.py +++ b/backend/apps/workflows/models.py @@ -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). diff --git a/backend/apps/workflows/scheduler.py b/backend/apps/workflows/scheduler.py index 28dd949f..8a3f5113 100644 --- a/backend/apps/workflows/scheduler.py +++ b/backend/apps/workflows/scheduler.py @@ -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: diff --git a/backend/apps/workflows/storage.py b/backend/apps/workflows/storage.py index 96204c2c..c8d0125a 100644 --- a/backend/apps/workflows/storage.py +++ b/backend/apps/workflows/storage.py @@ -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() diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 517cff95..420cde9a 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -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"), diff --git a/backend/tests/test_schedule_e2e.py b/backend/tests/test_schedule_e2e.py index 3723041a..6e50afc0 100644 --- a/backend/tests/test_schedule_e2e.py +++ b/backend/tests/test_schedule_e2e.py @@ -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(): diff --git a/frontend/src/app/pages/AgentChat/ChatInput/sendHelpers.ts b/frontend/src/app/pages/AgentChat/ChatInput/sendHelpers.ts index 34288810..20958171 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/sendHelpers.ts +++ b/frontend/src/app/pages/AgentChat/ChatInput/sendHelpers.ts @@ -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 || ''}`); diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx index 700fd138..7d14bad3 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx @@ -58,6 +58,7 @@ export const CompactMcpBubble: React.FC = ({ 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 ? : null; @@ -78,20 +79,22 @@ export const CompactMcpBubble: React.FC = ({ }} > {ServiceIcon} - - {serviceLabel} - + {!hideVerbLabel && ( + + {serviceLabel} + + )} {visibleSummary && !isError && ( = ({ 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 ( <> @@ -286,6 +291,26 @@ const DashboardCardLayer: React.FC = ({ onBringToFront={onBringToFront} /> )} + {missedRunsCard && ( + + )} {Object.values(workflowCards).map((wc) => ( = ({ {/* Scheduled-run nudge: "your {workflow} is running now" + jump-to-canvas */} + + {/* Launch nudge when scheduled runs elapsed while the app was closed */} + ); }; diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index b2e5da43..bc64682b 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -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 = ({ 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 = ({ suggested_cadence: workflowSuggestion?.cadence || undefined, } as Partial, })); + 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 = ({ 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 diff --git a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts index 9b5c9624..fa047c7b 100644 --- a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts +++ b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts @@ -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(`[data-select-type="${type}"]`); - for (const el of Array.from(candidates)) { - if (el.dataset.selectId === id) return el; - } - return null; -} - -function measuredCanvasRect( - contentRef: RefObject, - 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; glowingBrowserCards: Record; @@ -116,8 +87,6 @@ interface UseTethersArgs { liveDragInfo: LiveDragInfo | null; measuredHeightsRef: RefObject>; measuredHeightsTick: number; - contentRef: RefObject; - 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]); } diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts index 6a4a5ea3..c6f96a97 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts @@ -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]); diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index e56d6d34..68af7c92 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -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; diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index 33e57949..e37e4233 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -288,8 +288,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { liveDragInfo, measuredHeightsRef, measuredHeightsTick, - contentRef: canvas.contentRef, - zoom: canvas.zoom, sessionList, }); diff --git a/frontend/src/app/pages/Workflows/EditAgentView.tsx b/frontend/src/app/pages/Workflows/EditAgentView.tsx index b3bfce8e..4148baf5 100644 --- a/frontend/src/app/pages/Workflows/EditAgentView.tsx +++ b/frontend/src/app/pages/Workflows/EditAgentView.tsx @@ -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)' }, }}> diff --git a/frontend/src/app/pages/Workflows/MissedRunsCard.tsx b/frontend/src/app/pages/Workflows/MissedRunsCard.tsx new file mode 100644 index 00000000..4860adec --- /dev/null +++ b/frontend/src/app/pages/Workflows/MissedRunsCard.tsx @@ -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 = ({ + 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>(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(); + 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 ( + { + 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) */} + + + + Missed while you were away + + {items.length} run{items.length === 1 ? '' : 's'} didn't fire. Run the ones you still want. + + + { e.stopPropagation(); closeAndDismissRest(); }} + onPointerDown={(e) => e.stopPropagation()} + sx={{ p: 0.5, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }} + > + + + + + {/* Scrollable list grouped by workflow */} + + {groups.map((g) => ( + + + {g.runs.length} + {g.title} + + {g.runs.map((m) => ( + 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 }, + }} + > + toggle(m.id)} + onClick={(e) => e.stopPropagation()} + sx={{ p: 0.25, color: c.text.muted, '&.Mui-checked': { color: c.accent.primary } }} + /> + {formatWhen(m.scheduled_for)} + + ))} + + ))} + + + {/* Footer actions */} + + {confirming && ( + + That's {selectedIds.length} real runs. + + )} + {!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'} + + + {runLabel} + + + + ); +}; + +export default MissedRunsCard; diff --git a/frontend/src/app/pages/Workflows/MissedRunsToast.tsx b/frontend/src/app/pages/Workflows/MissedRunsToast.tsx new file mode 100644 index 00000000..7e750d69 --- /dev/null +++ b/frontend/src/app/pages/Workflows/MissedRunsToast.tsx @@ -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 ( + 0} + autoHideDuration={null} + onClose={() => dispatch(hideMissedRunsToast())} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + > + + + dispatch(hideMissedRunsToast())} + sx={{ color: c.text.muted, ml: 0.25, '&:hover': { color: c.text.primary } }} + > + + + + } + > + {`${count} scheduled run${count === 1 ? '' : 's'} ${count === 1 ? 'was' : 'were'} missed while you were away`} + + + ); +} diff --git a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx index 66c43cf7..7d5f75be 100644 --- a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx @@ -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([]); 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(); - 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(() => { + 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 ( - - {upcoming.length === 0 && ( + + {rows.length === 0 && ( No scheduled )} - {upcoming.map(({ date, events, isToday }, rowIdx) => ( - - - - {date.getDate()} - - - - {date.toLocaleString('en', { month: 'short' })} + {windowing.topSpacer > 0 && ( + + )} + {visibleRows.map((row, i) => { + const rowIdx = windowing.start + i; + if (row.kind === 'header') { + return ( + + + {row.date.getDate()} - {WEEKDAY_FULL[date.getDay()]} + + {WEEKDAY_FULL[row.date.getDay()]} + + + {row.date.toLocaleString('en', { month: 'short' })} + + + ); + } + if (row.kind === 'empty') { + return ( + + No events today + + ); + } + const e = row.ev; + return ( + 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 }, + }}> + + + {e.workflow.title} + {formatTime(e.date.getHours(), e.date.getMinutes())} - - {events.length === 0 && ( - No events today - )} - {events.map((e, idx) => ( - } placement="right" arrow> - 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 }, - }}> - - - {e.workflow.title} - {formatTime(e.date.getHours(), e.date.getMinutes())} - - - - ))} - - - ))} + ); + })} + {windowing.bottomSpacer > 0 && ( + + )} {ctxMenuEl} ); diff --git a/frontend/src/app/pages/Workflows/ScheduleFacet.tsx b/frontend/src/app/pages/Workflows/ScheduleFacet.tsx index 1068b058..0e2897e7 100644 --- a/frontend/src/app/pages/Workflows/ScheduleFacet.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleFacet.tsx @@ -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 ( - + This date is in the past. The schedule will turn itself off. ); @@ -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 ( - + This workflow has already run {s.runs_count}× (limit {s.max_runs}). Raise the number or reset the counter to re-arm. ); } return null; })()} - - If missed - - {/* Section: What can the agent do? */} @@ -445,11 +434,11 @@ function AppOpenStatusBadge({ info, hour, minute, frequent, onFix }: { info: App return ( - + {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.`)} @@ -514,7 +503,7 @@ function PermissionRow({ idx, tier, cloudSmsEnabled, onChange, onRemove }: { )} {!cloudSmsEnabled && ( - + Coming soon. Until cloud SMS ships, this tier falls back to an in-app notify with a "fallback" badge. )} diff --git a/frontend/src/app/pages/Workflows/SchedulePopover.tsx b/frontend/src/app/pages/Workflows/SchedulePopover.tsx index ad1d3ac6..3ca80972 100644 --- a/frontend/src/app/pages/Workflows/SchedulePopover.tsx +++ b/frontend/src/app/pages/Workflows/SchedulePopover.tsx @@ -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' && ( diff --git a/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx b/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx index 81edead4..49fe6c71 100644 --- a/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx @@ -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'}`, }}> This chat is already scheduled. diff --git a/frontend/src/app/pages/Workflows/WorkflowCard.tsx b/frontend/src/app/pages/Workflows/WorkflowCard.tsx index c255289c..fe2c65e1 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCard.tsx @@ -390,7 +390,8 @@ const WorkflowCard: React.FC = ({ 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 = ({ ? '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 = ({ ? c.shadow.lg : isSelected ? `0 0 0 1px #3b82f6, ${c.shadow.md}` - : c.shadow.md; + : c.shadow.sm; return ( = ({ 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 = ({ 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 = ({ position: 'relative', }} > - + {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 = ({ 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 = ({ 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 } }} > - + @@ -633,7 +636,6 @@ const WorkflowCard: React.FC = ({ workflow={null} runs={null} fallbackModel={card?.draft?.model} - fallbackMode={card?.draft?.mode} fallbackSourceSessionId={card?.draft?.source_session_id} /> @@ -842,7 +844,7 @@ const WorkflowCard: React.FC = ({ 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 ( {modelLabel && {modelLabel}} - {modeLabel && {modeLabel}} {duration && {duration}} ); @@ -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 ? : } Stop @@ -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)' }, diff --git a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx index ff6b6753..e0684204 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx @@ -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 @@ -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 }, }}> Edit @@ -596,7 +597,7 @@ function AuditTraceLink({ workflowId }: { workflowId: string }) { @@ -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 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 } }}> - + 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 } }}> + {labelForStatus(r.status)} {showWorkflow && workflowTitleFor ? ( @@ -753,7 +754,8 @@ export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleF {expanded && ( - + + {r.error ? ( {r.error} ) : r.session_id ? ( @@ -781,11 +783,11 @@ export function HistoryDetail({ run, onBack }: { run: WorkflowRun | null; onBack ← back - {labelForStatus(run.status)} + {labelForStatus(run.status)} {formatRunDate(run.started_at)} {run.error && ( - {run.error} + {run.error} )} Started {formatRunDate(run.started_at)}, finished {run.finished_at ? formatRunDate(run.finished_at) : 'in progress'}. {run.session_id && ( diff --git a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx index 96e90860..a4bdc15c 100644 --- a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx @@ -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 }[] = [ ]; 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 ( - - - - {count} - · - {timeLabel} back - - - ); -} - const WorkflowsHubCard: React.FC = ({ dashboardId, cardX, cardY, cardWidth, cardHeight, cardZOrder = 0, @@ -138,10 +91,12 @@ const WorkflowsHubCard: React.FC = ({ 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 = ({ ? 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 = ({ 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 = ({ points right, matching the Workflows brand mark. */} - Workflows + Workflows { e.stopPropagation(); dispatch(closeWorkflowsHub()); }} onPointerDown={(e) => e.stopPropagation()} - sx={{ p: 0.35, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }} + sx={{ p: 0.5, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }} > - + @@ -455,11 +410,11 @@ const WorkflowsHubCard: React.FC = ({ 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 }, }}> @@ -475,13 +430,12 @@ const WorkflowsHubCard: React.FC = ({ 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 setRefDate(addDays(refDate, view === 'Month' ? -28 : -7))} sx={{ p: 0.3 }}> setRefDate(addDays(refDate, view === 'Month' ? 28 : 7))} sx={{ p: 0.3 }}> {monthLabel} - @@ -499,7 +453,7 @@ const WorkflowsHubCard: React.FC = ({ {view} - {viewOpen && ( + {(['List', 'Week', 'Month'] as const).map((v) => ( = ({ ))} - )} + @@ -690,7 +644,7 @@ function SidebarSection({ title, items, onPick, scheduled, onContext, onSchedule 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 { 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 )} {scheduled && (!w.schedule.enabled || allPaused) && ( - Paused + Paused )} ))} diff --git a/frontend/src/app/pages/Workflows/scheduleUtils.ts b/frontend/src/app/pages/Workflows/scheduleUtils.ts index 53b89d56..921dc3eb 100644 --- a/frontend/src/app/pages/Workflows/scheduleUtils.ts +++ b/frontend/src/app/pages/Workflows/scheduleUtils.ts @@ -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, diff --git a/frontend/src/app/pages/Workflows/workflowEditCommon.tsx b/frontend/src/app/pages/Workflows/workflowEditCommon.tsx index 285e8c04..41404094 100644 --- a/frontend/src/app/pages/Workflows/workflowEditCommon.tsx +++ b/frontend/src/app/pages/Workflows/workflowEditCommon.tsx @@ -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, diff --git a/frontend/src/app/pages/Workflows/workflowVisuals.tsx b/frontend/src/app/pages/Workflows/workflowVisuals.tsx index b51540ec..eb7af1e1 100644 --- a/frontend/src/app/pages/Workflows/workflowVisuals.tsx +++ b/frontend/src/app/pages/Workflows/workflowVisuals.tsx @@ -48,7 +48,7 @@ export type LastRunStatus = NonNullable; export function statusDotColor(status: LastRunStatus | null | undefined, c: ReturnType) { 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 {tiers.map((t, 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) { 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 }) { 🔥 {n} diff --git a/frontend/src/shared/hooks/useWindowedList.ts b/frontend/src/shared/hooks/useWindowedList.ts new file mode 100644 index 00000000..66e74721 --- /dev/null +++ b/frontend/src/shared/hooks/useWindowedList.ts @@ -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(null); + + const heightsRef = useRef>(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(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('[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 }; +} diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 7068096f..a51c70bf 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -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; configurePanels: Record; workflowsHub: WorkflowsHubPosition | null; + missedRunsCard: MissedRunsCardPosition | null; notes: Record; closedCardPositions: Record; glowingBrowserCards: Record; @@ -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, diff --git a/frontend/src/shared/state/missedRunsSlice.ts b/frontend/src/shared/state/missedRunsSlice.ts new file mode 100644 index 00000000..ea99b3d6 --- /dev/null +++ b/frontend/src/shared/state/missedRunsSlice.ts @@ -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; diff --git a/frontend/src/shared/state/store.ts b/frontend/src/shared/state/store.ts index 30f5584b..11c54901 100644 --- a/frontend/src/shared/state/store.ts +++ b/frontend/src/shared/state/store.ts @@ -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 + diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index b35bd1bc..4358de95 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -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) => { + async (body: Partial & { 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; + const { meta } = arg; + const steps = draft.steps || []; + const nextDraft: Partial = { ...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 = { 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,