[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
-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;
}