[aidan] feat/workflows: launch-time scheduling UX and workflow-card polish (#101)

* [aidan] feat/schedule-list: lazy-load list view via scroll sentinel

* [aidan] feat/missed-runs: launch toast with per-workflow counts and pan-to-card

* [aidan] fix/dashboard-tethers: keep watching line anchored on canvas zoom

* [aidan] feat/scheduled-tasks: review missed runs at launch instead of auto-firing on_missed

* [aidan] refactor/workflow-cards: use radius and status design tokens, polish card chrome

* [aidan] ux/agent-card: keep convert-to-workflow visible during runs with mid-turn toast

* [aidan] ux/mcp-bubble: drop redundant verb label when a workflow label is shown

* [aidan] chore/backend: remove stale explanatory comments

* [aidan] fix/workflows-hub: load workflows on hub mount so calendar fills at launch

* [aidan] feat/workflows: generate title, description, step labels at convert time

* [aidan] fix/tidy-layout: include workflows hub in tidy and fit-to-view

* [aidan] feat/schedule-list: window long list via measured-height virtualizer

* [aidan] ux/workflows-hub: remove time-saved badge from calendar header

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