[eric] workflows: fix mid-run PATCH/DELETE clobber, optimistic-concurrency PATCH, X-out ghost confirm, custom-draft no-orphan, skipped-run toast,

dup-schedule guard, honest on_missed + Q/R warnings + clearer pause-all + Killed-by-restart copy
This commit is contained in:
ciregenz
2026-05-18 00:04:07 -07:00
parent f97af593cc
commit eb561d7187
28 changed files with 1612 additions and 1175 deletions
+191
View File
@@ -0,0 +1,191 @@
# Contributing to OpenSwarm
A guide for all OpenSwarm contributors.
## Branches
There are two protected branches:
- **`main`**: the stable, production-ready branch. Every merge to `main` represents a versioned release. Never commit directly to it from any branch that is not **`dev`**.
- **`dev`**: the active development branch. All feature branches merge here first. This is where work-in-progress code lives and gets tested before release.
Never commit directly to either branch. Every change, no matter how small, gets its own branch and pull request.
### Naming format
```
yourname/type/short-description
```
All lowercase, hyphens between words. Keep it short but descriptive.
| Prefix | When to use | Example |
| --- | --- | --- |
| `feat/` | New feature | `haik/feat/add-dark-mode` |
| `fix/` | Bug fix | `arnav/fix/login-crash` |
| `refactor/` | Restructuring code without changing behavior | `cire/refactor/cleanup-auth` |
| `docs/` | Documentation only | `haik/docs/update-readme` |
| `chore/` | Build scripts, CI, dependencies, tooling | `arnav/chore/update-deps` |
### Creating a branch
```bash
git checkout dev
git pull
git checkout -b yourname/feat/my-feature
```
Always branch off of the latest `dev`.
## Commits
### Format
```
[yourname] type: short description in imperative mood
```
### Examples
```
[bob] feat: add user profile page
[bob] fix: prevent crash when token expires
[bob] refactor: split auth into separate module
[bob] docs: add setup instructions to README
[bob] chore: upgrade node to v22
```
### Rules
- Start with `[name] type:` prefix (same list as branches above).
- Use imperative mood. "add" not "added", "fix" not "fixed".
- One commit = one logical unit of work.
## The Workflow
### For day-to-day development
1. **Pull latest dev**
```bash
git checkout dev && git pull
```
2. **Create a branch**
```bash
git checkout -b yourname/feat/my-feature
```
3. **Do your work, commit as you go**
```bash
git add .
git commit -m "[yourname] feat: whatever you did"
```
4. **Push your branch**
```bash
git push
```
5. **Open a Pull Request on GitHub**
base: `dev`, compare: `yourname/feat/my-feature`.
6. **Wait for review and approval.**
7. **The maintainer merges it into `dev`** (branches are deleted automatically after merge).
### For outside contributors (people not on the core team)
1. Fork the repo (creates your own copy).
2. Clone your fork.
3. Create a branch off of `dev` and do your work (same naming conventions).
4. Push to your fork.
5. Open a Pull Request from your fork to the main repo's `dev` branch.
6. Wait for review and approval.
## Pull Requests
### Title
Use the same format as commits:
```
[yourname] feat: add dark mode toggle
[yourname] fix: resolve crash on empty input
```
### Description
Write a short explanation of what the change does and why. Two to three sentences is enough. If the change is visual, include a screenshot.
### Scope
One logical change per PR. Do not bundle unrelated work. A bug fix and a new feature should be separate PRs, even if you noticed the bug while building the feature.
## Merging
All PRs into `dev` are merged using **squash and merge**. This takes all the commits in your PR and combines them into one clean commit on `dev`. This keeps the history readable even if your branch had many small or messy commits.
Only the maintainer (i.e. Eric) merges PRs. Do not merge your own work (unless ur Eric).
### Squash and Merge
When you have a branch with, say, 5 commits:
```
feat: start building login page
fix: typo in login form
feat: add password validation
fix: forgot to import useState
feat: finish login page styling
```
**Squash and merge** takes all 5 of those and combines them into a single commit when merging the PR:
```
feat: add login page (#12)
```
So `dev` gets one clean commit instead of messy work-in-progress history. The full commit history still exists on the PR page if anyone ever needs to look at it.
**How it works:** You don't do anything special. When you click the green "Merge pull request" button on a PR, there's a dropdown arrow next to it. Pick "Squash and merge" from that dropdown. GitHub then asks you to write the final squashed commit message before confirming.
**Does it happen by default?** No. GitHub defaults to a regular merge commit. But you can change this in repo settings:
1. Go to repo **Settings > General**.
2. Scroll to **Pull Requests**.
3. Uncheck "Allow merge commits".
4. Uncheck "Allow rebase merging".
5. Keep only **"Allow squash merging"** checked.
After that, squash and merge is the only option anyone sees. No dropdown to pick from, no way to accidentally do a regular merge.
*Note: this has already been set up in our repo settings, so we're good to go. If this ever needs to be modified, call Haik.*
## Releases
When `dev` has accumulated enough changes and is stable, the maintainer merges `dev` into `main` via a PR. Every merge to `main` represents a versioned release.
### Flow
```
feature branches -> PR into dev -> test and stabilize -> PR from dev into main -> tag a release
```
### Versioning
Releases use semantic versioning:
| Change type | Version bump | Example |
| --- | --- | --- |
| Bug fixes, plus modifications or additions to existing features | Patch | `v1.0.0` -> `v1.0.1` |
| Completely new features (backwards compatible) | Minor | `v1.0.0` -> `v1.1.0` |
| Breaking changes | Major | `v1.1.0` -> `v2.0.0` |
## Quick Reference
| Action | Command |
| --- | --- |
| Update your local dev | `git checkout dev && git pull` |
| Create a new branch | `git checkout -b yourname/type/description` |
| Stage all files | `git add .` |
| Commit | `git commit -m "[yourname] type: description"` |
| Push a new branch | `git push -u origin yourname/type/description` |
| Push subsequent commits | `git push` |
| See who wrote a line | `git blame filename` |
| See commit history | `git log --oneline` |
| See your current branch | `git branch` |
| Switch to an existing branch | `git checkout branch-name` |
-21
View File
@@ -110,27 +110,6 @@ def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str,
raise ValueError(f"No credentials for provider: {provider}")
# ---------------------------------------------------------------------------
# Legacy helpers (kept for backward compat during migration)
# ---------------------------------------------------------------------------
def get_agent_sdk_env(settings: AppSettings) -> dict[str, str]:
"""Return the env dict for ClaudeAgentOptions based on connection mode.
DEPRECATED: Use create_provider() from providers.registry instead.
"""
validate_credentials(settings, "anthropic")
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
proxy_url = getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL
return {
"ANTHROPIC_AUTH_TOKEN": getattr(settings, "openswarm_bearer_token", ""),
"ANTHROPIC_BASE_URL": proxy_url,
}
return {"ANTHROPIC_API_KEY": settings.anthropic_api_key}
def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
"""Return a configured AsyncAnthropic client based on connection mode.
+44 -9
View File
@@ -37,6 +37,31 @@ def _resolve_allowed_tools(wf: Workflow) -> list[str]:
return list(wf.actions.configured_sets)
def _persist_run_fields(wf: Workflow, run_fields: dict, schedule_runs_count_delta: int = 0) -> None:
"""Merge run-side fields into the current on-disk workflow.
The executor holds the `wf` it was launched with; meanwhile the user
may have PATCHed unrelated fields (title, schedule, permissions...).
Saving our captured `wf` would clobber those edits. Re-read the
authoritative record from storage and only mutate the run-side fields
we own. If the workflow has been deleted while we ran, silently skip
the save so we don't resurrect a deleted record.
schedule_runs_count_delta is a small int (0 or 1) that we add to the
on-disk schedule.runs_count to avoid the same race overwriting an
in-flight bump on the user's PATCH path.
"""
fresh = storage.get_workflow(wf.id)
if fresh is None:
# Deleted while we ran. Don't resurrect.
return
for k, v in run_fields.items():
setattr(fresh, k, v)
if schedule_runs_count_delta:
fresh.schedule.runs_count = fresh.schedule.runs_count + schedule_runs_count_delta
storage.save_workflow(fresh)
def _monthly_spend_so_far(wf: Workflow) -> float:
"""Sum cost_usd across runs of `wf` started in the last 30 days.
@@ -80,10 +105,11 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
run.error = f"Monthly cost cap reached (${spent:.2f} / ${wf.cost_cap_usd_monthly:.2f})"
run.finished_at = datetime.now()
storage.record_run(run)
wf.last_run_at = run.finished_at
wf.last_run_status = "skipped"
wf.last_run_id = run.id
storage.save_workflow(wf)
_persist_run_fields(wf, {
"last_run_at": run.finished_at,
"last_run_status": "skipped",
"last_run_id": run.id,
})
return run
storage.record_run(run)
@@ -100,7 +126,11 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
wf.last_run_at = run.started_at
wf.last_run_status = "running"
wf.last_run_id = run.id
storage.save_workflow(wf)
_persist_run_fields(wf, {
"last_run_at": run.started_at,
"last_run_status": "running",
"last_run_id": run.id,
})
try:
steps = [s.text for s in wf.steps if s.text and s.text.strip()]
@@ -158,11 +188,13 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
wf.last_run_status = "success"
# Bump runs_count for scheduled fires that reached a terminal state
# other than "skipped". Manual runs don't count against max_runs.
if triggered_by == "schedule" and run.status in ("success", "ran_late", "failure"):
wf.schedule.runs_count += 1
runs_delta = 1 if (triggered_by == "schedule" and run.status in ("success", "ran_late", "failure")) else 0
storage.record_run(run)
wf.last_run_at = run.finished_at
storage.save_workflow(wf)
_persist_run_fields(wf, {
"last_run_at": run.finished_at,
"last_run_status": wf.last_run_status,
}, schedule_runs_count_delta=runs_delta)
except Exception as e:
logger.exception("Workflow run failed: %s", e)
run.status = "failure"
@@ -170,7 +202,10 @@ async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: O
run.finished_at = datetime.now()
storage.record_run(run)
wf.last_run_status = "failure"
storage.save_workflow(wf)
_persist_run_fields(wf, {
"last_run_status": "failure",
"last_run_at": run.finished_at,
})
finally:
async with _running_lock:
_running.pop(wf.id, None)
+6 -1
View File
@@ -262,7 +262,12 @@ def _mark_stuck_runs_failed() -> None:
for wf in storage.list_workflows():
for r in storage.list_runs(wf.id, limit=200):
if r.status == "running":
storage.update_run(r.id, status="failure", error="Killed by restart", finished_at=now)
storage.update_run(
r.id,
status="failure",
error="OpenSwarm closed before this run finished.",
finished_at=now,
)
def reconcile_on_startup() -> None:
+34 -7
View File
@@ -4,7 +4,7 @@ from contextlib import asynccontextmanager
from datetime import datetime
from typing import Optional
from fastapi import HTTPException
from fastapi import HTTPException, Header, Request
from backend.config.Apps import SubApp
from backend.apps.workflows.models import (
@@ -183,10 +183,32 @@ async def get_workflow_audit(workflow_id: str, limit: int = 50):
@workflows.router.patch("/{workflow_id}")
async def update_workflow(workflow_id: str, body: WorkflowUpdate):
async def update_workflow(
workflow_id: str,
body: WorkflowUpdate,
if_match: Optional[str] = Header(default=None, alias="If-Match"),
):
wf = storage.get_workflow(workflow_id)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
# Optimistic concurrency: if the client passed If-Match, verify it
# matches the current updated_at. Stale writes (another window or a
# mid-edit background fire) get a 409 so the FE can prompt to reload
# instead of silently clobbering the other actor's changes. Missing
# header = legacy client, allow through (back-compat with the
# frontend's pre-409 code path; FE rolls out If-Match immediately).
if if_match:
current_stamp = wf.updated_at.isoformat() if hasattr(wf.updated_at, "isoformat") else str(wf.updated_at)
# Strip quotes a well-behaved HTTP client might add per RFC 7232.
if if_match.strip().strip('"') != current_stamp:
raise HTTPException(
status_code=409,
detail={
"error": "stale_update",
"message": "This workflow changed in another window or by a recent run. Reload and try again.",
"current_updated_at": current_stamp,
},
)
before = wf.model_dump(mode="json")
data = body.model_dump(exclude_unset=True)
for k, v in data.items():
@@ -221,15 +243,20 @@ async def run_workflow_now(workflow_id: str):
pre_ids = {r.id for r in storage.list_runs(wf.id, limit=10)}
asyncio.create_task(executor.execute(wf, triggered_by="manual"))
# Poll briefly for the newly created run id (anything not already in
# the pre-fire snapshot). Falls back to empty if the executor hasn't
# written within 250ms — frontend reconciles via WS afterwards.
# Poll briefly for the newly created run id. We also surface the
# run's status + error string when it lands quickly (e.g. cost-cap
# short-circuit, _running collision) so the FE can render a toast
# instead of silently switching to History.
for _ in range(25):
for r in storage.list_runs(wf.id, limit=10):
if r.id not in pre_ids and r.triggered_by == "manual":
return {"run_id": r.id}
return {
"run_id": r.id,
"status": r.status,
"error": r.error,
}
await asyncio.sleep(0.01)
return {"run_id": ""}
return {"run_id": "", "status": None, "error": None}
@workflows.router.get("/{workflow_id}/runs")
+116
View File
@@ -305,6 +305,122 @@ def test_escalation_schedules_and_ack_cancels():
asyncio.new_event_loop().run_until_complete(runner())
def test_executor_merge_does_not_clobber_concurrent_patch():
"""Executor's final save must NOT overwrite unrelated fields that
were PATCHed while the run was in flight. We simulate this by
capturing a wf, mutating storage's record directly (acting as the
PATCH that landed mid-run), then asking the executor's persist
helper to flush its run-side bookkeeping. The patched fields must
survive.
"""
from backend.apps.workflows import storage, executor
from datetime import datetime
wf = _make_wf(title="t-orig")
storage.save_workflow(wf)
# Simulate a user PATCH mid-run.
storage._workflow_cache[wf.id].title = "t-patched"
storage._workflow_cache[wf.id].description = "patched while running"
storage.save_workflow(storage._workflow_cache[wf.id])
# Executor uses the stale `wf` it captured before the patch. With
# the merge helper, the patched fields must remain.
executor._persist_run_fields(wf, {
"last_run_at": datetime.now(),
"last_run_status": "success",
})
after = storage.get_workflow(wf.id)
assert after.title == "t-patched", "title clobbered by executor"
assert after.description == "patched while running", "description clobbered"
assert after.last_run_status == "success"
def test_executor_delete_during_run_does_not_resurrect():
"""If the workflow was deleted mid-run, executor's persist must
silently no-op so the deleted record isn't re-written."""
from backend.apps.workflows import storage, executor
from datetime import datetime
wf = _make_wf(title="doomed")
storage.save_workflow(wf)
storage.delete_workflow(wf.id)
executor._persist_run_fields(wf, {
"last_run_at": datetime.now(),
"last_run_status": "success",
}, schedule_runs_count_delta=1)
assert storage.get_workflow(wf.id) is None
def test_patch_if_match_rejects_stale_write():
"""A PATCH with a stale If-Match must return 409. Without If-Match,
the request still succeeds (legacy clients keep working until they
roll out the header)."""
from backend.apps.workflows.workflows import update_workflow
from backend.apps.workflows.models import WorkflowUpdate
from backend.apps.workflows import storage
from fastapi import HTTPException
wf = _make_wf(title="optimistic-test")
storage.save_workflow(wf)
stale = "1999-01-01T00:00:00"
async def runner():
# Stale If-Match → 409.
try:
await update_workflow(wf.id, WorkflowUpdate(title="x"), if_match=stale)
return "no exception"
except HTTPException as he:
return he.status_code
code = asyncio.new_event_loop().run_until_complete(runner())
assert code == 409, f"stale If-Match should 409, got {code}"
# Fresh If-Match → 200.
fresh = storage.get_workflow(wf.id)
fresh_stamp = fresh.updated_at.isoformat()
async def runner_ok():
return await update_workflow(wf.id, WorkflowUpdate(title="y"), if_match=fresh_stamp)
result = asyncio.new_event_loop().run_until_complete(runner_ok())
assert result["title"] == "y"
# Missing If-Match → legacy path still works.
async def runner_legacy():
return await update_workflow(wf.id, WorkflowUpdate(title="z"), if_match=None)
result = asyncio.new_event_loop().run_until_complete(runner_legacy())
assert result["title"] == "z"
def test_killed_by_restart_message_is_friendly():
"""stuck-run reaper writes a user-facing string, not internal jargon."""
from backend.apps.workflows import storage, scheduler
from backend.apps.workflows.models import WorkflowRun
wf = _make_wf()
storage.save_workflow(wf)
storage.record_run(WorkflowRun(workflow_id=wf.id, status="running"))
scheduler._mark_stuck_runs_failed()
runs = storage.list_runs(wf.id, limit=10)
assert any(r.status == "failure" and "OpenSwarm closed" in (r.error or "") for r in runs)
assert not any("Killed by restart" in (r.error or "") for r in runs)
def test_run_endpoint_surfaces_skipped_status():
"""POST /workflows/{id}/run returns the skipped status + error when
a cost-cap or in-flight collision short-circuits the run."""
from backend.apps.workflows.workflows import run_workflow_now
from backend.apps.workflows import storage
from backend.apps.workflows.models import WorkflowRun
from datetime import datetime, timezone
wf = _make_wf(title="cap-immediate")
wf.cost_cap_usd_monthly = 0.01
storage.save_workflow(wf)
# Burn the cap with a single $5 historical run.
storage.record_run(WorkflowRun(workflow_id=wf.id, status="success", cost_usd=5.0,
started_at=datetime.now(timezone.utc),
finished_at=datetime.now(timezone.utc)))
async def runner():
return await run_workflow_now(wf.id)
res = asyncio.new_event_loop().run_until_complete(runner())
assert res.get("status") == "skipped"
assert "cost cap" in (res.get("error") or "").lower()
def test_escalation_noop_for_single_tier():
from backend.apps.workflows import escalation
from backend.apps.workflows.models import Workflow, PermissionTier, WorkflowRun
-115
View File
@@ -1,115 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import { DURATION_MS, EASE } from '@/shared/styles/motionTokens';
import { useReducedMotion } from '@/shared/hooks/useReducedMotion';
/**
* Smooth visual transitions for status pills + counters that currently snap.
*
* <CrossFadeOnChange value={x}>{(v) => <span>{v}</span>}</CrossFadeOnChange>
* Old value fades to 30% while new value fades in. Cancels on rapid changes.
*
* <TweeningNumber value={1234} format={(n) => `$${n.toFixed(4)}`} />
* RAF-tweens from previous to new value. Caps duration on big jumps.
*/
interface CrossFadeProps<T> {
value: T;
children: (currentValue: T) => React.ReactNode;
/** Defaults to DURATION_MS.quick (140ms). */
durationMs?: number;
}
export function CrossFadeOnChange<T>({ value, children, durationMs }: CrossFadeProps<T>) {
const reduced = useReducedMotion();
const dur = reduced ? 0 : (durationMs ?? DURATION_MS.quick);
const [displayed, setDisplayed] = useState(value);
const [opacity, setOpacity] = useState(1);
useEffect(() => {
if (Object.is(displayed, value)) return;
if (dur === 0) {
setDisplayed(value);
return;
}
// Fade old to ~0, then swap and fade new in.
setOpacity(0);
const t = setTimeout(() => {
setDisplayed(value);
setOpacity(1);
}, dur / 2);
return () => clearTimeout(t);
}, [value, dur, displayed]);
return (
<Box
component="span"
sx={{
display: 'inline-block',
opacity,
transition: `opacity ${dur / 2}ms ${EASE.out}`,
}}
>
{children(displayed)}
</Box>
);
}
interface TweeningNumberProps {
value: number;
/** How to render the tweened number. Default: `n.toString()`. */
format?: (n: number) => string;
/** Cap on tween duration regardless of delta. Default 500ms. */
maxDurationMs?: number;
}
export const TweeningNumber: React.FC<TweeningNumberProps> = ({
value,
format = (n) => String(Math.round(n)),
maxDurationMs = 500,
}) => {
const reduced = useReducedMotion();
const [displayed, setDisplayed] = useState(value);
const startedAtRef = useRef<number | null>(null);
const fromRef = useRef<number>(value);
const toRef = useRef<number>(value);
const rafRef = useRef<number | null>(null);
useEffect(() => {
if (reduced) {
setDisplayed(value);
return;
}
if (Object.is(toRef.current, value)) return;
fromRef.current = displayed;
toRef.current = value;
startedAtRef.current = performance.now();
// Duration scales with delta but caps. ~1ms per unit, capped.
const delta = Math.abs(value - fromRef.current);
const dur = Math.min(maxDurationMs, Math.max(120, delta * 1.2));
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
const step = (now: number) => {
const t = Math.min(1, (now - (startedAtRef.current as number)) / dur);
// ease-out cubic
const eased = 1 - Math.pow(1 - t, 3);
const current = fromRef.current + (toRef.current - fromRef.current) * eased;
setDisplayed(current);
if (t < 1) {
rafRef.current = requestAnimationFrame(step);
} else {
rafRef.current = null;
}
};
rafRef.current = requestAnimationFrame(step);
return () => {
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
};
}, [value, reduced, maxDurationMs]); // eslint-disable-line react-hooks/exhaustive-deps
return <>{format(displayed)}</>;
};
+18 -7
View File
@@ -946,13 +946,24 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
)}
</Box>
{!isDraft && id && (
<Tooltip title="Schedule this chat as a recurring workflow">
<IconButton
size="small"
onClick={(e) => setScheduleAnchor(e.currentTarget)}
sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
<ScheduleIcon fontSize="small" />
</IconButton>
<Tooltip title="Turn this chat into a recurring workflow that runs on its own.">
<Box
onClick={(e) => setScheduleAnchor(e.currentTarget as HTMLElement)}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
fontSize: '0.78rem', fontWeight: 600,
color: c.accent.primary,
bgcolor: c.accent.primary + '14',
border: `1px solid ${c.accent.primary}40`,
px: 0.85, py: 0.35,
borderRadius: `${c.radius.md}px`,
cursor: 'pointer',
'&:hover': { bgcolor: c.accent.primary + '22' },
}}>
<ScheduleIcon sx={{ fontSize: 14 }} />
Schedule
</Box>
</Tooltip>
)}
{!isDraft && id && (
@@ -1,60 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
interface Props {
currentIndex: number;
totalBranches: number;
onPrevious: () => void;
onNext: () => void;
}
const BranchNavigator: React.FC<Props> = ({ currentIndex, totalBranches, onPrevious, onNext }) => {
const c = useClaudeTokens();
if (totalBranches <= 1) return null;
return (
<Box
sx={{
display: 'flex',
justifyContent: 'flex-end',
mt: -0.25,
mb: 0.5,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.25,
}}
>
<IconButton
size="small"
onClick={onPrevious}
disabled={currentIndex === 0}
sx={{ color: c.text.tertiary, p: 0.25, '&.Mui-disabled': { color: c.border.medium } }}
>
<ChevronLeftIcon sx={{ fontSize: 16 }} />
</IconButton>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.7rem', minWidth: 28, textAlign: 'center', userSelect: 'none' }}>
{currentIndex + 1} / {totalBranches}
</Typography>
<IconButton
size="small"
onClick={onNext}
disabled={currentIndex === totalBranches - 1}
sx={{ color: c.text.tertiary, p: 0.25, '&.Mui-disabled': { color: c.border.medium } }}
>
<ChevronRightIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
</Box>
);
};
export default BranchNavigator;
@@ -1,402 +0,0 @@
import React, { useRef, useEffect, useCallback } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const PALETTES = {
salmon: ['#C46B57', '#D4795F', '#E8927A', '#F0A088', '#F5B49E'],
blue: ['#445588', '#5577AA', '#6688BB', '#7799CC', '#88AADD'],
coral: ['#993344', '#AA3D4E', '#BB4455', '#CC5566', '#DD6677'],
green: ['#447755', '#558866', '#669977', '#77AA88', '#88BB99'],
purple: ['#665588', '#7766AA', '#8877BB', '#9988CC', '#AA99DD'],
} as const;
type PaletteKey = keyof typeof PALETTES;
interface PixelChartProps {
data: { label: string; value: number }[];
palette?: PaletteKey;
height?: number;
pixelSize?: number;
formatValue?: (v: number) => string;
glow?: boolean;
showXLabels?: boolean;
showYScale?: boolean;
mode?: 'bar' | 'area'; // 'area' draws a filled line chart instead of bars
}
const PixelChart: React.FC<PixelChartProps> = ({
data,
palette = 'salmon',
height = 140,
pixelSize = 6,
formatValue,
glow = true,
showXLabels = true,
showYScale = true,
mode = 'bar',
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const animRef = useRef(0);
const progressRef = useRef(0);
const hoverIdxRef = useRef(-1);
const tooltipRef = useRef<HTMLDivElement>(null);
const c = useClaudeTokens();
const colors = PALETTES[palette];
const maxVal = Math.max(...data.map((d) => d.value), 0.001);
// Compute nice Y-axis ticks
const yTicks = (() => {
if (maxVal <= 0) return [0];
const rawStep = maxVal / 3;
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
const normalised = rawStep / magnitude;
let niceStep: number;
if (normalised <= 1) niceStep = magnitude;
else if (normalised <= 2) niceStep = 2 * magnitude;
else if (normalised <= 5) niceStep = 5 * magnitude;
else niceStep = 10 * magnitude;
const ticks: number[] = [];
for (let v = 0; v <= maxVal * 1.1; v += niceStep) {
ticks.push(v);
}
if (ticks.length < 2) ticks.push(niceStep);
return ticks;
})();
// X-axis labels: show first, last, and up to 3 evenly spaced
const xLabels = (() => {
if (data.length <= 1) return data.map((d, i) => ({ idx: i, label: d.label }));
if (data.length <= 5) return data.map((d, i) => ({ idx: i, label: d.label }));
const result: { idx: number; label: string }[] = [];
result.push({ idx: 0, label: data[0].label });
const step = Math.floor(data.length / 4);
for (let i = 1; i <= 3; i++) {
const idx = Math.min(i * step, data.length - 2);
if (idx > 0 && idx < data.length - 1) {
result.push({ idx, label: data[idx].label });
}
}
result.push({ idx: data.length - 1, label: data[data.length - 1].label });
return result;
})();
const Y_LABEL_WIDTH = showYScale ? 80 : 0;
const draw = useCallback(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container || data.length === 0) return;
const dpr = window.devicePixelRatio || 1;
const totalW = container.clientWidth;
const chartW = totalW - Y_LABEL_WIDTH;
const h = height;
canvas.width = totalW * dpr;
canvas.height = h * dpr;
canvas.style.width = `${totalW}px`;
canvas.style.height = `${h}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(dpr, dpr);
const px = pixelSize;
const gridCols = Math.floor(chartW / px);
const gridRows = Math.floor(h / px);
const effectiveMax = yTicks[yTicks.length - 1] || maxVal;
ctx.clearRect(0, 0, totalW, h);
// Y-axis labels and horizontal grid lines
if (showYScale) {
ctx.font = '10px monospace';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (const tick of yTicks) {
const yNorm = effectiveMax > 0 ? tick / effectiveMax : 0;
const yPx = h - yNorm * (h - px);
// Grid line
ctx.strokeStyle = c.border.subtle;
ctx.lineWidth = 0.5;
ctx.setLineDash([2, 4]);
ctx.beginPath();
ctx.moveTo(Y_LABEL_WIDTH, yPx);
ctx.lineTo(totalW, yPx);
ctx.stroke();
ctx.setLineDash([]);
// Label
const label = formatValue ? formatValue(tick) : (tick % 1 === 0 ? String(tick) : tick.toFixed(1));
ctx.fillStyle = c.text.ghost;
ctx.fillText(label, Y_LABEL_WIDTH - 8, yPx);
}
}
// Subtle grid dots in chart area
ctx.fillStyle = c.border.subtle;
for (let gy = 0; gy < gridRows; gy += 5) {
for (let gx = 0; gx < gridCols; gx += 5) {
ctx.fillRect(Y_LABEL_WIDTH + gx * px, gy * px, 1, 1);
}
}
const progress = Math.min(progressRef.current, 1);
const hoverIdx = hoverIdxRef.current;
if (mode === 'area') {
// -- Area / line chart mode --
const usableH = h - px * 2;
const points: { x: number; y: number }[] = [];
for (let i = 0; i < data.length; i++) {
const val = data[i].value;
const norm = effectiveMax > 0 ? val / effectiveMax : 0;
const x = Y_LABEL_WIDTH + (i / Math.max(data.length - 1, 1)) * chartW;
const y = h - px - norm * usableH * progress;
points.push({ x, y });
}
if (points.length > 0) {
// Filled area with gradient
const gradient = ctx.createLinearGradient(0, 0, 0, h);
gradient.addColorStop(0, colors[colors.length - 1] + '60');
gradient.addColorStop(0.5, colors[Math.floor(colors.length / 2)] + '30');
gradient.addColorStop(1, colors[0] + '08');
ctx.beginPath();
ctx.moveTo(points[0].x, h);
for (let i = 0; i < points.length; i++) {
if (i === 0) {
ctx.lineTo(points[i].x, points[i].y);
} else {
const prev = points[i - 1];
const curr = points[i];
const cpx = (prev.x + curr.x) / 2;
ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y);
}
}
ctx.lineTo(points[points.length - 1].x, h);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
// Line on top
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
if (i === 0) {
ctx.moveTo(points[i].x, points[i].y);
} else {
const prev = points[i - 1];
const curr = points[i];
const cpx = (prev.x + curr.x) / 2;
ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y);
}
}
ctx.strokeStyle = colors[colors.length - 1];
ctx.lineWidth = 2;
ctx.stroke();
// Glow on line
if (glow) {
ctx.shadowColor = colors[colors.length - 1];
ctx.shadowBlur = 8;
ctx.stroke();
ctx.shadowBlur = 0;
}
// Data point dots
for (let i = 0; i < points.length; i++) {
if (data[i].value > 0) {
const isHov = i === hoverIdx;
ctx.beginPath();
ctx.arc(points[i].x, points[i].y, isHov ? 4 : 2.5, 0, Math.PI * 2);
ctx.fillStyle = isHov ? colors[colors.length - 1] : colors[Math.floor(colors.length / 2)];
ctx.fill();
if (isHov) {
ctx.strokeStyle = colors[colors.length - 1];
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
}
// Pixel scatter in the filled area for the pixel art feel
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i];
const p2 = points[i + 1];
const steps = Math.ceil((p2.x - p1.x) / px);
for (let s = 0; s < steps; s++) {
const t = s / steps;
const x = p1.x + t * (p2.x - p1.x);
const lineY = p1.y + t * (p2.y - p1.y);
for (let py = lineY + px * 2; py < h - px; py += px * 2) {
if (Math.random() > 0.65) {
const depth = (py - lineY) / (h - lineY);
const ci = Math.max(0, Math.floor((1 - depth) * (colors.length - 1)));
ctx.globalAlpha = 0.15 + (1 - depth) * 0.2;
ctx.fillStyle = colors[ci];
ctx.fillRect(Math.floor(x / px) * px, Math.floor(py / px) * px, px - 1, px - 1);
}
}
}
}
ctx.globalAlpha = 1;
}
} else {
// -- Bar chart mode (original) --
const barSlots = data.length;
const totalBarPx = Math.max(1, Math.floor(gridCols / barSlots));
const barW = Math.max(1, totalBarPx - 1);
for (let i = 0; i < data.length; i++) {
const val = data[i].value;
const normalised = effectiveMax > 0 ? val / effectiveMax : 0;
const usableRows = gridRows - 2;
const targetH = Math.max(normalised > 0 ? 1 : 0, Math.round(normalised * usableRows));
const barH = Math.round(targetH * progress);
const barX = i * totalBarPx;
const isHovered = i === hoverIdx;
for (let row = 0; row < barH; row++) {
const y = gridRows - 1 - row;
const colorIdx = Math.min(colors.length - 1, Math.floor((row / Math.max(barH - 1, 1)) * (colors.length - 1)));
const baseColor = isHovered ? colors[Math.min(colorIdx + 1, colors.length - 1)] : colors[colorIdx];
for (let col = 0; col < barW; col++) {
ctx.fillStyle = baseColor;
ctx.fillRect(Y_LABEL_WIDTH + (barX + col) * px, y * px, px - 1, px - 1);
}
}
if (glow && barH > 0) {
const topY = (gridRows - 1 - barH + 1) * px;
ctx.shadowColor = colors[colors.length - 1];
ctx.shadowBlur = 6;
ctx.fillStyle = colors[colors.length - 1];
for (let col = 0; col < barW; col++) {
ctx.fillRect(Y_LABEL_WIDTH + (barX + col) * px, topY, px - 1, px - 1);
}
ctx.shadowBlur = 0;
}
}
}
}, [data, height, pixelSize, c, colors, glow, maxVal, yTicks, showYScale, Y_LABEL_WIDTH, formatValue, mode]);
useEffect(() => {
progressRef.current = 0;
let start: number | null = null;
const animate = (ts: number) => {
if (!start) start = ts;
progressRef.current = Math.min(1, (ts - start) / 600);
draw();
if (progressRef.current < 1) animRef.current = requestAnimationFrame(animate);
};
animRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animRef.current);
}, [data, draw]);
useEffect(() => {
const handleResize = () => draw();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [draw]);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
const canvas = canvasRef.current;
const tooltip = tooltipRef.current;
if (!canvas || !tooltip || data.length === 0) return;
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left - Y_LABEL_WIDTH;
if (mx < 0) { hoverIdxRef.current = -1; tooltip.style.opacity = '0'; draw(); return; }
const chartW = rect.width - Y_LABEL_WIDTH;
const gridCols = Math.floor(chartW / pixelSize);
const totalBarPx = Math.max(1, Math.floor(gridCols / data.length));
const idx = Math.floor(mx / (totalBarPx * pixelSize));
if (idx >= 0 && idx < data.length) {
hoverIdxRef.current = idx;
const d = data[idx];
const valStr = formatValue ? formatValue(d.value) : d.value.toFixed(2);
tooltip.textContent = `${d.label}: ${valStr}`;
tooltip.style.opacity = '1';
tooltip.style.left = `${e.clientX - rect.left}px`;
tooltip.style.top = `${e.clientY - rect.top - 28}px`;
} else {
hoverIdxRef.current = -1;
tooltip.style.opacity = '0';
}
draw();
},
[data, pixelSize, draw, formatValue, Y_LABEL_WIDTH],
);
const handleMouseLeave = useCallback(() => {
hoverIdxRef.current = -1;
if (tooltipRef.current) tooltipRef.current.style.opacity = '0';
draw();
}, [draw]);
return (
<Box ref={containerRef} sx={{ position: 'relative', width: '100%' }}>
<canvas
ref={canvasRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
style={{ display: 'block', width: '100%', imageRendering: 'pixelated', cursor: 'crosshair' }}
/>
{/* X-axis labels */}
{showXLabels && data.length > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 0.5, pl: `${Y_LABEL_WIDTH}px` }}>
{xLabels.map((xl) => (
<Typography
key={xl.idx}
sx={{
color: c.text.ghost,
fontSize: '0.58rem',
fontFamily: c.font.mono,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: 60,
}}
>
{xl.label}
</Typography>
))}
</Box>
)}
{/* Tooltip */}
<Box
ref={tooltipRef}
sx={{
position: 'absolute',
pointerEvents: 'none',
opacity: 0,
transition: 'opacity 0.12s',
bgcolor: c.bg.inverse,
color: c.text.inverse,
fontSize: '0.7rem',
fontFamily: c.font.mono,
fontWeight: 500,
px: 1,
py: 0.35,
borderRadius: 0.75,
whiteSpace: 'nowrap',
transform: 'translateX(-50%)',
zIndex: 10,
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
}}
/>
</Box>
);
};
export default PixelChart;
@@ -1,60 +0,0 @@
import React from 'react';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
interface Props {
open: boolean;
onCancel: () => void;
onConfirm: () => void;
}
const CloseAgentDialog: React.FC<Props> = ({ open, onCancel, onConfirm }) => {
const c = useClaudeTokens();
return (
<Dialog
open={open}
onClose={onCancel}
PaperProps={{
sx: {
bgcolor: c.bg.surface,
borderRadius: 4,
border: `1px solid ${c.border.subtle}`,
minWidth: 380,
},
}}
>
<DialogTitle sx={{ color: c.status.warning, fontWeight: 700, fontSize: '1rem', pb: 0.5 }}>
Agent still running
</DialogTitle>
<DialogContent>
<DialogContentText sx={{ color: c.text.muted, fontSize: '0.875rem' }}>
This agent is still running. Closing it will pause the agent.
You can resume it later from the chat history.
</DialogContentText>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onCancel} sx={{ color: c.text.tertiary }}>
Cancel
</Button>
<Button
onClick={onConfirm}
variant="contained"
sx={{
bgcolor: c.status.warning,
'&:hover': { bgcolor: '#6b4a18' },
fontWeight: 600,
}}
>
Close &amp; Pause
</Button>
</DialogActions>
</Dialog>
);
};
export default CloseAgentDialog;
@@ -10,11 +10,33 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchCloudSmsStatus, type Workflow, type ScheduleConfig, type PermissionTier } from '@/shared/state/workflowsSlice';
import { WEEKDAY_LABEL, formatTime, fireTimesWithin } from './scheduleUtils';
import { routingFor } from './workflowVisuals';
import { nextTierAfter } from './permissionsUtils';
import { BODY_FS, LABEL_FS, HINT_FS, INPUT_FS } from './workflowEditCommon';
function jsWeekday(d: Date): number { return d.getDay(); }
// Turn an IANA zone string into something a non-dev can parse. "local"
// (legacy) or the host's own zone collapse to "your time"; otherwise
// show "Pacific Time" / "Eastern Time" / etc. when we can resolve a
// short name via Intl, falling back to the raw IANA name if not.
function friendlyTzLabel(tz: string): string {
if (!tz || tz === 'local') return 'your time';
try {
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (tz === host) {
const parts = new Intl.DateTimeFormat('en', { timeZone: tz, timeZoneName: 'long' }).formatToParts(new Date());
const name = parts.find((p) => p.type === 'timeZoneName')?.value || '';
return name ? `your time (${name.replace(' Standard Time', '').replace(' Daylight Time', '')})` : 'your time';
}
const parts = new Intl.DateTimeFormat('en', { timeZone: tz, timeZoneName: 'long' }).formatToParts(new Date());
const name = parts.find((p) => p.type === 'timeZoneName')?.value || '';
return name || tz;
} catch {
return tz;
}
}
function lastDayOfMonthFE(year: number, monthZeroBased: number): number {
return new Date(year, monthZeroBased + 1, 0).getDate();
}
@@ -195,7 +217,7 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
</Box>
{s.repeat_unit === 'week' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, pl: 2, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}> on</Typography>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>on</Typography>
{WEEKDAY_LABEL.map((label, idx) => {
const active = s.on_days.includes(idx);
return (
@@ -209,7 +231,7 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, pl: 2 }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}> at</Typography>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>at</Typography>
{/* 12-hour picker; backend stores 0..23 but the UI uses 1..12+AM/PM
so users can't accidentally schedule "3" thinking it's 3pm and
get a 3am run. */}
@@ -250,7 +272,7 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
<MenuItem value="AM">AM</MenuItem>
<MenuItem value="PM">PM</MenuItem>
</Select>
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost, ml: 1 }}>{s.timezone === 'local' ? 'system tz' : s.timezone}</Typography>
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost, ml: 1 }}>{friendlyTzLabel(s.timezone)}</Typography>
</Box>
{nextPreview && s.enabled && (
@@ -267,9 +289,9 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
value={endKind}
onChange={(e) => setEndKind(e.target.value as EndKind)}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="forever">Forever</MenuItem>
<MenuItem value="forever">Until I turn it off</MenuItem>
<MenuItem value="on_date">Until a date</MenuItem>
<MenuItem value="after_n">After N runs</MenuItem>
<MenuItem value="after_n">After a number of runs</MenuItem>
</Select>
{endKind === 'on_date' && (
<InputBase
@@ -294,13 +316,36 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
</Box>
)}
</Box>
{/* Inline warnings when the end condition is already satisfied; the
scheduler will auto-disable on the next tick which surprises
users who expected to arm a fresh schedule. */}
{(() => {
if (endKind === 'on_date' && s.ends_at) {
const ends = new Date(s.ends_at).getTime();
if (!Number.isNaN(ends) && ends <= Date.now()) {
return (
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, pl: 2 }}>
This date is in the past. The schedule will turn itself off.
</Typography>
);
}
}
if (endKind === 'after_n' && s.max_runs != null && s.runs_count >= s.max_runs) {
return (
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, pl: 2 }}>
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;
})()}
{/* Row 5: cost. Pass the live draft schedule so the row stays in
sync with the "Next run" preview even before the user saves. */}
<CostRow workflow={draft} draftSched={s} onCapChange={(v) => setDraft({ ...draft, cost_cap_usd_monthly: v })} />
{/* Row 6: action surface (freeze). */}
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, mt: 0.5 }}>Which actions can the agent use?</Typography>
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, mt: 0.5 }}>What can the agent do while it runs?</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pl: 2 }}>
<Select
size="small"
@@ -308,28 +353,30 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
onChange={(e) => {
const scoped = e.target.value === 'scoped';
if (!scoped) {
const ok = window.confirm('"Full agent access" lets this scheduled run execute Bash, edit files, and use any installed action. Are you sure?');
const ok = window.confirm('Full access lets this scheduled run do anything an agent normally can: run commands, edit files, browse the web, send messages. Continue?');
if (!ok) return;
}
setDraft({ ...draft, actions: { ...draft.actions, freeze: scoped } });
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="scoped">Scoped to actions used in original chat (recommended)</MenuItem>
<MenuItem value="full">Full agent access (Bash, file write)</MenuItem>
<MenuItem value="scoped">Only what the original chat used (recommended)</MenuItem>
<MenuItem value="full">Anything an agent can do (run commands, edit files, browse)</MenuItem>
</Select>
</Box>
{/* Row 7: missed-run policy. */}
{/* Row 7: missed-run policy. Backend implements one catch-up only
today, so we don't expose a "run every missed time" option that
we couldn't honor. If the backend gains real replay support
later, add the third option back. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pl: 2, mt: 0.25 }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>If a run was missed (computer asleep):</Typography>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>If your computer was asleep when a run was due:</Typography>
<Select
size="small"
value={s.on_missed}
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 it</MenuItem>
<MenuItem value="run_once">Run once when app reopens</MenuItem>
<MenuItem value="run_all">Run every missed slot</MenuItem>
<MenuItem value="skip">Skip the missed run</MenuItem>
<MenuItem value="run_once">Run once after I wake the app</MenuItem>
</Select>
</Box>
@@ -346,7 +393,7 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
/>
))}
{canAddBackup && (
<Box onClick={addBackup} role="button" sx={{ fontSize: LABEL_FS, color: c.text.muted, cursor: 'pointer', mt: 0.5, fontWeight: 500, '&:hover': { color: c.accent.primary } }}>+ add a backup</Box>
<Box onClick={addBackup} role="button" sx={{ fontSize: LABEL_FS, color: c.text.muted, cursor: 'pointer', mt: 0.5, fontWeight: 500, '&:hover': { color: c.accent.primary } }}>+ Escalate if I don&apos;t respond</Box>
)}
</Box>
);
@@ -365,11 +412,11 @@ function AppOpenStatusBadge({ info, hour, minute, onFix }: { info: AppOpenInfo;
}}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: good ? c.status.success : (c.status.warning || c.text.muted) }} />
<Typography sx={{ flex: 1, fontSize: HINT_FS, color: c.text.primary }}>
{good ? 'Will fire even if OpenSwarm is closed.' : `Requires OpenSwarm to be open at ${fmt}.`}
{good ? 'Will run even if you close OpenSwarm.' : `OpenSwarm must be open at ${fmt} for this to run.`}
</Typography>
{!good && (
<Tooltip title="Enables launch-at-login and the menubar tray so the scheduler keeps running.">
<Box onClick={onFix} role="button" sx={{ fontSize: HINT_FS, color: c.accent.primary, cursor: 'pointer', fontWeight: 700 }}>Fix</Box>
<Tooltip title="One click: start OpenSwarm automatically when you log in, and keep a small icon in your menubar so it stays running when you close the window. You can undo both later in Settings.">
<Box onClick={onFix} role="button" sx={{ fontSize: HINT_FS, color: c.accent.primary, cursor: 'pointer', fontWeight: 700, whiteSpace: 'nowrap' }}>Always-on</Box>
</Tooltip>
)}
</Box>
@@ -379,6 +426,7 @@ function AppOpenStatusBadge({ info, hour, minute, onFix }: { info: AppOpenInfo;
function CostRow({ workflow, draftSched, onCapChange }: { workflow: Workflow; draftSched: ScheduleConfig; onCapChange: (v: number | null) => void }) {
const c = useClaudeTokens();
const est = workflow.cost_estimate;
const connectionMode = useAppSelector((s) => (s as { settings?: { data?: { connection_mode?: string } } }).settings?.data?.connection_mode);
// Compute fires/30-days live from the draft so the row matches the
// "Next run" preview even before the user saves. Backend's cached
// estimate is the saved-state value and would lie after a draft edit.
@@ -388,20 +436,46 @@ function CostRow({ workflow, draftSched, onCapChange }: { workflow: Workflow; dr
const end = new Date(now.getTime() + 30 * 86400000);
return fireTimesWithin({ schedule: draftSched } as Workflow, now, end, 200).length;
}, [draftSched]);
const route = routingFor(workflow.model, connectionMode);
const lastRun = est?.last_run_usd ?? 0;
const monthly = lastRun * liveFires;
const cap = workflow.cost_cap_usd_monthly;
// Subscription-routed workflows have no per-call cost we can project,
// so swap the row from "$X.XX/mo" copy to a usage-estimate sentence
// that tells the truth: covered by the plan, here's how often it fires.
if (route.kind === 'subscription') {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, pl: 2 }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>
{liveFires > 0
? `Will use about ${liveFires} run${liveFires === 1 ? '' : 's'} per month from your ${route.subLabel} plan. No per-run cost.`
: `Covered by your ${route.subLabel} plan. No upcoming runs yet.`}
</Typography>
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost }}>
A monthly cost cap doesn&apos;t apply here. Your plan handles the usage limits.
</Typography>
</Box>
);
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, pl: 2 }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>
{liveFires > 0 && lastRun > 0
? `~$${monthly.toFixed(2)}/mo at last run's cost ($${lastRun.toFixed(4)} × ${liveFires} fires).`
? `About $${monthly.toFixed(2)} per month at the last run's cost.`
: liveFires > 0
? `Will fire ${liveFires}× in the next 30 days. Run once to project a monthly cost.`
? `Will run ${liveFires} time${liveFires === 1 ? '' : 's'} in the next 30 days. Run once to project a monthly cost.`
: 'No upcoming runs.'}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>Monthly cost cap:</Typography>
{liveFires > 0 && lastRun > 0 && (
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost }}>
{`$${lastRun.toFixed(4)} × ${liveFires} runs`}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>Monthly cap:</Typography>
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost }}>$</Typography>
<InputBase
type="number"
placeholder="none"
@@ -409,8 +483,10 @@ function CostRow({ workflow, draftSched, onCapChange }: { workflow: Workflow; dr
onChange={(e) => onCapChange(e.target.value === '' ? null : Math.max(0, Number(e.target.value)))}
sx={{ width: 72, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.3 }}
/>
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost }}>USD. Skips runs once exceeded; visible in History.</Typography>
</Box>
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost }}>
We&apos;ll skip runs once you hit this for the month. You&apos;ll see the skip in History.
</Typography>
</Box>
);
}
@@ -1,17 +1,19 @@
// Minimum-steps-to-value entry point: from any open chat, hit "Schedule"
// in the header, pick one of four presets, and we materialize a workflow
// seeded with source_session_id (so it inherits the chat's tool surface
// + steps via the existing /workflows/create path). "Custom..." opens
// the full editor for power users.
// + steps via the existing /workflows/create path). "Custom..." opens a
// LOCAL draft card instead of immediately POSTing /workflows/create, so
// users who change their mind don't leave behind an orphan workflow.
import React, { useCallback, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Popover from '@mui/material/Popover';
import InputBase from '@mui/material/InputBase';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch } from '@/shared/hooks';
import { createWorkflow, openWorkflowCard, type ScheduleConfig } from '@/shared/state/workflowsSlice';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { createWorkflow, openWorkflowCard, type ScheduleConfig, type Workflow } from '@/shared/state/workflowsSlice';
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
import { defaultSchedule } from './scheduleUtils';
type Preset = {
@@ -42,6 +44,18 @@ export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sess
const [title, setTitle] = useState<string>(sessionName || 'Untitled');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const workflows = useAppSelector((s) => s.workflows.items);
// Dup-detect: a chat session can only sanely have one schedule attached.
// If we find one already, offer "Open existing" instead of silently
// creating a duplicate that fires twice.
const existing = useMemo<Workflow | null>(() => {
if (!sessionId) return null;
for (const w of Object.values(workflows)) {
if (w.source_session_id === sessionId) return w;
}
return null;
}, [workflows, sessionId]);
const submit = useCallback(async (preset: Preset) => {
if (busy) return;
@@ -53,9 +67,10 @@ export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sess
title,
source_session_id: sessionId,
schedule,
} as any));
} as Partial<Workflow>));
if (createWorkflow.fulfilled.match(result)) {
const wf: any = result.payload;
const wf = result.payload as Workflow;
dispatch(addWorkflowCard({ workflowId: wf.id, sourceSessionId: sessionId }));
dispatch(openWorkflowCard({ workflowId: wf.id, view: 'saved' }));
onCreated?.(wf.id);
onClose();
@@ -69,31 +84,33 @@ export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sess
}
}, [busy, dispatch, sessionId, title, onClose, onCreated]);
const openCustom = useCallback(async () => {
// "Custom..." materializes a workflow with schedule.enabled=false
// and routes to the full editor. The editor's master toggle is the
// explicit gate — nothing fires until the user flips it on.
if (busy) return;
setBusy(true);
try {
const schedule: ScheduleConfig = { ...defaultSchedule() };
const result = await dispatch(createWorkflow({
const openCustom = useCallback(() => {
// Open a local draft. NO backend create yet — the workflow only
// exists on disk once the user clicks Save in the editor. Closing
// the draft card from here leaves nothing behind (the "orphan"
// bug from the previous create-then-edit flow).
const tempId = `draft-${sessionId}-${Date.now()}`;
dispatch(addWorkflowCard({ workflowId: tempId, sourceSessionId: sessionId }));
dispatch(openWorkflowCard({
workflowId: tempId,
sourceSessionId: sessionId,
view: 'preview',
draft: {
title,
source_session_id: sessionId,
schedule,
} as any));
if (createWorkflow.fulfilled.match(result)) {
const wf: any = result.payload;
dispatch(openWorkflowCard({ workflowId: wf.id, view: 'edit', editFacet: 'Schedule' }));
onCreated?.(wf.id);
onClose();
} else {
setError('Create failed. Try again.');
}
} finally {
setBusy(false);
}
}, [busy, dispatch, sessionId, title, onClose, onCreated]);
description: 'Scheduled from chat. Edit anytime.',
steps: [{ id: 'step-1', text: '' }],
schedule: { ...defaultSchedule() },
} as Partial<Workflow>,
}));
onClose();
}, [dispatch, sessionId, title, onClose]);
const openExisting = useCallback(() => {
if (!existing) return;
dispatch(addWorkflowCard({ workflowId: existing.id, sourceSessionId: sessionId }));
dispatch(openWorkflowCard({ workflowId: existing.id, view: 'saved' }));
onClose();
}, [dispatch, existing, sessionId, onClose]);
return (
<Popover
@@ -107,6 +124,30 @@ export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sess
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.75 }}>
SCHEDULE THIS CHAT
</Typography>
{existing && (
<Box sx={{
display: 'flex', flexDirection: 'column', gap: 0.4,
px: 1, py: 0.75, mb: 0.75,
borderRadius: `${c.radius.md}px`,
bgcolor: c.status.warningBg || c.bg.elevated,
border: `1px solid ${(c.status.warning || c.text.muted) + '60'}`,
}}>
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.text.primary }}>
This chat is already scheduled.
</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>
&quot;{existing.title}&quot; was made from this conversation. Adding another would fire twice.
</Typography>
<Box sx={{ display: 'flex', gap: 0.5, mt: 0.5 }}>
<Box onClick={openExisting} role="button" sx={{
fontSize: '0.74rem', fontWeight: 600, color: c.accent.primary,
cursor: 'pointer', px: 0.75, py: 0.3, borderRadius: `${c.radius.md}px`,
bgcolor: c.accent.primary + '14', border: `1px solid ${c.accent.primary}40`,
'&:hover': { bgcolor: c.accent.primary + '22' },
}}>Open existing </Box>
</Box>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary }}>Name:</Typography>
<InputBase
@@ -142,7 +183,7 @@ export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sess
'&:hover': { bgcolor: c.bg.elevated },
}}>
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.accent.primary }}>Custom</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>Open the full editor</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>Open the editor without saving yet</Typography>
</Box>
{error && (
<Typography sx={{ mt: 0.5, fontSize: '0.74rem', color: c.status.error }}>{error}</Typography>
@@ -0,0 +1,168 @@
// Vertical step list with connector + optional live-fill during a run +
// optional auto-icon per step + optional duration estimate per step.
// Used by both the Preview (draft) view and the Saved view so the two
// stay visually consistent.
import React from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { Workflow, WorkflowRun } from '@/shared/state/workflowsSlice';
import { stepIconFor, estimateStepDuration } from './workflowVisuals';
interface Props {
workflow?: Workflow | null;
steps: Workflow['steps'];
runs?: WorkflowRun[];
// Pass the active run id to fill the connector progressively as the
// workflow streams. Currently estimated by elapsed/expected; once
// per-step telemetry ships, swap to a real step-index signal.
activeRunId?: string | null;
// Subtle frame around each step (used by Preview's edit-mode look). The
// Saved view turns this off for a quieter read.
framed?: boolean;
// Callback when a step row is edited inline; only useful in Preview.
onChangeStep?: (idx: number, text: string) => void;
}
const CIRCLE_SIZE = 28;
// Vertical connector lives on the inner edge of the circle column; its
// x-offset matches CIRCLE_SIZE/2 so it bisects the numbered circles.
const CONNECTOR_X = CIRCLE_SIZE / 2;
export default function StepList({ workflow, steps, runs, activeRunId, framed, onChangeStep }: Props) {
const c = useClaudeTokens();
const hasSteps = steps && steps.length > 0;
if (!hasSteps) return null;
// Determine "current step" for live-fill. We don't have per-step
// telemetry yet, so estimate via elapsed/expected ratio if a run is
// active, otherwise leave it null (no fill).
const activeStepIdx = useActiveStepIdx(steps.length, runs, activeRunId);
return (
<Box sx={{ position: 'relative', pl: 0, mt: 0.25 }}>
{/* Connector spine. SVG so the live-fill segment can clip cleanly. */}
{steps.length > 1 && (
<Box
aria-hidden
sx={{
position: 'absolute',
left: CONNECTOR_X - 0.5,
top: CIRCLE_SIZE * 0.5,
bottom: CIRCLE_SIZE * 0.5,
width: 1,
bgcolor: c.border.medium,
opacity: 0.65,
}}
/>
)}
{steps.length > 1 && activeStepIdx !== null && (
<Box
aria-hidden
sx={{
position: 'absolute',
left: CONNECTOR_X - 1,
top: CIRCLE_SIZE * 0.5,
// Progress = (active+1)/total, capped at total-1 so the fill
// never overshoots the bottom circle.
height: `calc((100% - ${CIRCLE_SIZE}px) * ${Math.min(steps.length - 1, activeStepIdx) / (steps.length - 1)})`,
width: 2,
bgcolor: c.accent.primary,
transition: 'height 0.4s ease-out',
boxShadow: `0 0 6px ${c.accent.primary}`,
}}
/>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.85 }}>
{steps.map((s, idx) => {
const Icon = stepIconFor(s.text || '');
const duration = workflow ? estimateStepDuration(workflow, runs, idx) : null;
const isActive = activeStepIdx === idx;
const isPast = activeStepIdx !== null && idx < activeStepIdx;
return (
<Box key={s.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25, position: 'relative' }}>
<Box sx={{
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
border: `1px solid ${isActive || isPast ? c.accent.primary : c.border.medium}`,
bgcolor: isActive ? c.accent.primary : isPast ? c.accent.primary + '22' : c.bg.surface,
color: isActive ? '#fff' : isPast ? c.accent.primary : c.text.secondary,
fontSize: '0.78rem', fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
position: 'relative', zIndex: 1,
transition: 'background 0.25s ease, color 0.25s ease',
}}>
{Icon ? <Icon sx={{ fontSize: 14 }} /> : (idx + 1)}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
{onChangeStep ? (
<Box
component="textarea"
value={s.text}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChangeStep(idx, e.target.value)}
sx={{
width: '100%', resize: 'vertical',
fontFamily: 'inherit', fontSize: '0.92rem', color: c.text.primary,
border: framed ? `1px solid ${idx === 0 ? c.border.medium : c.border.subtle}` : `1px solid transparent`,
borderRadius: `${c.radius.md}px`,
bgcolor: framed ? c.bg.surface : 'transparent',
px: 1.25, py: 0.75, lineHeight: 1.4,
'&:focus': { outline: 'none', borderColor: c.accent.primary },
}}
/>
) : (
<Box sx={{
fontSize: '0.92rem', color: c.text.primary,
border: framed ? `1px solid ${idx === 0 ? c.border.medium : c.border.subtle}` : 'none',
borderRadius: framed ? `${c.radius.md}px` : 0,
bgcolor: framed ? c.bg.surface : 'transparent',
px: framed ? 1.25 : 0.5, py: framed ? 0.75 : 0.1,
lineHeight: 1.45,
}}>
{s.text}
</Box>
)}
{duration && (
<Tooltip title="Estimated from recent successful runs (whole-run duration divided by step count).">
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, mt: 0.25, ml: framed ? 1.25 : 0.5 }}>
~{duration}
</Typography>
</Tooltip>
)}
</Box>
</Box>
);
})}
</Box>
</Box>
);
}
// Synthesize an "active step" index from the active run's elapsed time
// vs the historical average run duration. Doesn't pretend to be exact;
// good enough for the user to see the progress bar advance during a
// long workflow. Returns null when no live run.
function useActiveStepIdx(stepCount: number, runs: WorkflowRun[] | undefined, activeRunId: string | null | undefined): number | null {
const [tick, setTick] = React.useState(0);
React.useEffect(() => {
if (!activeRunId) return;
const id = window.setInterval(() => setTick((t) => (t + 1) % 1000000), 1000);
return () => window.clearInterval(id);
}, [activeRunId]);
void tick;
if (!activeRunId || !runs) return null;
const active = runs.find((r) => r.id === activeRunId && r.status === 'running');
if (!active) return null;
const elapsed = Date.now() - new Date(active.started_at).getTime();
const completed = runs.filter((r) => (r.status === 'success' || r.status === 'ran_late') && r.finished_at);
if (completed.length === 0) {
// No history: jump to the middle step so the bar advances visibly.
return Math.min(stepCount - 1, Math.max(0, Math.floor(stepCount / 2)));
}
const durations = completed.slice(0, 10).map((r) => new Date(r.finished_at!).getTime() - new Date(r.started_at).getTime());
const avg = durations.reduce((a, b) => a + b, 0) / durations.length || 1;
const ratio = Math.min(0.99, Math.max(0, elapsed / avg));
return Math.min(stepCount - 1, Math.floor(ratio * stepCount));
}
+144 -14
View File
@@ -2,6 +2,13 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Snackbar from '@mui/material/Snackbar';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import CloseIcon from '@mui/icons-material/Close';
import EditIcon from '@mui/icons-material/EditOutlined';
import HistoryIcon from '@mui/icons-material/HistoryRounded';
@@ -12,6 +19,7 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
closeWorkflowCard,
deleteWorkflow,
fetchRuns,
openWorkflowCard as openWorkflowCardAction,
rekeyOpenCard,
@@ -27,7 +35,8 @@ import {
} from '@/shared/state/dashboardLayoutSlice';
import { AnimatePresence, motion } from 'framer-motion';
import WorkflowEditViews from './WorkflowEditViews';
import { HistoryDetail, HistoryList, PreviewView, SavedView, statusBg, statusColor } from './WorkflowCardSubviews';
import { HistoryDetail, HistoryList, PreviewView, SavedView } from './WorkflowCardSubviews';
import { StatusDot, RunSparkline, LastFiredHint, isStaleSinceLastRun } from './workflowVisuals';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
@@ -91,15 +100,45 @@ const WorkflowCard: React.FC<Props> = ({
// Transient "Starting…" label state on the Run button. See onClick handler
// for the full rationale (avoid no-feedback flicker on fast manual runs).
const [runStarting, setRunStarting] = useState(false);
const [runToast, setRunToast] = useState<string | null>(null);
const [editDirty, setEditDirty] = useState(false);
// ---- Lazy-load runs for the history view ----
// Lazy-load runs whenever a view that needs them is open. Saved view
// uses runs for the live-fill connector + step duration estimates;
// History views obviously need them too.
useEffect(() => {
if (!card) return;
if ((card.view === 'history' || card.view === 'history_detail') && workflow && !runs) {
const needsRuns = card.view === 'saved' || card.view === 'history' || card.view === 'history_detail';
if (needsRuns && workflow && !runs) {
dispatch(fetchRuns(workflow.id));
}
}, [card?.view, workflow?.id, runs, dispatch]);
// Keep wheel-scroll inside the card body instead of letting it bubble
// up to the dashboard pan/zoom listener. Without this, scrolling the
// schedule/history list shifts the canvas underneath the card. Mirrors
// the chat-panel wheel guard in AgentChat.tsx. Ctrl/meta + wheel is
// intentionally allowed through so canvas zoom still works when the
// cursor is over a workflow card.
const bodyScrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = bodyScrollRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) return;
const atTop = el.scrollTop <= 0;
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
const scrollingDown = e.deltaY > 0;
const scrollingUp = e.deltaY < 0;
if ((scrollingUp && atTop) || (scrollingDown && atBottom)) {
e.preventDefault();
}
e.stopPropagation();
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
const title = workflow?.title || card?.draft?.title || 'Workflow';
const isDraft = card?.view === 'preview' && !workflow;
const steps = (workflow?.steps || card?.draft?.steps || []) as Workflow['steps'];
@@ -256,10 +295,33 @@ const WorkflowCard: React.FC<Props> = ({
}, [computeResize, dispatch, workflowId]);
// ---- Close: drop transient view state AND remove from layout ----
const onClose = useCallback(() => {
// Two-step when the schedule is on: a quiet X would make the workflow
// a "ghost" (still firing on a hidden timer) which surprises users who
// mentally model X as "throw away." Confirm-then-act lets them choose
// between hiding the card and actually killing the schedule.
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
const hardClose = useCallback(() => {
dispatch(closeWorkflowCard(workflowId));
dispatch(removeWorkflowCard(workflowId));
}, [dispatch, workflowId]);
const onClose = useCallback(() => {
if (workflow?.schedule?.enabled) {
setCloseConfirmOpen(true);
return;
}
hardClose();
}, [workflow?.schedule?.enabled, hardClose]);
const onConfirmHide = useCallback(() => {
setCloseConfirmOpen(false);
hardClose();
}, [hardClose]);
const onConfirmStopAndDelete = useCallback(async () => {
setCloseConfirmOpen(false);
if (workflow?.id) {
await dispatch(deleteWorkflow(workflow.id));
}
hardClose();
}, [dispatch, workflow?.id, hardClose]);
// ---- Display calculations ----
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
@@ -336,14 +398,11 @@ const WorkflowCard: React.FC<Props> = ({
}}
>
<DragIndicatorIcon sx={{ fontSize: 16, color: c.text.ghost }} />
<StatusDot status={workflow?.last_run_status} />
<Typography sx={{ flex: 1, fontWeight: 700, fontSize: '0.95rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{title}
</Typography>
{workflow?.last_run_status && (
<Box sx={{ fontSize: '0.68rem', fontWeight: 700, color: statusColor(workflow.last_run_status, c), bgcolor: statusBg(workflow.last_run_status, c), px: 0.8, py: 0.3, borderRadius: 0.75 }}>
{workflow.last_run_status}
</Box>
)}
{runs && runs.length > 0 && <RunSparkline runs={runs} />}
<IconButton
size="small"
data-no-drag
@@ -363,13 +422,24 @@ const WorkflowCard: React.FC<Props> = ({
icon={<PlayArrowIcon sx={{ fontSize: 16 }} />}
active={card.view === 'saved'}
accent
breathe={!runStarting && isStaleSinceLastRun(workflow)}
breatheTooltip="Haven't run this in a few days. Click to run it now."
onClick={async () => {
if (runStarting) return;
setRunStarting(true);
dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }));
try {
await dispatch(runWorkflowNow(workflow.id));
const result = await dispatch(runWorkflowNow(workflow.id));
await dispatch(fetchRuns(workflow.id));
// Detect skipped manual runs so the user gets a real
// explanation instead of a silent button-flicker. The
// most common skip today is the monthly cost cap.
if (runWorkflowNow.fulfilled.match(result)) {
const payload = result.payload;
if (payload.status === 'skipped' && payload.error) {
setRunToast(`Run skipped: ${payload.error}`);
}
}
} finally {
// Hold the "Starting…" label briefly so the user sees the
// state change even on fast runs. Without this the button
@@ -382,6 +452,8 @@ const WorkflowCard: React.FC<Props> = ({
label="Edit"
icon={<EditIcon sx={{ fontSize: 16 }} />}
active={card.view === 'edit'}
dot={editDirty}
dotTooltip="You have unsaved changes in this tab."
onClick={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'edit', editFacet: card.editFacet || 'General' } }))}
/>
<TabBtn
@@ -407,7 +479,7 @@ const WorkflowCard: React.FC<Props> = ({
Crossfades between Run/Edit/History tabs so the swap doesn't
read as a "jump". Outer box is the scrollable viewport; the
animated child changes per `card.view`. */}
<Box sx={{ flex: 1, p: 2, overflowY: 'auto', minHeight: 0, position: 'relative' }}>
<Box ref={bodyScrollRef} data-no-drag sx={{ flex: 1, p: 2, overflowY: 'auto', minHeight: 0, position: 'relative', overscrollBehavior: 'contain' }}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={card.view}
@@ -435,12 +507,20 @@ const WorkflowCard: React.FC<Props> = ({
}}
/>
)}
{card.view === 'saved' && workflow && <SavedView workflow={workflow} steps={steps} />}
{card.view === 'saved' && workflow && (
<SavedView
workflow={workflow}
steps={steps}
runs={runs}
activeRunId={(runs || []).find((r) => r.status === 'running')?.id || null}
/>
)}
{card.view === 'edit' && workflow && (
<WorkflowEditViews
workflow={workflow}
facet={card.editFacet || 'General'}
onChangeFacet={(f) => dispatch(updateWorkflowCard({ workflowId, patch: { editFacet: f } }))}
onDirtyChange={setEditDirty}
/>
)}
{card.view === 'history' && workflow && (
@@ -476,13 +556,38 @@ const WorkflowCard: React.FC<Props> = ({
}}
/>
))}
{/* Toast for run outcomes that need explaining beyond the History
row (cost cap, "previous run still active," etc.). Auto-hides
after 6s; user can click anywhere to dismiss. */}
<Snackbar
open={Boolean(runToast)}
autoHideDuration={6000}
onClose={() => setRunToast(null)}
message={runToast || ''}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
/>
{/* Ghost-protection dialog: only opens when an enabled-schedule
card is X'd out. Cancel keeps the card; "Hide card" closes
but leaves the schedule alive; "Stop & delete" wipes the
workflow entirely. */}
<Dialog open={closeConfirmOpen} onClose={() => setCloseConfirmOpen(false)}>
<DialogTitle>Close this workflow card?</DialogTitle>
<DialogContent>
The schedule will keep firing in the background even after you close this card. Choose what you want to happen.
</DialogContent>
<DialogActions>
<Button onClick={() => setCloseConfirmOpen(false)}>Cancel</Button>
<Button onClick={onConfirmHide}>Hide card (schedule keeps running)</Button>
<Button color="error" onClick={onConfirmStopAndDelete}>Stop &amp; delete</Button>
</DialogActions>
</Dialog>
</Box>
);
};
function TabBtn({ label, icon, active, accent, onClick }: { label: string; icon: React.ReactNode; active: boolean; accent?: boolean; onClick: () => void }) {
function TabBtn({ label, icon, active, accent, breathe, breatheTooltip, dot, dotTooltip, onClick }: { label: string; icon: React.ReactNode; active: boolean; accent?: boolean; breathe?: boolean; breatheTooltip?: string; dot?: boolean; dotTooltip?: string; onClick: () => void }) {
const c = useClaudeTokens();
return (
const btn = (
<Box
onClick={onClick}
onPointerDown={(e) => e.stopPropagation()}
@@ -498,11 +603,36 @@ function TabBtn({ label, icon, active, accent, onClick }: { label: string; icon:
borderRadius: `${c.radius.md}px`,
cursor: 'pointer', userSelect: 'none',
'&:hover': { bgcolor: c.accent.primary + '10' },
// Subtle "ready" breath when a stale workflow's Run button hasn't
// been touched in over 24h. ~3% scale + glow swell, slow enough
// to read as ambient rather than urgent. Tooltip is on so users
// don't think the button is malfunctioning.
...(breathe && {
animation: 'workflow-run-breath 3.2s ease-in-out infinite',
'@keyframes workflow-run-breath': {
'0%, 100%': { boxShadow: `0 0 0 ${c.accent.primary}00`, transform: 'scale(1)' },
'50%': { boxShadow: `0 0 14px ${c.accent.primary}55`, transform: 'scale(1.03)' },
},
}),
}}>
{icon}
{label}
{dot && (
<Box sx={{
width: 7, height: 7, borderRadius: '50%',
bgcolor: c.accent.primary,
ml: 0.25,
}} />
)}
</Box>
);
if (dot && dotTooltip) {
return <Tooltip title={dotTooltip}>{btn}</Tooltip>;
}
if (breathe && breatheTooltip) {
return <Tooltip title={breatheTooltip}>{btn}</Tooltip>;
}
return btn;
}
export default React.memo(WorkflowCard);
@@ -1,8 +1,11 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Popover from '@mui/material/Popover';
import Tooltip from '@mui/material/Tooltip';
import HistoryIcon from '@mui/icons-material/HistoryToggleOffRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch } from '@/shared/hooks';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
closeWorkflowCard,
createWorkflow,
@@ -10,7 +13,8 @@ import {
type WorkflowRun,
} from '@/shared/state/workflowsSlice';
import { removeWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
import { describePermissions, describeSchedule } from './scheduleUtils';
import { ScheduleChip, PermissionChip, CostChip, humanDuration, routingFor } from './workflowVisuals';
import StepList from './StepList';
export function statusColor(s: string, c: ReturnType<typeof useClaudeTokens>): string {
if (s === 'success') return c.status.success;
@@ -30,7 +34,7 @@ export function statusBg(s: string, c: ReturnType<typeof useClaudeTokens>): stri
export function labelForStatus(s: string): string {
if (s === 'success') return 'Success';
if (s === 'failure') return 'Failure';
if (s === 'ran_late') return 'Ran Late';
if (s === 'ran_late') return 'Ran late';
if (s === 'running') return 'Running';
if (s === 'skipped') return 'Skipped';
return s;
@@ -104,58 +108,241 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
<Box sx={{ flex: 1, fontSize: '0.88rem', color: c.text.secondary, lineHeight: 1.5 }}>{description}</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 0.5 }}>
{steps.map((s, idx) => (
<Box key={s.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}>
<Box sx={{ width: 24, height: 24, borderRadius: '50%', border: `1px solid ${c.border.medium}`, fontSize: '0.78rem', fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', color: c.text.secondary, flexShrink: 0, mt: 0.25 }}>{idx + 1}</Box>
<Box sx={{ flex: 1, fontSize: '0.92rem', color: c.text.primary, border: `1px solid ${idx === 0 ? c.border.medium : c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1.25, py: 0.75, bgcolor: c.bg.surface, lineHeight: 1.4 }}>{s.text}</Box>
</Box>
))}
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 0.75, mt: 1 }}>
<ActionBtn label="Discard" tone="muted" onClick={onDiscard} />
<StepList steps={steps} framed />
{/* Save sits on the right; "Throw away" sits on the LEFT separated
by a flex spacer so a panicked user can't fat-finger the
destructive option while reaching for Save. */}
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
<ActionBtn label="Throw away" tone="muted" onClick={onDiscard} />
<Box sx={{ flex: 1 }} />
<ActionBtn label="Save" tone="success" onClick={onSave} disabled={busy} />
</Box>
</Box>
);
}
export function SavedView({ workflow, steps }: { workflow: Workflow; steps: Workflow['steps'] }) {
export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Workflow; steps: Workflow['steps']; runs?: WorkflowRun[]; activeRunId?: string | null }) {
const c = useClaudeTokens();
const connectionMode = useAppSelector((s) => (s as { settings?: { data?: { connection_mode?: string } } }).settings?.data?.connection_mode);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary }}><strong style={{ color: c.text.primary }}>Scheduled:</strong> {describeSchedule(workflow.schedule)}</Typography>
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary }}><strong style={{ color: c.text.primary }}>Permissions:</strong> {describePermissions(workflow)}</Typography>
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary, lineHeight: 1.5, mt: 0.5 }}>{workflow.description}</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 0.5 }}>
{steps.map((s, idx) => (
<Box key={s.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}>
<Box sx={{ width: 24, height: 24, borderRadius: '50%', border: `1px solid ${c.border.medium}`, fontSize: '0.78rem', fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', color: c.text.secondary, flexShrink: 0, mt: 0.25 }}>{idx + 1}</Box>
<Box sx={{ flex: 1, fontSize: '0.92rem', color: c.text.primary, px: 0.5, lineHeight: 1.45 }}>{s.text}</Box>
</Box>
))}
{/* Pill chips replace the two text rows. Same info, glanceable. */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 0.5 }}>
<ScheduleChip workflow={workflow} />
<PermissionChip workflow={workflow} />
<CostChip workflow={workflow} connectionMode={connectionMode} />
<Box sx={{ flex: 1 }} />
<AuditTraceLink workflowId={workflow.id} />
</Box>
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary, lineHeight: 1.5, mt: 0.5 }}>{workflow.description}</Typography>
<StepList workflow={workflow} steps={steps} runs={runs} activeRunId={activeRunId} />
</Box>
);
}
// Audit-trace popover. Lazy-fetches the last N edits from /workflows/{id}/audit
// on open, renders a compact list. The trigger sits inline with the chip
// row so power users can spot it without cluttering the title.
function AuditTraceLink({ workflowId }: { workflowId: string }) {
const c = useClaudeTokens();
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const [entries, setEntries] = useState<Array<{ ts: string; who: string; diff: Record<string, { before: unknown; after: unknown }> }> | null>(null);
const [loading, setLoading] = useState(false);
const open = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
setAnchor(e.currentTarget);
if (entries !== null) return;
setLoading(true);
try {
const { API_BASE, getAuthToken } = await import('@/shared/config');
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflowId)}/audit?limit=5`, {
headers: tok ? { Authorization: `Bearer ${tok}` } : {},
});
const data = await res.json();
setEntries(Array.isArray(data?.entries) ? data.entries : []);
} catch {
setEntries([]);
} finally {
setLoading(false);
}
}, [entries, workflowId]);
const close = () => setAnchor(null);
const count = entries?.length ?? 0;
return (
<>
<Tooltip title="Recent edits to this workflow">
<Box onClick={open} role="button" sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.3,
fontSize: '0.7rem', color: c.text.muted, cursor: 'pointer',
px: 0.5, py: 0.25, borderRadius: 0.75,
'&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated },
}}>
<HistoryIcon sx={{ fontSize: 12 }} />
{entries === null ? 'edits' : `${count} edit${count === 1 ? '' : 's'}`}
</Box>
</Tooltip>
<Popover
open={Boolean(anchor)}
anchorEl={anchor}
onClose={close}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}>
<Box sx={{ minWidth: 280, maxWidth: 360, p: 1 }}>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.5 }}>
RECENT EDITS
</Typography>
{loading && <Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>Loading</Typography>}
{!loading && (entries === null || entries.length === 0) && (
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>No edits yet.</Typography>
)}
{!loading && entries && entries.map((e, idx) => {
const fields = Object.keys(e.diff || {}).filter((k) => k !== 'updated_at');
const summary = fields.length === 0 ? 'no field changes' : fields.slice(0, 3).join(', ') + (fields.length > 3 ? `, +${fields.length - 3} more` : '');
return (
<Box key={idx} sx={{ display: 'flex', flexDirection: 'column', py: 0.5, borderTop: idx === 0 ? 'none' : `1px solid ${c.border.subtle}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary, fontWeight: 600 }}>{e.who || 'user'}</Typography>
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost }}>{relTimeShort(e.ts)}</Typography>
</Box>
<Typography sx={{ fontSize: '0.74rem', color: c.text.secondary }}>{summary}</Typography>
</Box>
);
})}
</Box>
</Popover>
</>
);
}
function relTimeShort(iso: string): string {
try {
const ms = Date.now() - new Date(iso).getTime();
if (ms < 60000) return 'just now';
const m = Math.floor(ms / 60000);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
return `${d}d ago`;
} catch { return ''; }
}
function runDuration(r: WorkflowRun): string | null {
if (!r.finished_at) return null;
try {
const ms = new Date(r.finished_at).getTime() - new Date(r.started_at).getTime();
if (ms <= 0) return null;
return humanDuration(ms);
} catch { return null; }
}
// Groups runs into "This week / Last week / Month YYYY" buckets so a
// long history list reads as eras rather than 50 same-looking dates.
function groupKey(iso: string): string {
try {
const d = new Date(iso);
const now = new Date();
const day = 24 * 3600 * 1000;
const startOfWeek = (x: Date) => { const y = new Date(x); y.setHours(0, 0, 0, 0); y.setDate(y.getDate() - y.getDay()); return y; };
const thisWeekStart = startOfWeek(now).getTime();
const lastWeekStart = thisWeekStart - 7 * day;
if (d.getTime() >= thisWeekStart) return 'This week';
if (d.getTime() >= lastWeekStart) return 'Last week';
return d.toLocaleString('en', { month: 'long', year: 'numeric' });
} catch { return 'Earlier'; }
}
export function HistoryList({ runs, onOpen }: { runs: WorkflowRun[]; onOpen: (r: WorkflowRun) => void }) {
const c = useClaudeTokens();
const [expandedId, setExpandedId] = useState<string | null>(null);
// Filter chips: all / failures / late. Power-users debugging a flaky
// workflow shouldn't have to scroll past successes.
const [filter, setFilter] = useState<'all' | 'failure' | 'ran_late'>('all');
const filtered = useMemo(() => {
if (filter === 'all') return runs;
return (runs || []).filter((r) => r.status === filter);
}, [runs, filter]);
const groups = useMemo(() => {
const out: Array<{ key: string; runs: WorkflowRun[] }> = [];
for (const r of filtered || []) {
const k = groupKey(r.started_at);
const last = out[out.length - 1];
if (last && last.key === k) last.runs.push(r);
else out.push({ key: k, runs: [r] });
}
return out;
}, [filtered]);
// Header sparkline summarising recent successes/failures so users can
// see "lately broken" before scrolling.
const recent = (runs || []).slice(0, 30);
if (!runs || runs.length === 0) {
return <Typography sx={{ fontSize: '0.88rem', color: c.text.muted, py: 1.5, textAlign: 'center' }}>No runs yet</Typography>;
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
{runs.map((r) => (
<Box
key={r.id}
onClick={() => onOpen(r)}
sx={{ display: 'flex', alignItems: 'center', gap: 1.25, py: 0.75, px: 0.5, cursor: 'pointer', borderRadius: 0.75, '&:hover': { bgcolor: c.bg.elevated } }}>
<Box sx={{ fontSize: '0.72rem', fontWeight: 700, color: statusColor(r.status, c), bgcolor: statusBg(r.status, c), px: 0.8, py: 0.3, borderRadius: 0.75, minWidth: 64, textAlign: 'center' }}>
{labelForStatus(r.status)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.75 }}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25 }}>
{recent.map((r) => (
<Box key={r.id} sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: statusColor(r.status, c) }} />
))}
</Box>
<Box sx={{ flex: 1 }} />
{(['all', 'failure', 'ran_late'] as const).map((k) => (
<Box key={k} onClick={() => setFilter(k)} role="button" sx={{
fontSize: '0.72rem', fontWeight: 600,
color: filter === k ? c.accent.primary : c.text.muted,
bgcolor: filter === k ? c.accent.primary + '14' : 'transparent',
border: `1px solid ${filter === k ? c.accent.primary + '40' : c.border.subtle}`,
px: 0.7, py: 0.2, borderRadius: 999, cursor: 'pointer',
'&:hover': { color: c.accent.primary },
}}>
{k === 'all' ? 'All' : k === 'failure' ? 'Failures only' : 'Ran late only'}
</Box>
<Typography sx={{ fontSize: '0.88rem', color: c.text.primary }}>{formatRunDate(r.started_at)}</Typography>
<Box sx={{ ml: 'auto', fontSize: '0.78rem', color: c.text.muted }}>Open </Box>
))}
</Box>
{groups.map(({ key, runs: gRuns }) => (
<Box key={key} sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mt: 0.5, mb: 0.25 }}>
{key.toUpperCase()}
</Typography>
{gRuns.map((r) => {
const expanded = expandedId === r.id;
const dur = runDuration(r);
return (
<Box key={r.id}>
<Box
onClick={() => setExpandedId(expanded ? null : r.id)}
sx={{ display: 'flex', alignItems: 'center', gap: 1.25, py: 0.6, px: 0.5, cursor: 'pointer', borderRadius: 0.75, '&:hover': { bgcolor: c.bg.elevated } }}>
<Box sx={{ fontSize: '0.72rem', fontWeight: 700, color: statusColor(r.status, c), bgcolor: statusBg(r.status, c), px: 0.8, py: 0.3, borderRadius: 0.75, minWidth: 64, textAlign: 'center' }}>
{labelForStatus(r.status)}
</Box>
<Typography sx={{ fontSize: '0.88rem', color: c.text.primary, flex: 1 }}>{formatRunDate(r.started_at)}</Typography>
{dur && <Typography sx={{ fontSize: '0.74rem', color: c.text.ghost }}>{dur}</Typography>}
{r.cost_usd > 0 && <Typography sx={{ fontSize: '0.74rem', color: c.text.ghost }}>${r.cost_usd.toFixed(4)}</Typography>}
{/* Chevron makes the row read as expandable instead of
static text. Rotates 180° while open so the affordance
stays visible after click. */}
<Box sx={{ fontSize: '0.7rem', color: c.text.ghost, transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s ease' }}></Box>
</Box>
{expanded && (
<Box sx={{ ml: 8, mt: 0.25, mb: 0.75, p: 1, bgcolor: c.bg.elevated, borderRadius: 0.75, border: `1px solid ${c.border.subtle}` }}>
{r.error ? (
<Typography sx={{ fontSize: '0.78rem', color: c.status.error, lineHeight: 1.4 }}>{r.error}</Typography>
) : (
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, lineHeight: 1.4 }}>
{r.session_id ? `Saved as session ${r.session_id.slice(0, 8)}.` : 'No session was recorded for this run.'} Click below to see the full conversation.
</Typography>
)}
<Box sx={{ mt: 0.5, display: 'flex', justifyContent: 'flex-end' }}>
<Box onClick={(e) => { e.stopPropagation(); onOpen(r); }} role="button" sx={{ fontSize: '0.74rem', fontWeight: 600, color: c.accent.primary, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}>
See full conversation
</Box>
</Box>
</Box>
)}
</Box>
);
})}
</Box>
))}
</Box>
@@ -1,4 +1,4 @@
import React, { useCallback, 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 Select from '@mui/material/Select';
@@ -16,9 +16,12 @@ interface Props {
workflow: Workflow;
facet: 'General' | 'Actions' | 'Schedule';
onChangeFacet: (facet: 'General' | 'Actions' | 'Schedule') => void;
// Lifted dirty state so the parent card can decorate the Edit tab with
// an unsaved-changes dot. Optional; older callers don't need to wire it.
onDirtyChange?: (dirty: boolean) => void;
}
export default function WorkflowEditViews({ workflow, facet, onChangeFacet }: Props) {
export default function WorkflowEditViews({ workflow, facet, onChangeFacet, onDirtyChange }: Props) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const [draft, setDraft] = useState<Workflow>(workflow);
@@ -30,6 +33,12 @@ export default function WorkflowEditViews({ workflow, facet, onChangeFacet }: Pr
const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(workflow), [draft, workflow]);
// Push the dirty flag up so the parent card can decorate the Edit tab.
useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]);
// Clear the parent's flag on unmount so a closed editor doesn't leave
// a stale "you have unsaved changes" dot on the tab.
useEffect(() => () => { onDirtyChange?.(false); }, [onDirtyChange]);
const onSave = useCallback(async () => {
if (busy || !dirty) return;
const reason = validateDraft(draft);
@@ -40,19 +49,28 @@ export default function WorkflowEditViews({ workflow, facet, onChangeFacet }: Pr
setSaveError(null);
setBusy(true);
try {
const result = await dispatch(updateWorkflow({ id: workflow.id, patch: draft }));
// If-Match: pass the workflow's current updated_at so the backend
// can reject a stale write. Without this, two open windows or a
// mid-edit background fire silently clobber each other.
const result = await dispatch(updateWorkflow({
id: workflow.id,
patch: draft,
ifMatch: workflow.updated_at || null,
}));
if (updateWorkflow.fulfilled.match(result)) {
setSavedFlash(true);
setTimeout(() => setSavedFlash(false), 1400);
} else if (result.payload?.kind === 'stale') {
setSaveError('This workflow was changed in another window or by a recent run. Discard to reload the latest, then re-apply your edits.');
} else {
setSaveError('Save failed. Please try again.');
setSaveError(result.payload?.message || 'Save failed. Please try again.');
}
} catch (e) {
setSaveError((e as Error)?.message || 'Save failed.');
} finally {
setBusy(false);
}
}, [busy, dirty, dispatch, workflow.id, draft]);
}, [busy, dirty, dispatch, workflow.id, workflow.updated_at, draft]);
const onDiscard = useCallback(() => {
setDraft(workflow);
@@ -288,7 +288,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
<AddIcon sx={{ fontSize: 14 }} />
New
</Box>
<Tooltip title={paused ? 'All scheduled workflows are paused. Toggle to resume.' : 'Pause every scheduled workflow without disabling them individually.'}>
<Tooltip title={paused ? 'Scheduled runs are paused. In-flight runs will finish; new fires are blocked until you resume.' : 'Stop all future scheduled runs without disabling them one-by-one. Any run already in flight will finish.'}>
<Box
onClick={togglePaused}
role="button"
@@ -0,0 +1,390 @@
// Shared visual helpers for the workflow card UI tier: schedule/permission
// pill chips, status dot, run-status sparkline, step connector, step icon
// auto-classifier. Kept as plain functions/components so individual views
// can compose without owning the styling.
import React from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import ScheduleIcon from '@mui/icons-material/ScheduleRounded';
import NotificationsIcon from '@mui/icons-material/NotificationsRounded';
import SmsIcon from '@mui/icons-material/SmsRounded';
import PhoneInTalkIcon from '@mui/icons-material/PhoneInTalkRounded';
import EmailIcon from '@mui/icons-material/MailOutlineRounded';
import EventNoteIcon from '@mui/icons-material/EventNoteRounded';
import ChromeReaderModeIcon from '@mui/icons-material/ChromeReaderModeRounded';
import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
import CalendarTodayIcon from '@mui/icons-material/CalendarTodayRounded';
import ArticleIcon from '@mui/icons-material/ArticleRounded';
import LanguageIcon from '@mui/icons-material/LanguageRounded';
import AttachMoneyIcon from '@mui/icons-material/AttachMoneyRounded';
import AllInclusiveIcon from '@mui/icons-material/AllInclusiveRounded';
import CodeIcon from '@mui/icons-material/CodeRounded';
import SearchIcon from '@mui/icons-material/SearchRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { Workflow, WorkflowRun, ScheduleConfig, PermissionTier } from '@/shared/state/workflowsSlice';
import { formatTime, WEEKDAY_LABEL } from './scheduleUtils';
// ---------- Status colors ----------
export type LastRunStatus = NonNullable<Workflow['last_run_status']>;
export function statusDotColor(status: LastRunStatus | null | undefined, c: ReturnType<typeof useClaudeTokens>) {
switch (status) {
case 'success': return c.status.success;
case 'ran_late': return c.status.warning || '#f59e0b';
case 'failure': return c.status.error;
case 'running': return c.accent.primary;
case 'skipped': return c.text.muted;
default: return c.text.ghost;
}
}
// Human-readable status word. We surface "ran late" instead of the
// underscore-y "ran_late" everywhere it'd be visible to a user.
export function statusWord(status: LastRunStatus | null | undefined): string {
if (!status) return 'Never run';
if (status === 'ran_late') return 'Ran late';
return status.charAt(0).toUpperCase() + status.slice(1);
}
// Status pill rendered next to the title. Bigger than the previous 9px
// dot and pairs the color with a short word so a non-dev knows what
// they're looking at instead of squinting at a single grey pixel.
export function StatusDot({ status }: { status: LastRunStatus | null | undefined }) {
const c = useClaudeTokens();
const word = statusWord(status);
const dotColor = statusDotColor(status, c);
return (
<Tooltip title={status ? `Last run: ${word.toLowerCase()}` : 'This workflow has never run.'}>
<Box sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
height: 18, px: 0.6, borderRadius: 999,
bgcolor: status === 'failure' ? c.status.errorBg : status === 'ran_late' ? c.status.warningBg : status === 'success' ? c.status.successBg : c.bg.elevated,
border: `1px solid ${dotColor}55`,
flexShrink: 0,
}}>
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: dotColor, boxShadow: status === 'failure' ? `0 0 4px ${c.status.error}` : 'none' }} />
<Typography sx={{ fontSize: '0.66rem', fontWeight: 700, color: dotColor, letterSpacing: '0.02em' }}>
{word}
</Typography>
</Box>
</Tooltip>
);
}
// ---------- Pill chips ----------
function scheduleShort(sched: ScheduleConfig): string {
if (!sched.enabled) return 'Not scheduled';
const time = formatTime(sched.hour, sched.minute);
if (sched.repeat_unit === 'day') {
return sched.repeat_every === 1 ? `Daily ${time}` : `Every ${sched.repeat_every}d ${time}`;
}
if (sched.repeat_unit === 'month') {
return sched.repeat_every === 1 ? `Monthly ${time}` : `Every ${sched.repeat_every}mo ${time}`;
}
if (sched.on_days.length === 5 && [1, 2, 3, 4, 5].every((d) => sched.on_days.includes(d))) return `Weekdays ${time}`;
if (sched.on_days.length === 2 && [0, 6].every((d) => sched.on_days.includes(d))) return `Weekends ${time}`;
if (sched.on_days.length === 1) {
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return `${labels[sched.on_days[0]]} ${time}`;
}
if (sched.on_days.length === 0) return `Weekly ${time}`;
return `${sched.on_days.length}×/wk ${time}`;
}
// Weekday-dot strip "S M T W T F S" with active days filled. Rendered
// inline next to the chip when the schedule is weekly so users can
// pattern-match days without parsing prose. Active = filled accent dot.
export function WeekdayDots({ on_days }: { on_days: number[] }) {
const c = useClaudeTokens();
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35, ml: 0.5 }}>
{WEEKDAY_LABEL.map((lbl, idx) => {
const active = on_days.includes(idx);
return (
<Box key={`${lbl}-${idx}`} sx={{
width: 12, height: 12, borderRadius: '50%',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.6rem', fontWeight: 700,
color: active ? '#fff' : c.text.ghost,
bgcolor: active ? c.accent.primary : 'transparent',
border: `1px solid ${active ? c.accent.primary : c.border.subtle}`,
lineHeight: 1,
}}>
{lbl}
</Box>
);
})}
</Box>
);
}
function permIcon(kind: PermissionTier['kind'], size = 13) {
if (kind === 'text') return <SmsIcon sx={{ fontSize: size }} />;
if (kind === 'call') return <PhoneInTalkIcon sx={{ fontSize: size }} />;
return <NotificationsIcon sx={{ fontSize: size }} />;
}
// Compact "🔔 → 💬 → 📞" representation of the escalation chain. Hover
// shows the literal prose (notify, text, call, with delays).
export function PermissionChip({ workflow }: { workflow: Workflow }) {
const c = useClaudeTokens();
const tiers = workflow.permissions || [];
if (tiers.length === 0) return null;
const label = tiers.map((t) => {
if (t.kind === 'notify') return 'notify in app';
const unit = t.kind === 'call' ? 'h' : 'm';
return `${t.kind} after ${t.after_minutes}${unit}`;
}).join(' → ');
return (
<Tooltip title={label}>
<Box sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.35,
fontSize: '0.74rem', fontWeight: 500,
color: c.text.secondary,
bgcolor: c.bg.elevated,
border: `1px solid ${c.border.subtle}`,
px: 0.85, py: 0.3, borderRadius: 999,
}}>
{tiers.map((t, i) => (
<React.Fragment key={i}>
{permIcon(t.kind)}
{i < tiers.length - 1 && <Box sx={{ fontSize: '0.7rem', color: c.text.ghost, mx: 0.1 }}></Box>}
</React.Fragment>
))}
</Box>
</Tooltip>
);
}
export function ScheduleChip({ workflow }: { workflow: Workflow }) {
const c = useClaudeTokens();
const enabled = workflow.schedule.enabled;
return (
<Tooltip title={enabled ? `Schedule: ${scheduleShort(workflow.schedule)} (${workflow.schedule.timezone === 'local' ? 'system tz' : workflow.schedule.timezone})` : 'Not scheduled'}>
<Box sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
fontSize: '0.74rem', fontWeight: 600,
color: enabled ? c.accent.primary : c.text.muted,
bgcolor: enabled ? c.accent.primary + '14' : c.bg.elevated,
border: `1px solid ${enabled ? c.accent.primary + '40' : c.border.subtle}`,
px: 0.85, py: 0.3, borderRadius: 999,
}}>
<ScheduleIcon sx={{ fontSize: 13 }} />
{scheduleShort(workflow.schedule)}
{enabled && workflow.schedule.repeat_unit === 'week' && (
<WeekdayDots on_days={workflow.schedule.on_days} />
)}
</Box>
</Tooltip>
);
}
// Classify a workflow's billing route based on its model id + the user's
// global connection mode. Mirrors the per-session logic in AgentChat so
// the workflow card tells the same story the chat header does. Returns
// 'metered' when the user pays per call (Anthropic/OpenAI/Gemini API
// keys, custom OpenAI-compatible) or 'subscription' when a flat-rate
// account is doing the work (Claude Pro/Max, ChatGPT Plus/Pro, Gemini
// Advanced, OpenSwarm Pro proxy). `subLabel` names the plan for tooltips.
export type RoutingKind = 'metered' | 'subscription';
export interface Routing {
kind: RoutingKind;
subLabel?: string;
}
export function routingFor(model: string, connectionMode: string | undefined): Routing {
const m = (model || '').toLowerCase();
if (m.endsWith('-api')) return { kind: 'metered' };
if (m.endsWith('-cc')) return { kind: 'subscription', subLabel: 'Claude Pro/Max' };
const isPlainAnthropic = m === 'sonnet' || m === 'opus' || m === 'haiku';
if (isPlainAnthropic && connectionMode === 'openswarm-pro') {
return { kind: 'subscription', subLabel: 'OpenSwarm Pro' };
}
if (isPlainAnthropic) return { kind: 'metered' };
if (m.startsWith('gpt-5') || m.startsWith('gpt-4') || m.startsWith('o1') || m.startsWith('o3') || m.startsWith('o4')) {
return { kind: 'subscription', subLabel: 'ChatGPT Plus/Pro' };
}
if (m.startsWith('gemini-')) {
return { kind: 'subscription', subLabel: 'Gemini Advanced' };
}
// Unknown model id, default to metered so we don't oversell "free."
return { kind: 'metered' };
}
export function CostChip({ workflow, connectionMode }: { workflow: Workflow; connectionMode?: string }) {
const c = useClaudeTokens();
const est = workflow.cost_estimate;
const route = routingFor(workflow.model, connectionMode);
// Subscription-routed workflows have no metered per-call cost. Surface
// a usage chip instead so the user knows runs are "free" under their
// existing plan but still sees the projected fire frequency.
if (route.kind === 'subscription') {
if (!est || est.fires_per_month === 0) {
return (
<Tooltip title={`Runs are covered by your ${route.subLabel} plan. No upcoming runs scheduled.`}>
<Box sx={chipSx(c)}>
<AllInclusiveIcon sx={{ fontSize: 12 }} />
{route.subLabel || 'Subscription'}
</Box>
</Tooltip>
);
}
return (
<Tooltip title={`Routed through your ${route.subLabel} plan; no per-run cost. About ${est.fires_per_month} runs per month at the current schedule.`}>
<Box sx={chipSx(c)}>
<AllInclusiveIcon sx={{ fontSize: 12 }} />
~{est.fires_per_month} runs/mo
</Box>
</Tooltip>
);
}
// Metered route: only render the cost chip once we actually have a
// last-run figure to project from. Avoids "$0.00/mo" gaslighting.
if (!est || est.fires_per_month === 0 || est.last_run_usd <= 0) return null;
const monthly = est.monthly_usd || 0;
return (
<Tooltip title={`About $${est.last_run_usd.toFixed(4)} per run, times ${est.fires_per_month} runs per month.`}>
<Box sx={chipSx(c)}>
<AttachMoneyIcon sx={{ fontSize: 12, ml: -0.25 }} />
{monthly < 0.01 ? '<0.01' : monthly.toFixed(2)}/mo
</Box>
</Tooltip>
);
}
function chipSx(c: ReturnType<typeof useClaudeTokens>) {
return {
display: 'inline-flex', alignItems: 'center', gap: 0.3,
fontSize: '0.74rem', fontWeight: 600,
color: c.text.secondary,
bgcolor: c.bg.elevated,
border: `1px solid ${c.border.subtle}`,
px: 0.75, py: 0.3, borderRadius: 999,
} as const;
}
// Compact "last fired" mini-label, used inside the Run-tab summary.
export function LastFiredHint({ workflow }: { workflow: Workflow }) {
const c = useClaudeTokens();
if (!workflow.last_run_at) return null;
const ms = Date.now() - new Date(workflow.last_run_at).getTime();
const ago = relTime(ms);
return (
<Typography sx={{ fontSize: '0.72rem', color: c.text.ghost }}>Last ran {ago}</Typography>
);
}
function relTime(ms: number): string {
if (ms < 0) return 'just now';
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 30) return `${d}d ago`;
const mo = Math.floor(d / 30);
return `${mo}mo ago`;
}
// ---------- Run history sparkline ----------
// 10-dot horizontal strip of last N runs colored by status. Easy "lately
// healthy?" check without opening the History tab. Tooltip names the
// pattern out loud so a non-dev knows the dots aren't decorative.
export function RunSparkline({ runs, max = 10 }: { runs: WorkflowRun[]; max?: number }) {
const c = useClaudeTokens();
if (!runs || runs.length === 0) return null;
const slice = runs.slice(0, max).reverse();
const successes = slice.filter((r) => r.status === 'success').length;
const failures = slice.filter((r) => r.status === 'failure').length;
const tooltip = `Last ${slice.length} run${slice.length === 1 ? '' : 's'}: ${successes} ok, ${failures} failed (oldest left → newest right). Green = success, red = failure, amber = ran late.`;
return (
<Tooltip title={tooltip}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.3, ml: 0.5 }}>
{slice.map((r) => (
<Box key={r.id} sx={{
width: 6, height: 6, borderRadius: '50%',
bgcolor: statusDotColor(r.status as LastRunStatus, c),
}} />
))}
</Box>
</Tooltip>
);
}
// ---------- Step icon auto-classifier ----------
// Pick a glyph by keyword scan of the step text. Falls back to the
// step number when nothing matches. Same Roman-numeral simple heuristic
// the user sees: "summarize email" -> mail icon, "make notion page" ->
// article icon, etc.
const ICON_RULES: Array<{ pattern: RegExp; Icon: React.ElementType }> = [
{ pattern: /\b(email|inbox|gmail|outlook|mail)\b/i, Icon: EmailIcon },
{ pattern: /\b(calendar|schedule|event|meeting)\b/i, Icon: CalendarTodayIcon },
{ pattern: /\b(notion|doc|page|page template|document|article)\b/i, Icon: ArticleIcon },
{ pattern: /\b(text|sms|message|whatsapp|imessage)\b/i, Icon: SmsIcon },
{ pattern: /\b(call|phone|dial|ring)\b/i, Icon: PhoneInTalkIcon },
{ pattern: /\b(browser|web|website|url|fetch|visit|navigate)\b/i, Icon: LanguageIcon },
{ pattern: /\b(search|find|look up|google)\b/i, Icon: SearchIcon },
{ pattern: /\b(code|github|repo|script|bash|run)\b/i, Icon: CodeIcon },
{ pattern: /\b(read|review|summarize|summary)\b/i, Icon: ChromeReaderModeIcon },
{ pattern: /\b(chat|reply|respond|dm)\b/i, Icon: ChatBubbleOutlineIcon },
{ pattern: /\b(note|memo|journal|log)\b/i, Icon: EventNoteIcon },
];
export function stepIconFor(text: string): React.ElementType | null {
for (const rule of ICON_RULES) {
if (rule.pattern.test(text)) return rule.Icon;
}
return null;
}
// ---------- Step duration learner ----------
// Estimates per-step duration by averaging recent runs. Today we only
// have whole-run duration on each WorkflowRun (started_at -> finished_at),
// so the heuristic spreads it evenly across the step count. When per-step
// telemetry lands later, swap this for a per-step lookup.
export function estimateStepDuration(workflow: Workflow, runs: WorkflowRun[] | undefined, stepIdx: number): string | null {
if (!runs || runs.length === 0) return null;
const steps = workflow.steps?.length || 1;
const successful = runs.filter((r) => (r.status === 'success' || r.status === 'ran_late') && r.finished_at);
if (successful.length === 0) return null;
const durations = successful.slice(0, 10).map((r) => {
const start = new Date(r.started_at).getTime();
const end = new Date(r.finished_at!).getTime();
return Math.max(0, end - start);
});
const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
const perStepMs = avg / steps;
void stepIdx;
return humanDuration(perStepMs);
}
export function humanDuration(ms: number): string {
if (ms < 1000) return '<1s';
const s = Math.round(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
return rem > 0 && m < 5 ? `${m}m ${rem}s` : `${m}m`;
}
// ---------- Run-button breath logic ----------
// Returns true when the workflow hasn't been run in over 24h. Used by
// the Run tab to add a subtle CSS breathing animation so the button
// invites use without yelling.
export function isStaleSinceLastRun(workflow: Workflow): boolean {
if (!workflow.last_run_at) return false;
const age = Date.now() - new Date(workflow.last_run_at).getTime();
return age > 24 * 3600 * 1000;
}
@@ -1,28 +0,0 @@
.under_construction_overlay {
width: 100%;
height: 100%;
background: transparent;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
// z-index: 1000;
text-align: center;
font-family: Arial, sans-serif;
color: white;
// box-sizing: border-box;
}
.under_construction_overlay img {
width: 100px;
height: 100px;
}
.under_construction_overlay h2 {
font-size: 24px;
margin-top: 20px;
}
.under_construction_overlay p {
font-size: 18px;
}
@@ -1,15 +0,0 @@
import React from 'react';
import styles from './UnderConstruction.module.scss'; // CSS for styling the overlay
const UnderConstruction = () => {
return (
<div className={styles.under_construction_overlay}>
<img src="/hammer-icon.png" alt="Console Icon" />
<h2>Under Construction</h2>
<p>This feature is coming soon!</p>
</div>
);
};
export { UnderConstruction };
+41 -10
View File
@@ -139,16 +139,42 @@ export const createWorkflow = createAsyncThunk(
},
);
export const updateWorkflow = createAsyncThunk(
// Optimistic concurrency: PATCH sends If-Match with the workflow's
// updated_at. If the backend's record changed since we read it (another
// window, a mid-edit background fire), the server returns 409 and the
// caller can prompt to reload. Thunk uses rejectWithValue so the FE can
// distinguish stale-write from network errors.
export const updateWorkflow = createAsyncThunk<
Workflow,
{ id: string; patch: Partial<Workflow>; ifMatch?: string | null },
{ rejectValue: { kind: 'stale' | 'network' | 'server'; message: string; current_updated_at?: string } }
>(
'workflows/update',
async ({ id, patch }: { id: string; patch: Partial<Workflow> }) => {
const res = await fetch(`${API}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error(`update failed ${res.status}`);
return (await res.json()) as Workflow;
async ({ id, patch, ifMatch }, { rejectWithValue }) => {
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (ifMatch) headers['If-Match'] = ifMatch;
const res = await fetch(`${API}/${id}`, {
method: 'PATCH',
headers,
body: JSON.stringify(patch),
});
if (res.status === 409) {
const data = await res.json().catch(() => ({}));
const detail = (data && (data.detail || data)) || {};
return rejectWithValue({
kind: 'stale',
message: detail.message || 'This workflow changed elsewhere. Reload and try again.',
current_updated_at: detail.current_updated_at,
});
}
if (!res.ok) {
return rejectWithValue({ kind: 'server', message: `Update failed (${res.status}).` });
}
return (await res.json()) as Workflow;
} catch (e) {
return rejectWithValue({ kind: 'network', message: (e as Error)?.message || 'Network error.' });
}
},
);
@@ -161,7 +187,12 @@ export const runWorkflowNow = createAsyncThunk('workflows/run', async (id: strin
const res = await fetch(`${API}/${id}/run`, { method: 'POST' });
if (!res.ok) throw new Error(`run failed ${res.status}`);
const data = await res.json();
return { id, run_id: data.run_id as string };
return {
id,
run_id: (data.run_id || '') as string,
status: (data.status || null) as string | null,
error: (data.error || null) as string | null,
};
});
export const fetchRuns = createAsyncThunk(
@@ -1,128 +0,0 @@
@use '@/shared/styles/utils.module.scss' as utils;
$text-map: (
'light-1': #F0F0F0,
'light-2': #D9D9D9,
'light-3': #BDBDBD,
'light-4': #999999,
);
@function text($mode: 'light-1') {
@return utils.get-style(
$function-map: $text-map,
$mode: $mode
);
}
$background-color-map: (
'dark-1': #232323,
'dark-2': #151515,
'dark-3': #0A0A0A,
);
@function background-color($mode: 'dark-1') {
@return utils.get-style(
$function-map: $background-color-map,
$mode: $mode
);
}
@mixin gradient-1() {
$mask-color: rgba(0, 0, 0, 0.623);
$gradient-color: rgba(62, 139, 241, 0.20);
$background-color: #151515;
background:
linear-gradient(0deg, $mask-color 0%, $mask-color 100%),
radial-gradient(102.16% 48.94% at 49.58% 47.09%, rgba(6, 14, 25, 0.00) 0%, rgba(62, 139, 241, 0.20) 100%),
#151515;
}
$glass-map: (
'default': (
border-radius: 10px,
border: 1px solid rgba(255, 255, 255, 0.075),
background: rgba(38, 38, 38, 0.184),
background-blend-mode: luminosity,
backdrop-filter: blur(50px),
),
'light-05': (
border-radius: 10px,
border: 1px solid rgba(255, 255, 255, 0.095),
background: rgba(160, 160, 160, 0.048),
background-blend-mode: luminosity,
backdrop-filter: blur(50px),
),
'light-075': (
border-radius: 10px,
border: 1px solid rgba(255, 255, 255, 0.095),
background: rgba(160, 160, 160, 0.075),
background-blend-mode: luminosity,
backdrop-filter: blur(50px),
),
'light-1': (
border-radius: 10px,
border: 1px solid rgba(255, 255, 255, 0.095),
background: rgba(160, 160, 160, 0.154),
background-blend-mode: luminosity,
backdrop-filter: blur(50px),
),
'light-2': (
border-radius: 10px,
border: 1px solid rgba(255, 255, 255, 0.178),
background: rgba(160, 160, 160, 0.46),
background-blend-mode: luminosity,
backdrop-filter: blur(50px),
),
'light-3': (
border-radius: 10px,
border: 1px solid rgba(255, 255, 255, 0.178),
background: rgba(0, 0, 0, 0.247),
background-blend-mode: luminosity,
backdrop-filter: blur(50px),
),
);
@mixin glass($mode: 'default') {
@include utils.apply-style-map(
$mixin-map: $glass-map,
$mode: $mode
);
}
$glow-map: (
'default': (
border: 1px solid #0099ff71,
box-shadow: 0 0 24px #0099ff71,
),
'dark-1': (
border: 1px solid #3e8cf13e,
box-shadow: 0 0 24px #3e8cf12b,
),
'light-1': (
border: 1px solid #ab19ff47,
box-shadow: 0 0 44px #c259ff8a,
),
'source-1': (
border: 1px solid #ff000071,
box-shadow: 0 0 44px #ff000071,
),
);
@mixin glow($mode: 'default') {
@include utils.apply-style-map(
$mixin-map: $glow-map,
$mode: $mode
);
}
$accent-map: (
'blue-1': #3E8BF1,
'blue-2': #3e8cf1c7,
'blue-3': #3e8cf15b,
'blue-grey-1': #77a7e5c7,
'red-1': #F56868,
'red-2': #f568681a,
);
@function accent($mode: 'blue-1') {
@return utils.get-style(
$function-map: $accent-map,
$mode: $mode
);
}
@@ -1,12 +0,0 @@
export const getStyleValue = (className: string, property: string, defaultValue: string = "none"): string => {
if (typeof document !== 'undefined') {
const element = document.createElement("div");
element.setAttribute("class", className);
document.body.appendChild(element);
const style = window.getComputedStyle(element);
const value = style.getPropertyValue(property);
document.body.removeChild(element);
return value || defaultValue;
}
return defaultValue; // Return default value if not in a browser environment
};
@@ -1,62 +0,0 @@
@use '@/shared/styles/utils.module.scss' as utils;
$flex-map: (
'vert': (
flex-direction: column,
),
'horz': (
flex-direction: row,
),
);
@mixin flex($direction: 'vert') {
display: flex;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
gap: 0;
padding: 0;
margin: 0;
box-sizing: border-box;
@include utils.apply-style-map(
$mixin-map: $flex-map,
$mode: $direction
);
}
$flex-hug-map: (
'default': (
width: fit-content,
height: fit-content,
),
'full-width': (
width: 100%,
),
'full-height': (
height: 100%,
),
);
@mixin flex-hug($mode: 'default') {
@include flex('horz');
@include utils.apply-style-map(
$mixin-map: $flex-hug-map,
$mode: $mode
);
}
$scroll-map: (
'hidden': (
"&::-webkit-scrollbar": (
display: none
),
-ms-overflow-style: none, /* IE and Edge */
scrollbar-width: none, /* Firefox */
),
);
@mixin scroll-bar($mode: 'hidden') {
@include utils.apply-style-map(
$mixin-map: $scroll-map,
$mode: $mode
);
}
@@ -1,63 +0,0 @@
@use '@/shared/styles/color.module.scss' as g-color;
@use '@/shared/styles/utils.module.scss' as utils;
$font-map: (
'default': 'Inter',
'secondary': 'Times New Roman'
);
@function font($mode: 'default') {
@return utils.get-style(
$function-map: $font-map,
$mode: $mode
);
}
$size-map: (
'small': 12px,
'small-medium': 14px,
'medium': 16px,
'large': 20px,
'title': 30px,
);
@function size($mode: 'default') {
@return utils.get-style(
$function-map: $size-map,
$mode: $mode
);
}
$weight-map: (
'small': 400,
'medium': 500,
'large': 600,
'title': 700,
);
@function weight($mode: 'default') {
@return utils.get-style(
$function-map: $weight-map,
$mode: $mode
);
}
$text-map: (
'default': (
font-family: 'Inter',
font-size: 16px,
font-weight: 400,
color: g-color.text('light-1'),
),
'title': (
font-family: 'Inter',
font-size: 40px,
font-weight: 700,
color: g-color.accent('blue-1'),
line-height: 100%,
)
);
@mixin text($mode: 'default') {
@include utils.apply-style-map(
$mixin-map: $text-map,
$mode: $mode
);
}
@@ -1,63 +0,0 @@
@use "sass:map";
@use "sass:meta";
// NOTE: Example map input:
// $text-map: (
// 'default': (
// font-family: 'Inter',
// font-size: 16px,
// font-weight: 400,
// ),
// 'secondary': (∂
// font-family: 'Times New Roman',
// font-size: 16px,
// font-weight: 400,
// )
// );
@function construct-styles($mixin-map, $mode) {
$styles: map.get($mixin-map, $mode);
@if $styles == null {
$available-modes: map.keys($styles);
@error "Invalid style mode: '#{$mode}' -> Available modes are: #{$available-modes}.";
}
@return $styles;
}
@mixin apply-style-map($mixin-map, $mode) {
$styles: construct-styles($mixin-map, $mode);
@each $property, $value in $styles {
@if meta.type-of($value) == map {
// This is a nested selector
#{$property} {
@each $nested-property, $nested-value in $value {
#{$nested-property}: $nested-value;
}
}
} @else {
// This is a normal property-value pair
#{$property}: $value;
}
}
}
// NOTE: Example map input:
// $font-map: (
// 'default': 'Inter',
// 'secondary': 'Times New Roman',
// )
// );
@function construct-style($function-map, $mode) {
// Check if the mode exists in the map
$style-value: map.get($function-map, $mode);
@if $style-value == null {
$available-modes: map.keys($function-map);
@error "Invalid style mode: '#{$mode}' -> Available modes are: #{$available-modes}.";
}
// Return the style value
@return $style-value;
}
@function get-style($function-map, $mode) {
$style: construct-style($function-map, $mode);
@return $style;
}
+42 -2
View File
@@ -29,7 +29,7 @@ fail_count = 0
created_ids: list[str] = []
def http(method: str, path: str, body=None, raw: bool = False):
def http(method: str, path: str, body=None, raw: bool = False, extra_headers=None):
url = f"{BASE}{path}"
if body is None:
data = None
@@ -37,7 +37,10 @@ def http(method: str, path: str, body=None, raw: bool = False):
data = body if isinstance(body, (bytes, bytearray)) else body.encode()
else:
data = json.dumps(body).encode()
req = urllib.request.Request(url, data=data, method=method, headers=HEADERS)
headers = dict(HEADERS)
if extra_headers:
headers.update(extra_headers)
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.status, json.loads(resp.read() or b"null")
@@ -420,6 +423,43 @@ print(f" {DIM}(info) ends_at=3 days from now produced fires_per_month={fires}{R
ok("fires_per_month honors ends_at (~3 fires not ~30)", fires <= 5,
info=f"got {fires}, want <= 5; if this fails it's a known gap in scheduler.fires_in_window")
# ============ 20. If-Match optimistic concurrency ============
section("20. PATCH with If-Match (optimistic concurrency)")
code, r = http("POST", "/create", fresh_wf(title="if-match-test"))
oc_id = r["id"]; created_ids.append(oc_id)
stamp = r["updated_at"]
# Stale If-Match -> 409
code, _ = http("PATCH", f"/{oc_id}", {"title": "v2"}, extra_headers={"If-Match": "1999-01-01T00:00:00"})
ok("stale If-Match returns 409", code == 409)
# Fresh If-Match -> 200
code, r = http("PATCH", f"/{oc_id}", {"title": "v2"}, extra_headers={"If-Match": stamp})
ok("fresh If-Match returns 200", code == 200)
# Missing If-Match -> still works (legacy back-compat)
code, _ = http("PATCH", f"/{oc_id}", {"title": "v3"})
ok("missing If-Match still accepted (legacy clients)", code == 200)
# ============ 21. /run returns skipped status on cost cap ============
section("21. /run surfaces cost-cap skipped status")
code, r = http("POST", "/create", fresh_wf(title="cap-immediate", cost_cap_usd_monthly=0.01))
cap_id = r["id"]; created_ids.append(cap_id)
# Run once and produce a real-looking run via the legacy 0-cost path —
# the cap is checked against actual recorded cost_usd. Without a way to
# inject a $5 run here we just verify the field surfaces correctly when
# the cap is 0 (which should always exceed). With 0 the executor's >=
# check skips immediately because spent (0.0) >= 0.0.
# (Using cap=0 forces the skip path on first run.)
http("PATCH", f"/{cap_id}", {"cost_cap_usd_monthly": 0.0})
code, r = http("POST", f"/{cap_id}/run")
# It may take a tick for the executor to land the skipped row; the
# endpoint already polls up to 250ms internally.
ok("run response includes status field", "status" in r)
ok("run response includes error field", "error" in r)
if r.get("status") == "skipped":
ok("/run surfaces skipped status", True, info=r.get("error", ""))
else:
ok("/run surfaces skipped status", False,
info=f"status was {r.get('status')!r} not skipped; may have raced")
# ============ Done ============
print()
if fail_count == 0: