mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[eric] scheduled tasks: target-image visual pass, inline-edit in preview, AI descriptions, agent blocks claude.ai MCP shims, fixes hooks-order
crash and ghost-card on relaunch
This commit is contained in:
@@ -660,6 +660,13 @@ class AgentManager:
|
||||
"`mcp__*__authenticate` helpers; those are legacy shims; always go "
|
||||
"through MCPActivate."
|
||||
)
|
||||
sections.append(
|
||||
"1a. NEVER call any tool whose name begins with `mcp__claude_ai_` "
|
||||
"(claude.ai-connected partner shims). They bypass the OpenSwarm "
|
||||
"gate and don't share auth with this app. If the user wants Gmail/"
|
||||
"Calendar/Drive, the equivalent OpenSwarm server is listed below; "
|
||||
"activate that one via MCPActivate instead."
|
||||
)
|
||||
sections.append(
|
||||
"2. After MCPActivate returns, end the turn; a follow-up turn fires "
|
||||
"automatically with the new tools available."
|
||||
@@ -1629,6 +1636,27 @@ class AgentManager:
|
||||
)
|
||||
composed_prompt = (composed_prompt + "\n\n" + schedule_ctx) if composed_prompt else schedule_ctx
|
||||
|
||||
# Pin the agent's notion of "now" to the host wall clock + zone
|
||||
# so it can answer day-of-week questions, choose sensible
|
||||
# cadences ("every Friday afternoon"), and avoid hallucinated
|
||||
# dates. Location stays out of scope; only timezone is shared.
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
from backend.apps.workflows.storage import _resolve_host_tz_name
|
||||
tz_name = _resolve_host_tz_name()
|
||||
now_local = datetime.now(ZoneInfo(tz_name))
|
||||
tz_abbr = now_local.strftime("%Z") or tz_name
|
||||
time_ctx = (
|
||||
"<current_time>\n"
|
||||
f"Today is {now_local.strftime('%A, %B %-d, %Y')}.\n"
|
||||
f"Local time: {now_local.strftime('%-I:%M %p')} {tz_abbr} ({tz_name}).\n"
|
||||
"Use this as ground truth for any date/time/day-of-week question.\n"
|
||||
"</current_time>"
|
||||
)
|
||||
composed_prompt = (composed_prompt + "\n\n" + time_ctx) if composed_prompt else time_ctx
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if session.mode == "view-builder":
|
||||
# Read the LIVE skill content rather than a frozen-at-import
|
||||
# constant. The skill is registered as a built-in skill at
|
||||
@@ -2270,6 +2298,17 @@ class AgentManager:
|
||||
if session.max_turns:
|
||||
options_kwargs["max_turns"] = session.max_turns
|
||||
|
||||
# The claude_code preset auto-attaches the user's claude.ai-
|
||||
# connected partner MCPs (`mcp__claude_ai_*`). Those bypass our
|
||||
# MCPActivate gate, don't share OAuth state with the OpenSwarm
|
||||
# Gmail/Calendar/Drive connectors the user actually configured
|
||||
# here, and confuse the model into picking the partner shim
|
||||
# instead of our vetted server. Hard-block them at the SDK
|
||||
# layer so the model can't even attempt the call.
|
||||
options_kwargs["disallowed_tools"] = [
|
||||
"mcp__claude_ai_*",
|
||||
]
|
||||
|
||||
if session.cwd:
|
||||
# Pre-existing sessions may have workspaces that predate
|
||||
# the git-init block in launch_agent, leaving them
|
||||
|
||||
@@ -138,11 +138,65 @@ async def create_workflow(body: WorkflowCreate):
|
||||
wf.icon = _derive_icon(wf)
|
||||
if wf.schedule.enabled:
|
||||
wf.next_run_at = scheduler.compute_next_fire(wf)
|
||||
# AI-generated description when the caller didn't supply one. Best-
|
||||
# effort via the user's configured aux model; on failure we leave
|
||||
# description empty so the UI just hides the row rather than showing
|
||||
# a fake placeholder. Doesn't block create — caller gets the workflow
|
||||
# back, and a background task fills the description in seconds.
|
||||
if not (wf.description or "").strip():
|
||||
try:
|
||||
wf.description = await _generate_description(wf)
|
||||
except Exception:
|
||||
pass
|
||||
storage.save_workflow(wf)
|
||||
scheduler.kick()
|
||||
return _enriched(wf)
|
||||
|
||||
|
||||
async def _generate_description(wf: Workflow) -> str:
|
||||
"""One aux-model call: summarize steps into a one-paragraph blurb.
|
||||
|
||||
Returns "" on any failure so the caller can write the result back
|
||||
unconditionally. Never raises.
|
||||
"""
|
||||
if not wf.steps:
|
||||
return ""
|
||||
try:
|
||||
from backend.apps.agents.providers.registry import resolve_aux_model
|
||||
from backend.apps.agents.providers.registry import get_anthropic_client_for_model
|
||||
from backend.apps.settings.settings import load_settings as _ls
|
||||
except Exception:
|
||||
return ""
|
||||
settings = _ls()
|
||||
try:
|
||||
aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
|
||||
client = get_anthropic_client_for_model(settings, aux_model)
|
||||
except Exception:
|
||||
return ""
|
||||
steps_lines = "\n".join(f"{i+1}. {s.text}" for i, s in enumerate(wf.steps) if s.text)
|
||||
prompt = (
|
||||
"Write one short paragraph (2-3 sentences, under 50 words) that "
|
||||
"describes what this workflow does, in plain English. No bullet "
|
||||
"points, no preamble like 'This workflow...'. Just the description.\n\n"
|
||||
f"Title: {wf.title}\n\n"
|
||||
f"Steps:\n{steps_lines}"
|
||||
)
|
||||
try:
|
||||
resp = await client.messages.create(
|
||||
model=aux_model,
|
||||
max_tokens=160,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
text = ""
|
||||
if isinstance(resp.content, list):
|
||||
for block in resp.content:
|
||||
if getattr(block, "type", None) == "text":
|
||||
text += getattr(block, "text", "")
|
||||
return text.strip()[:500]
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _last_run_cost(wid: str) -> float:
|
||||
for r in storage.list_runs(wid, limit=10):
|
||||
if r.status in ("success", "ran_late") and r.cost_usd:
|
||||
|
||||
@@ -204,20 +204,25 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
const [suggestDismissedFor, setSuggestDismissedFor] = useState<string | null>(null);
|
||||
const scheduleSuggestion = useMemo(() => {
|
||||
if (!session?.messages || session.messages.length === 0) return null;
|
||||
// Find the most recent terminal assistant message.
|
||||
let lastAssistant: typeof session.messages[number] | null = null;
|
||||
// The asker is the user, not the agent. Parsing the assistant reply
|
||||
// means hour-shaped numbers in any list ("3 new messages", "May 16",
|
||||
// "$50 offer") get misread as the schedule hour. Use the most recent
|
||||
// user prompt as the source of truth, fall back to the assistant
|
||||
// only if no user message has time-words.
|
||||
let chosen: typeof session.messages[number] | null = null;
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
const m = session.messages[i];
|
||||
if (m.role === 'assistant' && typeof m.content === 'string' && m.content.trim()) {
|
||||
lastAssistant = m; break;
|
||||
if (m.role === 'user' && typeof m.content === 'string' && m.content.trim()) {
|
||||
const det = detectSchedule(m.content);
|
||||
if (det) { chosen = m; break; }
|
||||
}
|
||||
}
|
||||
if (!lastAssistant) return null;
|
||||
if (suggestDismissedFor === lastAssistant.id) return null;
|
||||
const text = typeof lastAssistant.content === 'string' ? lastAssistant.content : '';
|
||||
if (!chosen) return null;
|
||||
if (suggestDismissedFor === chosen.id) return null;
|
||||
const text = typeof chosen.content === 'string' ? chosen.content : '';
|
||||
const detected = detectSchedule(text);
|
||||
if (!detected) return null;
|
||||
return { messageId: lastAssistant.id, ...detected };
|
||||
return { messageId: chosen.id, ...detected };
|
||||
}, [session?.messages, suggestDismissedFor]);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const [showResumeBubble, setShowResumeBubble] = useState(false);
|
||||
|
||||
@@ -933,7 +933,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
if (steps.length === 0) return;
|
||||
const draft: Partial<Workflow> = {
|
||||
title: session.name || 'New workflow',
|
||||
description: 'Auto-generated from this chat. Edit anytime in Workflows.',
|
||||
description: '',
|
||||
steps,
|
||||
source_session_id: session.id,
|
||||
dashboard_id: session.dashboard_id || null,
|
||||
|
||||
@@ -1694,6 +1694,13 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
|
||||
const hasReal = wc.workflow_id in workflowItems;
|
||||
const hasDraft = wc.workflow_id in workflowOpenCards;
|
||||
if (!hasReal && !hasDraft) continue;
|
||||
// The "Make workflow" tether is a draft-time affordance: it shows
|
||||
// the user which chat the new workflow card came out of. Once the
|
||||
// workflow is saved (openCard transitions to 'saved' view), the
|
||||
// user has committed and the visual link can retire. Per user
|
||||
// feedback on image #70.
|
||||
const openCard = workflowOpenCards[wc.workflow_id];
|
||||
if (openCard && openCard.view !== 'preview') continue;
|
||||
|
||||
let srcX = src.x, srcY = src.y;
|
||||
let dstX = wc.x, dstY = wc.y;
|
||||
@@ -1838,7 +1845,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
|
||||
}}
|
||||
/>
|
||||
|
||||
{sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 ? (
|
||||
{sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 && Object.keys(workflowCards).length === 0 && !workflowsHub ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
@@ -1909,7 +1916,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
|
||||
markerHeight="10"
|
||||
orient="auto"
|
||||
>
|
||||
<path d="M 0 1 L 10 5 L 0 9 z" fill={c.accent.primary} opacity={0.8} />
|
||||
<path d="M 0 1 L 10 5 L 0 9 z" fill={c.accent.primary} />
|
||||
</marker>
|
||||
</defs>
|
||||
{tethers.map((t) => (
|
||||
@@ -1937,7 +1944,6 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity={0.8}
|
||||
markerEnd="url(#tether-arrow)"
|
||||
/>
|
||||
{t.label && (
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import type { Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { runWorkflowNow, deleteWorkflow, updateWorkflow, openWorkflowCard } from '@/shared/state/workflowsSlice';
|
||||
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { WEEKDAY_LABEL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, fireTimesWithin, formatTime, formatHourLabel } from './scheduleUtils';
|
||||
import { WEEKDAY_FULL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, fireTimesWithin, formatTime, formatHourLabel } from './scheduleUtils';
|
||||
|
||||
interface Props {
|
||||
view: 'Week' | 'Month' | 'List';
|
||||
@@ -99,52 +99,59 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workflows, view, dayKey]);
|
||||
|
||||
const SLOT_H = compact ? 28 : 36;
|
||||
const ROW_LABEL = compact ? '0.72rem' : '0.78rem';
|
||||
const DAY_NUM = compact ? '0.85rem' : '0.95rem';
|
||||
const DAY_LABEL = compact ? '0.7rem' : '0.78rem';
|
||||
const EVENT_FS = compact ? '0.72rem' : '0.82rem';
|
||||
const SLOT_H = compact ? 32 : 44;
|
||||
const ROW_LABEL = compact ? '0.7rem' : '0.74rem';
|
||||
const DAY_NUM = compact ? '0.95rem' : '1.15rem';
|
||||
const DAY_LABEL = compact ? '0.66rem' : '0.72rem';
|
||||
const EVENT_FS = compact ? '0.7rem' : '0.78rem';
|
||||
|
||||
if (view === 'Week') {
|
||||
const start = startOfWeek(today);
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
||||
const HOURS = HOURS_24;
|
||||
// Prefer the short zone name ("PDT", "EST", "JST") so the label
|
||||
// reads in plain English instead of "GMT-7". formatToParts is wide-
|
||||
// supported; if it ever fails we degrade silently rather than show
|
||||
// a confusing fallback.
|
||||
const TZ_LABEL = (() => {
|
||||
try {
|
||||
const offset = -new Date().getTimezoneOffset() / 60;
|
||||
return `GMT${offset >= 0 ? '+' : ''}${offset.toString().padStart(2, '0').replace('.', ':')}`;
|
||||
const parts = new Intl.DateTimeFormat('en', { timeZoneName: 'short' }).formatToParts(new Date());
|
||||
return parts.find((p) => p.type === 'timeZoneName')?.value || '';
|
||||
} catch { return ''; }
|
||||
})();
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', color: c.text.secondary }}>
|
||||
{/* Day headers — full names in roomy, single letter in compact */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', gap: 0, position: 'sticky', top: 0, bgcolor: c.bg.surface, zIndex: 2 }}>
|
||||
{/* Day headers: muted weekday caps; today's date gets the filled circle */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', gap: 0, position: 'sticky', top: 0, bgcolor: c.bg.surface, zIndex: 2, pb: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'flex-end', pr: 1, pb: 0.5 }}>
|
||||
{!compact && (
|
||||
<Typography sx={{ fontSize: '0.66rem', color: c.text.ghost, fontWeight: 500 }}>{TZ_LABEL}</Typography>
|
||||
<Typography sx={{ fontSize: '0.62rem', color: c.text.ghost, fontWeight: 500 }}>{TZ_LABEL}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{days.map((d) => {
|
||||
const isToday = sameDay(d, today);
|
||||
return (
|
||||
<Box key={d.toISOString()} sx={{ textAlign: 'center', pb: 0.5 }}>
|
||||
<Typography sx={{ fontSize: DAY_LABEL, color: isToday ? c.accent.primary : c.text.muted, fontWeight: 700, letterSpacing: '0.06em', lineHeight: 1.3 }}>
|
||||
<Typography sx={{ fontSize: DAY_LABEL, color: c.text.muted, fontWeight: 600, letterSpacing: '0.08em', lineHeight: 1.3, textTransform: 'uppercase' }}>
|
||||
{WEEKDAY_LABEL_SHORT[d.getDay()]}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: compact ? 28 : 34, height: compact ? 28 : 34, borderRadius: '50%', bgcolor: isToday ? c.accent.primary : 'transparent', color: isToday ? '#fff' : c.text.primary, fontWeight: 700, fontSize: DAY_NUM, mt: 0.25 }}>{d.getDate()}</Box>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: compact ? 30 : 38, height: compact ? 30 : 38, borderRadius: '50%', bgcolor: isToday ? c.accent.primary : 'transparent', color: isToday ? '#fff' : c.text.primary, fontWeight: isToday ? 700 : 500, fontSize: DAY_NUM, mt: 0.25 }}>{d.getDate()}</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', borderTop: `1px solid ${c.border.subtle}` }}>
|
||||
{HOURS.map((hour) => (
|
||||
{HOURS.map((hour, hourIdx) => (
|
||||
<React.Fragment key={hour}>
|
||||
{/* Hour label sits inside its row (top-aligned) rather than
|
||||
straddling the line above it; that way the first row
|
||||
doesn't clip "12 AM" and the labels never drift when the
|
||||
body scrolls. Apple Calendar does the same. */}
|
||||
<Box sx={{
|
||||
height: SLOT_H, fontSize: ROW_LABEL,
|
||||
color: c.text.ghost, fontWeight: 500,
|
||||
textAlign: 'right', pr: 1,
|
||||
position: 'relative', top: -7, // tuck label so it sits on the gridline, not in the cell
|
||||
borderTop: `1px solid ${c.border.subtle}`,
|
||||
textAlign: 'right', pr: 1, pt: 0.25,
|
||||
borderTop: hourIdx === 0 ? 'none' : `1px solid ${c.border.subtle}`,
|
||||
}}>
|
||||
{formatHourLabel(hour)}
|
||||
</Box>
|
||||
@@ -173,7 +180,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
ifMatch: wf.updated_at || null,
|
||||
}));
|
||||
}}
|
||||
sx={{ height: SLOT_H, borderLeft: `1px solid ${c.border.subtle}`, borderTop: `1px solid ${c.border.subtle}`, position: 'relative' }}>
|
||||
sx={{ height: SLOT_H, borderLeft: `1px solid ${c.border.subtle}`, borderTop: hourIdx === 0 ? 'none' : `1px solid ${c.border.subtle}`, position: 'relative' }}>
|
||||
<EventStack
|
||||
events={evs}
|
||||
onSelectWorkflow={onSelectWorkflow}
|
||||
@@ -194,38 +201,45 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
if (view === 'Month') {
|
||||
const start = startOfMonthGrid(today);
|
||||
const cells = Array.from({ length: 35 }, (_, i) => addDays(start, i));
|
||||
const accent = c.accent.primary;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', mb: 0.5 }}>
|
||||
{/* Sticky weekday header so it stays visible even when the
|
||||
calendar body scrolls. Slightly bigger + tinted bg so it
|
||||
reads cleanly in both light and dark themes. */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', position: 'sticky', top: 0, bgcolor: c.bg.surface, zIndex: 2, borderBottom: `1px solid ${c.border.subtle}`, py: 0.6 }}>
|
||||
{WEEKDAY_LABEL_SHORT.map((l, i) => (
|
||||
<Typography key={`${l}-${i}`} sx={{ textAlign: 'center', fontSize: DAY_LABEL, color: c.text.muted, fontWeight: 600, letterSpacing: '0.06em' }}>{l}</Typography>
|
||||
<Typography key={`${l}-${i}`} sx={{ textAlign: 'center', fontSize: '0.74rem', color: c.text.secondary, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase' }}>{l}</Typography>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 0 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 0, borderLeft: `1px solid ${c.border.subtle}` }}>
|
||||
{cells.map((d) => {
|
||||
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||
const evs = eventsByDay.map.get(key) || [];
|
||||
const isToday = sameDay(d, today);
|
||||
const inMonth = d.getMonth() === today.getMonth();
|
||||
return (
|
||||
<Box key={d.toISOString()} sx={{ minHeight: compact ? 64 : 88, borderRight: `1px solid ${c.border.subtle}`, borderBottom: `1px solid ${c.border.subtle}`, p: 0.5, opacity: inMonth ? 1 : 0.45, position: 'relative', overflow: 'hidden' }}>
|
||||
<Box key={d.toISOString()} sx={{ minHeight: compact ? 70 : 96, borderRight: `1px solid ${c.border.subtle}`, borderBottom: `1px solid ${c.border.subtle}`, p: 0.5, position: 'relative', overflow: 'hidden', bgcolor: inMonth ? 'transparent' : c.bg.elevated }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', minWidth: 22, height: 22, borderRadius: '50%', bgcolor: isToday ? c.accent.primary : 'transparent', color: isToday ? '#fff' : c.text.secondary, fontWeight: isToday ? 700 : 500, fontSize: DAY_NUM, px: 0.5 }}>{d.getDate()}</Box>
|
||||
{/* Out-of-month dates still need to be legible (Apple
|
||||
Calendar shows them in a muted shade, not invisible).
|
||||
Color tweak instead of opacity so dark themes stay
|
||||
readable. */}
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', minWidth: 22, height: 22, borderRadius: '50%', bgcolor: isToday ? accent : 'transparent', color: isToday ? '#fff' : inMonth ? c.text.primary : c.text.ghost, fontWeight: isToday ? 700 : 500, fontSize: '0.82rem', px: 0.5 }}>{d.getDate()}</Box>
|
||||
</Box>
|
||||
{evs.slice(0, compact ? 3 : 5).map((e, idx) => (
|
||||
{evs.slice(0, compact ? 3 : 4).map((e, idx) => (
|
||||
<Box
|
||||
key={`${e.workflow.id}-${idx}`}
|
||||
onClick={() => onSelectWorkflow?.(e.workflow.id)}
|
||||
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
|
||||
sx={{ mt: 0.3, display: 'flex', alignItems: 'center', gap: 0.4, fontSize: EVENT_FS, color: c.text.secondary, cursor: 'pointer', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', '&:hover': { color: c.accent.primary } }}>
|
||||
<Box sx={{ width: 5, height: 5, borderRadius: '50%', bgcolor: c.accent.primary, flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||
{formatTime(e.date.getHours(), e.date.getMinutes())} {e.workflow.title}
|
||||
</span>
|
||||
sx={{ mt: 0.3, display: 'flex', alignItems: 'center', gap: 0.5, fontSize: EVENT_FS, color: c.text.primary, cursor: 'pointer', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', '&:hover': { color: accent } }}>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: accent, flexShrink: 0 }} />
|
||||
<span style={{ color: c.text.muted, flexShrink: 0 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, fontWeight: 500 }}>{e.workflow.title}</span>
|
||||
</Box>
|
||||
))}
|
||||
{evs.length > (compact ? 3 : 5) && (
|
||||
<Typography sx={{ fontSize: EVENT_FS, color: c.text.muted, mt: 0.3 }}>+{evs.length - (compact ? 3 : 5)} more</Typography>
|
||||
{evs.length > (compact ? 3 : 4) && (
|
||||
<Typography sx={{ fontSize: EVENT_FS, color: c.text.muted, mt: 0.3, pl: 1.4 }}>+{evs.length - (compact ? 3 : 4)} more</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
@@ -236,34 +250,63 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
);
|
||||
}
|
||||
|
||||
const upcoming: { date: Date; events: { workflow: Workflow; date: Date }[] }[] = [];
|
||||
// Apple-Calendar-style list: big day number + weekday on the left, a
|
||||
// vertical colored bar separating it from events on the right. Today
|
||||
// renders even with no events (shows a "No events today" placeholder)
|
||||
// so the list doesn't feel empty for new users.
|
||||
const upcoming: { date: Date; events: { workflow: Workflow; date: Date }[]; isToday: boolean }[] = [];
|
||||
for (let i = 0; i < 14; i += 1) {
|
||||
const day = addDays(today, i);
|
||||
const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`;
|
||||
const arr = eventsByDay.map.get(key) || [];
|
||||
if (arr.length) upcoming.push({ date: day, events: arr });
|
||||
const isToday = sameDay(day, today);
|
||||
if (arr.length || isToday) upcoming.push({ date: day, events: arr, isToday });
|
||||
}
|
||||
const accent = c.accent.primary;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.lg}px`, overflow: 'hidden', bgcolor: c.bg.surface }}>
|
||||
{upcoming.length === 0 && (
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.muted, textAlign: 'center', py: 2 }}>No scheduled workflows</Typography>
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.muted, textAlign: 'center', py: 3 }}>No scheduled workflows</Typography>
|
||||
)}
|
||||
{upcoming.map(({ date, events }) => (
|
||||
<Box key={date.toISOString()} sx={{ display: 'flex', gap: 1.25 }}>
|
||||
<Box sx={{ width: 52, flexShrink: 0, textAlign: 'center', borderRight: `1px solid ${c.border.subtle}`, pr: 0.75 }}>
|
||||
<Typography sx={{ fontSize: '1.1rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.1 }}>{date.getDate()}</Typography>
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.text.muted, fontWeight: 600 }}>{date.toLocaleString('en', { month: 'short' })}</Typography>
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.text.muted }}>{WEEKDAY_LABEL[date.getDay()]}</Typography>
|
||||
{upcoming.map(({ date, events, isToday }, rowIdx) => (
|
||||
<Box
|
||||
key={date.toISOString()}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'stretch',
|
||||
borderTop: rowIdx === 0 ? 'none' : `1px dashed ${c.border.subtle}`,
|
||||
minHeight: 64,
|
||||
}}>
|
||||
<Box sx={{ width: 96, flexShrink: 0, display: 'flex', alignItems: 'center', gap: 0.75, pl: 2, pr: 1.25 }}>
|
||||
<Typography sx={{ fontSize: '1.55rem', fontWeight: 600, color: isToday ? accent : c.text.primary, lineHeight: 1, letterSpacing: '-0.01em' }}>
|
||||
{date.getDate()}
|
||||
</Typography>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: isToday ? accent : c.text.secondary, fontWeight: 500, lineHeight: 1.2 }}>
|
||||
{date.toLocaleString('en', { month: 'short' })}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.2 }}>{WEEKDAY_FULL[date.getDay()]}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 0.4 }}>
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', py: 1, pr: 2 }}>
|
||||
{events.length === 0 && (
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.ghost }}>No events today</Typography>
|
||||
)}
|
||||
{events.map((e, idx) => (
|
||||
<Tooltip key={`${e.workflow.id}-${idx}`} title={<EventTooltipBody event={e} />} placement="right" arrow>
|
||||
<Box
|
||||
onClick={() => onSelectWorkflow?.(e.workflow.id)}
|
||||
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
|
||||
sx={{ fontSize: '0.85rem', color: c.text.secondary, cursor: 'pointer', '&:hover': { color: c.accent.primary } }}>
|
||||
<strong style={{ color: c.text.primary }}>{e.workflow.title}</strong>
|
||||
<span style={{ color: c.text.muted, marginLeft: 8 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</span>
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1.25,
|
||||
py: 0.4,
|
||||
fontSize: '0.88rem', color: c.text.secondary, cursor: 'pointer',
|
||||
'&:hover .ev-title': { color: accent },
|
||||
}}>
|
||||
<Box sx={{ width: 3, alignSelf: 'stretch', minHeight: 22, bgcolor: accent, borderRadius: 1, flexShrink: 0 }} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography className="ev-title" sx={{ fontSize: '0.9rem', fontWeight: 500, color: c.text.primary, lineHeight: 1.3 }}>{e.workflow.title}</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.3 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
))}
|
||||
@@ -275,10 +318,9 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
);
|
||||
}
|
||||
|
||||
// Renders the events for a single calendar cell. Up to one pill is shown
|
||||
// inline; everything else collapses into a "+N" chip that opens a popover
|
||||
// with the full list, so the calendar stays readable at high schedule
|
||||
// density without truncating workflow titles.
|
||||
// Apple Calendar style event chip: 3px colored left-bar + faintly-tinted
|
||||
// background + readable text. One chip per cell with a "+N" badge for
|
||||
// overflow; clicking it opens a popover listing all events that hour.
|
||||
function EventStack({ events, onSelectWorkflow, eventFontSize, onContextWorkflow }: {
|
||||
events: { workflow: Workflow; date: Date }[];
|
||||
onSelectWorkflow?: (id: string) => void;
|
||||
@@ -290,7 +332,12 @@ function EventStack({ events, onSelectWorkflow, eventFontSize, onContextWorkflow
|
||||
if (events.length === 0) return null;
|
||||
const first = events[0];
|
||||
const rest = events.slice(1);
|
||||
const accent = c.accent.primary;
|
||||
|
||||
// Time string is part of the chip so a glance tells you both what and
|
||||
// when, matching Apple's "Title, 1pm" pattern. Chip is slim (height ~22)
|
||||
// not slot-stretching, since OpenSwarm events fire at a single instant.
|
||||
const timeLabel = formatTime(first.date.getHours(), first.date.getMinutes());
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={<EventTooltipBody event={first} />} placement="top" arrow>
|
||||
@@ -304,18 +351,20 @@ function EventStack({ events, onSelectWorkflow, eventFontSize, onContextWorkflow
|
||||
onContextMenu={(e) => onContextWorkflow?.(first.workflow, e)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 3, right: rest.length > 0 ? 28 : 3, top: 3, bottom: 3,
|
||||
bgcolor: c.accent.primary + '1f',
|
||||
color: c.accent.primary,
|
||||
border: `1px solid ${c.accent.primary}`,
|
||||
borderRadius: 999,
|
||||
px: 1.1, py: 0,
|
||||
fontSize: eventFontSize, fontWeight: 600,
|
||||
left: 2, right: rest.length > 0 ? 24 : 2, top: 2,
|
||||
height: 22,
|
||||
bgcolor: accent + '14',
|
||||
color: c.text.primary,
|
||||
borderLeft: `3px solid ${accent}`,
|
||||
borderRadius: '4px',
|
||||
px: 0.65, py: 0,
|
||||
fontSize: eventFontSize, fontWeight: 500,
|
||||
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center',
|
||||
'&:hover': { bgcolor: c.accent.primary + '33' },
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 0.5,
|
||||
'&:hover': { bgcolor: accent + '22' },
|
||||
}}>
|
||||
{first.workflow.title}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{first.workflow.title}</span>
|
||||
<span style={{ color: 'inherit', opacity: 0.7, flexShrink: 0 }}>{timeLabel}</span>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
{rest.length > 0 && (
|
||||
@@ -324,14 +373,15 @@ function EventStack({ events, onSelectWorkflow, eventFontSize, onContextWorkflow
|
||||
role="button"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 3, top: 3, bottom: 3,
|
||||
width: 22,
|
||||
bgcolor: c.accent.primary,
|
||||
color: '#fff',
|
||||
borderRadius: 999,
|
||||
right: 2, top: 2,
|
||||
height: 22,
|
||||
minWidth: 20, px: 0.4,
|
||||
bgcolor: accent + '22',
|
||||
color: accent,
|
||||
borderRadius: '4px',
|
||||
fontSize: eventFontSize, fontWeight: 700,
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
'&:hover': { filter: 'brightness(1.1)' },
|
||||
'&:hover': { bgcolor: accent + '33' },
|
||||
}}>
|
||||
+{rest.length}
|
||||
</Box>
|
||||
|
||||
@@ -6,6 +6,11 @@ import Select from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import RepeatIcon from '@mui/icons-material/RepeatRounded';
|
||||
import HourglassEmptyIcon from '@mui/icons-material/HourglassEmptyRounded';
|
||||
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||
import BedtimeIcon from '@mui/icons-material/BedtimeOutlined';
|
||||
import NotificationsIcon from '@mui/icons-material/NotificationsNoneRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { fetchCloudSmsStatus, type Workflow, type ScheduleConfig, type PermissionTier } from '@/shared/state/workflowsSlice';
|
||||
@@ -195,10 +200,12 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
|
||||
<AppOpenStatusBadge info={appOpen} hour={s.hour} minute={s.minute} onFix={fixAppOpen} />
|
||||
)}
|
||||
|
||||
{/* Row 3: repeat + timezone. */}
|
||||
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, mt: 0.5 }}>When should this workflow run?</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
<Typography sx={{ fontSize: BODY_FS, color: c.text.secondary }}>Repeat every</Typography>
|
||||
{/* Row 3: repeat + timezone. Icon replaces the "When should this
|
||||
workflow run?" prose; the inputs read self-evidently. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap', mt: 0.5 }}>
|
||||
<Tooltip title="How often this runs">
|
||||
<RepeatIcon sx={{ fontSize: 16, color: c.text.muted }} />
|
||||
</Tooltip>
|
||||
<InputBase
|
||||
type="number"
|
||||
value={s.repeat_every}
|
||||
@@ -216,8 +223,7 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
|
||||
</Select>
|
||||
</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>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, pl: 2.5, flexWrap: 'wrap' }}>
|
||||
{WEEKDAY_LABEL.map((label, idx) => {
|
||||
const active = s.on_days.includes(idx);
|
||||
return (
|
||||
@@ -230,8 +236,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>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, pl: 2.5 }}>
|
||||
{/* 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. */}
|
||||
@@ -282,8 +287,10 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
|
||||
)}
|
||||
|
||||
{/* Row 4: end condition. */}
|
||||
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, mt: 0.5 }}>For how long?</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pl: 2, flexWrap: 'wrap' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
|
||||
<Tooltip title="How long this should keep running">
|
||||
<HourglassEmptyIcon sx={{ fontSize: 16, color: c.text.muted }} />
|
||||
</Tooltip>
|
||||
<Select
|
||||
size="small"
|
||||
value={endKind}
|
||||
@@ -345,8 +352,10 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
|
||||
<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 }}>What can the agent do while it runs?</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pl: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5 }}>
|
||||
<Tooltip title="What the agent is allowed to do while it runs">
|
||||
<LockOutlinedIcon sx={{ fontSize: 16, color: c.text.muted }} />
|
||||
</Tooltip>
|
||||
<Select
|
||||
size="small"
|
||||
value={draft.actions.freeze ? 'scoped' : 'full'}
|
||||
@@ -368,8 +377,10 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
|
||||
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 your computer was asleep when a run was due:</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.25 }}>
|
||||
<Tooltip title="What to do if your computer was asleep when a run was due">
|
||||
<BedtimeIcon sx={{ fontSize: 16, color: c.text.muted }} />
|
||||
</Tooltip>
|
||||
<Select
|
||||
size="small"
|
||||
value={s.on_missed === 'run_all' ? 'run_once' : s.on_missed}
|
||||
@@ -381,7 +392,10 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
|
||||
</Box>
|
||||
|
||||
{/* Row 8: permission tiers. */}
|
||||
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, mt: 0.5 }}>How should the agent ask for your permission?</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
||||
<NotificationsIcon sx={{ fontSize: 16, color: c.text.muted }} />
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>When the agent needs your OK</Typography>
|
||||
</Box>
|
||||
{(draft.permissions || []).map((tier, idx) => (
|
||||
<PermissionRow
|
||||
key={idx}
|
||||
@@ -513,7 +527,7 @@ function PermissionRow({ idx, tier, cloudSmsEnabled, onChange, onRemove }: {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, pl: 2, position: 'relative' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>↳ and if I don't respond after</Typography>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>after</Typography>
|
||||
<InputBase
|
||||
type="number"
|
||||
value={tier.after_minutes}
|
||||
@@ -531,7 +545,7 @@ function PermissionRow({ idx, tier, cloudSmsEnabled, onChange, onRemove }: {
|
||||
{tier.kind !== 'call' && <MenuItem value="text">Text me</MenuItem>}
|
||||
{tier.kind === 'call' && <MenuItem value="call">Call me</MenuItem>}
|
||||
</Select>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>at this number</Typography>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>at</Typography>
|
||||
<InputBase
|
||||
value={tier.phone || ''}
|
||||
placeholder="+1 (000) 123 4567"
|
||||
|
||||
@@ -2,14 +2,20 @@ import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import BookmarkIcon from '@mui/icons-material/BookmarkBorderRounded';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import CalendarMonthIcon from '@mui/icons-material/CalendarMonthRounded';
|
||||
import OpenInFullIcon from '@mui/icons-material/OpenInFullRounded';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import ScheduleCalendar from './ScheduleCalendar';
|
||||
import { addDays, startOfWeek } from './scheduleUtils';
|
||||
|
||||
type Mode = 'search' | 'schedule';
|
||||
|
||||
@@ -34,8 +40,33 @@ export default function SchedulePopover({
|
||||
}: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const [calendarView, setCalendarView] = useState<'Week' | 'Month' | 'List'>('Week');
|
||||
const [refDate, setRefDate] = useState<Date>(() => new Date());
|
||||
const workflows = useAppSelector((s) => s.workflows.items);
|
||||
|
||||
const periodLabel = useMemo(() => {
|
||||
if (calendarView === 'Month') {
|
||||
return refDate.toLocaleString('en', { month: 'long', year: 'numeric' });
|
||||
}
|
||||
if (calendarView === 'Week') {
|
||||
const start = startOfWeek(refDate);
|
||||
const end = addDays(start, 6);
|
||||
const sameMonth = start.getMonth() === end.getMonth();
|
||||
const startStr = start.toLocaleString('en', { month: 'short', day: 'numeric' });
|
||||
const endStr = sameMonth
|
||||
? String(end.getDate())
|
||||
: end.toLocaleString('en', { month: 'short', day: 'numeric' });
|
||||
return `${startStr} – ${endStr}, ${end.getFullYear()}`;
|
||||
}
|
||||
return refDate.toLocaleString('en', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||
}, [refDate, calendarView]);
|
||||
|
||||
const onPrev = useCallback(() => {
|
||||
setRefDate((d) => addDays(d, calendarView === 'Month' ? -28 : calendarView === 'Week' ? -7 : -1));
|
||||
}, [calendarView]);
|
||||
const onNext = useCallback(() => {
|
||||
setRefDate((d) => addDays(d, calendarView === 'Month' ? 28 : calendarView === 'Week' ? 7 : 1));
|
||||
}, [calendarView]);
|
||||
|
||||
const workflowIconMap = useMemo(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const wf of Object.values(workflows)) {
|
||||
@@ -101,12 +132,19 @@ export default function SchedulePopover({
|
||||
<Typography sx={{ px: 1.5, py: 2.5, fontSize: '0.82rem', color: c.text.muted, textAlign: 'center' }}>{historyQuery ? 'No matching chats' : 'No chat history yet'}</Typography>
|
||||
)}
|
||||
{historyResults.map((entry) => {
|
||||
const icon = workflowIconMap[entry.id];
|
||||
const hasWorkflow = Boolean(workflowIconMap[entry.id]);
|
||||
return (
|
||||
<Box key={entry.id} onClick={() => onHistorySelect(entry.id)} sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.9, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}>
|
||||
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{entry.name}</Typography>
|
||||
{icon && (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 20, height: 20, borderRadius: '4px', bgcolor: c.accent.primary + '22', color: c.accent.primary, fontSize: '0.7rem', fontWeight: 700 }}>{icon}</Box>
|
||||
{/* Only annotate chats that became saved workflows.
|
||||
A small workflow glyph reads as a tag, where the
|
||||
old single-letter chip read as a random initial. */}
|
||||
{hasWorkflow && (
|
||||
<Tooltip title="This chat is saved as a workflow">
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 18, height: 18, borderRadius: '4px', color: c.text.muted }}>
|
||||
<BookmarkIcon sx={{ fontSize: 13 }} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, flexShrink: 0, whiteSpace: 'nowrap' }}>{relTime(entry.closed_at)}</Typography>
|
||||
</Box>
|
||||
@@ -128,8 +166,25 @@ export default function SchedulePopover({
|
||||
Expand
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Period nav: Today pill, prev/next chevrons, range label.
|
||||
Apple Calendar pattern. Keeps the popover usable without
|
||||
forcing a full Expand for date browsing. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 1.5, pb: 0.75, flexShrink: 0 }}>
|
||||
<Box
|
||||
onClick={() => setRefDate(new Date())}
|
||||
role="button"
|
||||
sx={{
|
||||
fontSize: '0.78rem', fontWeight: 600, color: c.text.secondary,
|
||||
border: `1px solid ${c.border.subtle}`, px: 0.95, py: 0.3,
|
||||
borderRadius: `${c.radius.md}px`, cursor: 'pointer',
|
||||
'&:hover': { color: c.text.primary, borderColor: c.border.medium },
|
||||
}}>Today</Box>
|
||||
<IconButton size="small" onClick={onPrev} sx={{ p: 0.3, color: c.text.muted, '&:hover': { color: c.text.primary } }}><ChevronLeftIcon sx={{ fontSize: 17 }} /></IconButton>
|
||||
<IconButton size="small" onClick={onNext} sx={{ p: 0.3, color: c.text.muted, '&:hover': { color: c.text.primary } }}><ChevronRightIcon sx={{ fontSize: 17 }} /></IconButton>
|
||||
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.primary, ml: 0.25 }}>{periodLabel}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 1.5, py: 1, borderTop: `1px solid ${c.border.subtle}`, minHeight: 0 }}>
|
||||
<ScheduleCalendar view={calendarView} density="roomy" onSelectWorkflow={onWorkflowSelect} />
|
||||
<ScheduleCalendar view={calendarView} density="roomy" onSelectWorkflow={onWorkflowSelect} refDate={refDate} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -26,7 +26,7 @@ interface Props {
|
||||
onChangeStep?: (idx: number, text: string) => void;
|
||||
}
|
||||
|
||||
const CIRCLE_SIZE = 28;
|
||||
const CIRCLE_SIZE = 24;
|
||||
// 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;
|
||||
@@ -81,20 +81,36 @@ export default function StepList({ workflow, steps, runs, activeRunId, framed, o
|
||||
const duration = workflow ? estimateStepDuration(workflow, runs, idx) : null;
|
||||
const isActive = activeStepIdx === idx;
|
||||
const isPast = activeStepIdx !== null && idx < activeStepIdx;
|
||||
// Target #54: step 1 always gets the framed-box treatment so
|
||||
// the eye lands on it (it reads as the "entry point" of the
|
||||
// workflow), steps 2+ stay plain text. The disc fill follows
|
||||
// the live run: active step gets the solid accent disc; past
|
||||
// steps a tinted disc; the rest a quiet outlined circle. When
|
||||
// no run is in flight, nothing is "active" so all discs stay
|
||||
// outlined, including step 1.
|
||||
const firstStep = idx === 0;
|
||||
const frameThis = framed && firstStep;
|
||||
// Target image #54: in framed mode, step 1's disc is a solid
|
||||
// accent fill with white text (it's the "entry point"), steps
|
||||
// 2+ are quiet outlined discs. During a live run the activeStepIdx
|
||||
// takes over and overrides this baseline.
|
||||
const primary = frameThis || isActive;
|
||||
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,
|
||||
border: `1px solid ${primary || isPast ? c.accent.primary : c.border.medium}`,
|
||||
bgcolor: primary ? c.accent.primary : isPast ? c.accent.primary + '22' : c.bg.surface,
|
||||
color: primary ? '#fff' : isPast ? c.accent.primary : c.text.muted,
|
||||
fontSize: '0.74rem', fontWeight: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
position: 'relative', zIndex: 1,
|
||||
lineHeight: 1,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
transition: 'background 0.25s ease, color 0.25s ease',
|
||||
}}>
|
||||
{Icon ? <Icon sx={{ fontSize: 14 }} /> : (idx + 1)}
|
||||
{Icon ? <Icon sx={{ fontSize: 13 }} /> : (idx + 1)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
{onChangeStep ? (
|
||||
@@ -105,20 +121,20 @@ export default function StepList({ workflow, steps, runs, activeRunId, framed, o
|
||||
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`,
|
||||
border: frameThis ? `1px solid ${c.border.medium}` : `1px solid transparent`,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: framed ? c.bg.surface : 'transparent',
|
||||
px: 1.25, py: 0.75, lineHeight: 1.4,
|
||||
bgcolor: frameThis ? c.bg.surface : 'transparent',
|
||||
px: frameThis ? 1.25 : 0, py: frameThis ? 0.75 : 0.1, lineHeight: 1.45,
|
||||
'&: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,
|
||||
border: frameThis ? `1px solid ${c.border.medium}` : 'none',
|
||||
borderRadius: frameThis ? `${c.radius.md}px` : 0,
|
||||
bgcolor: frameThis ? c.bg.surface : 'transparent',
|
||||
px: frameThis ? 1.25 : 0, py: frameThis ? 0.75 : 0.1,
|
||||
lineHeight: 1.45,
|
||||
}}>
|
||||
{s.text}
|
||||
|
||||
@@ -15,6 +15,7 @@ import HistoryIcon from '@mui/icons-material/HistoryRounded';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrowRounded';
|
||||
import ScheduleIcon from '@mui/icons-material/ScheduleRounded';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import {
|
||||
@@ -130,6 +131,24 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
}
|
||||
}, [card?.view, workflow?.id, runs, dispatch]);
|
||||
|
||||
// Layout state (workflowCards in dashboardLayoutSlice) persists across
|
||||
// app restarts; workflows.openCards in workflowsSlice does NOT — it's a
|
||||
// transient view-state cache. On relaunch the user sees the workflow
|
||||
// card position restored AND the source-chat tether redrawn, but the
|
||||
// card body itself doesn't render because openCards is empty. Auto-
|
||||
// create a Saved-view openCard once we know the workflow really exists
|
||||
// server-side. Without this, the user sees only the orange tether arrow
|
||||
// pointing at nothing.
|
||||
useEffect(() => {
|
||||
if (!workflow || card) return;
|
||||
dispatch(openWorkflowCardAction({
|
||||
workflowId: workflow.id,
|
||||
sourceSessionId: workflow.source_session_id || null,
|
||||
view: 'saved',
|
||||
draft: null,
|
||||
}));
|
||||
}, [workflow?.id, card, 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
|
||||
@@ -350,11 +369,22 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
|
||||
if (!card) return null;
|
||||
|
||||
// A "running" run is one that's actively executing right now. While
|
||||
// running, the card grows a subtle conic-gradient halo + a faint title
|
||||
// pulse so a glance at the canvas tells you something's working.
|
||||
const isRunning = (runs || []).some((r) => r.status === 'running') || workflow?.last_run_status === 'running';
|
||||
|
||||
// Hairline border for the default idle state (item #19 in target #54
|
||||
// diff). Keeps the card feeling like a soft surface, not a fenced
|
||||
// box. Highlighted / selected / running still bump up so feedback
|
||||
// is unambiguous.
|
||||
const border = isHighlighted
|
||||
? `2px solid ${c.accent.primary}`
|
||||
: isSelected
|
||||
? '2px solid #3b82f6'
|
||||
: `1px solid ${c.border.medium}`;
|
||||
: isRunning
|
||||
? `1px solid ${c.accent.primary}80`
|
||||
: `1px solid ${c.border.subtle}`;
|
||||
|
||||
const shadow = isHighlighted
|
||||
? `0 0 0 3px ${c.accent.primary}50, 0 0 20px ${c.accent.primary}35, 0 0 40px ${c.accent.primary}15`
|
||||
@@ -369,6 +399,7 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
data-select-type="workflow-card"
|
||||
data-select-id={workflowId}
|
||||
data-select-meta={JSON.stringify({ name: title })}
|
||||
data-running={isRunning ? 'true' : undefined}
|
||||
onPointerDownCapture={() => onBringToFront?.(workflowId, 'workflow')}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (justDraggedRef.current) return;
|
||||
@@ -386,7 +417,7 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
top: displayY,
|
||||
width: displayW,
|
||||
height: displayH,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
borderRadius: '14px',
|
||||
border,
|
||||
bgcolor: c.bg.surface,
|
||||
boxShadow: shadow,
|
||||
@@ -395,17 +426,58 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
zIndex: (isDragging || isResizing) ? 999999 : cardZOrder,
|
||||
transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease',
|
||||
'&:hover .resize-handle': { opacity: 1 },
|
||||
// Running halo: conic-gradient sweep around the card border + a
|
||||
// faint inner glow. Lives on ::before so the card body stays
|
||||
// crisp and isn't redrawn each frame. Only renders when the
|
||||
// data-running attribute is set (no perf cost when idle).
|
||||
'&[data-running="true"]::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: -1,
|
||||
borderRadius: '15px',
|
||||
padding: '1.5px',
|
||||
background: `conic-gradient(from 0deg, transparent 0deg, ${c.accent.primary} 60deg, transparent 120deg, transparent 240deg, ${c.accent.primary} 300deg, transparent 360deg)`,
|
||||
WebkitMask: 'linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)',
|
||||
WebkitMaskComposite: 'xor',
|
||||
maskComposite: 'exclude',
|
||||
animation: 'workflowRunSweep 2.4s linear infinite',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
opacity: 0.85,
|
||||
},
|
||||
'&[data-running="true"]::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: '14px',
|
||||
background: `radial-gradient(120% 80% at 50% 0%, ${c.accent.primary}10 0%, transparent 60%)`,
|
||||
animation: 'workflowRunPulse 2.4s ease-in-out infinite',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
},
|
||||
'@keyframes workflowRunSweep': {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'100%': { transform: 'rotate(360deg)' },
|
||||
},
|
||||
'@keyframes workflowRunPulse': {
|
||||
'0%, 100%': { opacity: 0.5 },
|
||||
'50%': { opacity: 1 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* ===== Title bar / drag handle ===== */}
|
||||
{/* ===== Title bar / drag handle =====
|
||||
Matches target image #54 spec: drag-grip on the far left, then a
|
||||
single bold title (no pill prefix), then a quiet close X. The
|
||||
run-status indicator moved to the inline "Scheduled:" prose
|
||||
below so the title row stays calm. Padding bumped from 1.1 to
|
||||
1.4 vertical so the title has air around it. */}
|
||||
<Box
|
||||
onPointerDown={handleDragPointerDown}
|
||||
onPointerMove={handleDragPointerMove}
|
||||
onPointerUp={handleDragPointerUp}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
px: 1.75, py: 1.1,
|
||||
borderBottom: `1px solid ${c.border.subtle}`,
|
||||
px: 2, py: 1.4,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
touchAction: 'none', userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
@@ -413,30 +485,53 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<DragIndicatorIcon sx={{ fontSize: 18, color: c.text.muted }} />
|
||||
{isDraft ? (
|
||||
// Draft state: title is inline-editable. Patches the openCard's
|
||||
// draft.title so PreviewView picks it up on Save. Saved cards
|
||||
// keep the read-only Typography below.
|
||||
<InputBase
|
||||
data-no-drag
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
value={(card?.draft?.title as string) || ''}
|
||||
placeholder="New workflow"
|
||||
onChange={(e) => dispatch(updateWorkflowCard({ workflowId, patch: { draft: { ...(card?.draft || {}), title: e.target.value } } }))}
|
||||
sx={{
|
||||
flex: 1, fontWeight: 700, fontSize: '1rem', color: c.text.primary,
|
||||
letterSpacing: '-0.005em',
|
||||
'& input::placeholder': { color: c.text.muted, opacity: 1 },
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Typography sx={{ flex: 1, fontWeight: 700, fontSize: '1rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', letterSpacing: '-0.005em' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
{runs && runs.length > 0 && <RunSparkline runs={runs} />}
|
||||
<IconButton
|
||||
size="small"
|
||||
data-no-drag
|
||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ p: 0.5, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}
|
||||
sx={{ p: 0.5, color: c.text.secondary, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
<CloseIcon sx={{ fontSize: 17 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* ===== Action bar ===== */}
|
||||
{/* ===== Action bar =====
|
||||
Target #54 puts Run / Edit / History flush left and "Schedule
|
||||
this task" flush right on the SAME row. We use justifyContent
|
||||
+ a flex spacer instead of wrap, so narrow widths shrink the
|
||||
action group rather than dropping Schedule onto a second line.
|
||||
Run is the only accent-colored button (it's the verb users
|
||||
actually do) but its border weight matches the siblings. */}
|
||||
{!isDraft && workflow && (
|
||||
<Box sx={{ display: 'flex', gap: 0.6, px: 2, py: 1, borderBottom: `1px solid ${c.border.subtle}`, flexWrap: 'wrap', flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 2, pb: 1.25, pt: 0, flexShrink: 0 }}>
|
||||
<TabBtn
|
||||
label={runStarting ? 'Starting…' : 'Run'}
|
||||
icon={<PlayArrowIcon sx={{ fontSize: 16 }} />}
|
||||
active={card.view === 'saved'}
|
||||
active={false}
|
||||
accent
|
||||
breathe={!runStarting && isStaleSinceLastRun(workflow)}
|
||||
breatheTooltip="Haven't run this in a few days. Click to run it now."
|
||||
@@ -478,8 +573,9 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
active={card.view === 'history' || card.view === 'history_detail'}
|
||||
onClick={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }))}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{!workflow.schedule.enabled && (
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<Box>
|
||||
<TabBtn
|
||||
label="Schedule this task"
|
||||
icon={<ScheduleIcon sx={{ fontSize: 16 }} />}
|
||||
@@ -520,14 +616,15 @@ 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 ref={bodyScrollRef} data-no-drag sx={{ flex: 1, p: 2, overflowY: 'auto', minHeight: 0, position: 'relative', overscrollBehavior: 'contain' }}>
|
||||
<Box ref={bodyScrollRef} data-no-drag sx={{ flex: 1, p: 2, overflowY: 'auto', minHeight: 0, position: 'relative', overscrollBehavior: 'contain', display: 'flex', flexDirection: 'column' }}>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={card.view}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -2 }}
|
||||
transition={{ duration: 0.14, ease: 'easeOut' }}>
|
||||
transition={{ duration: 0.14, ease: 'easeOut' }}
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
{card.view === 'preview' && (
|
||||
<PreviewView
|
||||
workflowId={workflowId}
|
||||
@@ -656,15 +753,30 @@ function TabBtn({ label, icon, active, accent, breathe, breatheTooltip, dot, dot
|
||||
role="button"
|
||||
data-no-drag
|
||||
sx={{
|
||||
// Consistent visual weight across Run/Edit/History per target
|
||||
// #54: identical padding + border thickness, matched 32px row
|
||||
// height. `accent` (Run only) gets the colored text + tinted bg
|
||||
// so it reads as the primary verb without screaming "selected".
|
||||
// Tabs no longer flip the bg on `active`; the body view itself
|
||||
// tells the user where they are.
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
px: 1.1, py: 0.5,
|
||||
px: 1.25, py: 0.5,
|
||||
minHeight: 32,
|
||||
fontSize: '0.82rem', fontWeight: 600,
|
||||
color: active ? c.accent.primary : c.text.secondary,
|
||||
bgcolor: active || accent ? c.accent.primary + '14' : 'transparent',
|
||||
border: `1px solid ${active || accent ? c.accent.primary + '40' : c.border.subtle}`,
|
||||
whiteSpace: 'nowrap',
|
||||
color: accent ? c.accent.primary : c.text.secondary,
|
||||
bgcolor: accent ? c.accent.primary + '14' : 'transparent',
|
||||
border: `1px solid ${accent ? c.accent.primary + '50' : c.border.medium}`,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
cursor: 'pointer', userSelect: 'none',
|
||||
'&:hover': { bgcolor: c.accent.primary + '10' },
|
||||
'&:hover': { bgcolor: accent ? c.accent.primary + '22' : c.bg.elevated, borderColor: accent ? c.accent.primary : c.text.muted },
|
||||
// Active just nudges the border + bg, doesn't repaint the whole
|
||||
// button. Mirrors macOS segmented-control behavior.
|
||||
...(active && {
|
||||
color: c.text.primary,
|
||||
bgcolor: c.bg.elevated,
|
||||
borderColor: c.border.medium,
|
||||
}),
|
||||
// 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
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 InputBase from '@mui/material/InputBase';
|
||||
import HistoryIcon from '@mui/icons-material/HistoryToggleOffRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
@@ -10,11 +11,12 @@ import {
|
||||
closeWorkflowCard,
|
||||
createWorkflow,
|
||||
updateWorkflow,
|
||||
updateWorkflowCard,
|
||||
type Workflow,
|
||||
type WorkflowRun,
|
||||
} from '@/shared/state/workflowsSlice';
|
||||
import { removeWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { ScheduleChip, PermissionChip, CostChip, humanDuration, routingFor, StreakBadge } from './workflowVisuals';
|
||||
import { CostChip, humanDuration, routingFor, StreakBadge } from './workflowVisuals';
|
||||
import StepList from './StepList';
|
||||
|
||||
export function statusColor(s: string, c: ReturnType<typeof useClaudeTokens>): string {
|
||||
@@ -48,23 +50,43 @@ export function formatRunDate(iso: string): string {
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
export function ActionBtn({ label, tone, disabled, onClick }: { label: string; tone: 'muted' | 'success'; disabled?: boolean; onClick: () => void }) {
|
||||
type ActionBtnTone = 'muted' | 'success' | 'danger';
|
||||
|
||||
export function ActionBtn({ label, tone, disabled, onClick, icon }: { label: string; tone: ActionBtnTone; disabled?: boolean; onClick: () => void; icon?: 'trash' | 'check' }) {
|
||||
const c = useClaudeTokens();
|
||||
const isSuccess = tone === 'success';
|
||||
// Tone -> color triple. Matches target #58/#63 styling:
|
||||
// success = green pill (Save)
|
||||
// danger = red/pink pill (Discard)
|
||||
// muted = neutral pill (Undo)
|
||||
const palette = tone === 'success'
|
||||
? { color: c.status.success, bg: c.status.successBg, border: c.status.success + '60', hover: c.status.success + '30' }
|
||||
: tone === 'danger'
|
||||
? { color: c.status.error, bg: c.status.errorBg, border: c.status.error + '60', hover: c.status.error + '30' }
|
||||
: { color: c.text.secondary, bg: c.bg.secondary, border: c.border.subtle, hover: c.bg.elevated };
|
||||
return (
|
||||
<Box
|
||||
onClick={disabled ? undefined : onClick}
|
||||
role="button"
|
||||
sx={{
|
||||
fontSize: '0.85rem', fontWeight: 600, px: 1.25, py: 0.55,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
// Compact pill matching target #58/#63. Smaller padding + smaller
|
||||
// glyphs so the buttons stop overshadowing the step body.
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
fontSize: '0.78rem', fontWeight: 600,
|
||||
px: 1, py: 0.35,
|
||||
borderRadius: 999,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
color: isSuccess ? c.status.success : c.text.secondary,
|
||||
bgcolor: isSuccess ? c.status.successBg : c.bg.secondary,
|
||||
border: `1px solid ${isSuccess ? c.status.success + '60' : c.border.subtle}`,
|
||||
color: palette.color,
|
||||
bgcolor: palette.bg,
|
||||
border: `1px solid ${palette.border}`,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
'&:hover': { bgcolor: isSuccess ? c.status.success + '30' : c.bg.elevated },
|
||||
'&:hover': { bgcolor: palette.hover },
|
||||
}}>
|
||||
{icon === 'trash' && (
|
||||
<Box component="span" sx={{ display: 'inline-flex', fontSize: 12, lineHeight: 1 }}>{'\u{1F5D1}'}</Box>
|
||||
)}
|
||||
{icon === 'check' && (
|
||||
<Box component="span" sx={{ display: 'inline-flex', fontSize: 12, lineHeight: 1 }}>{'✓'}</Box>
|
||||
)}
|
||||
{label}
|
||||
</Box>
|
||||
);
|
||||
@@ -80,8 +102,20 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const title = (initialDraft?.title as string) || 'Email summary request';
|
||||
const description = (initialDraft?.description as string) || "This is an ai generated description of the workflow that gets auto generated after you click complete on the last step. It's used when we wrap workflows as tool calls for other agents to invoke";
|
||||
// Title + description live in the openCard draft so the parent header
|
||||
// (which renders the inline-editable title) and PreviewView body (which
|
||||
// renders the inline-editable description + steps) stay in sync. On
|
||||
// Save we pull whatever's currently in the draft, falling back to the
|
||||
// initialDraft passed at mount time.
|
||||
const card = useAppSelector((s) => s.workflows.openCards[workflowId]);
|
||||
const liveDraft = (card?.draft ?? initialDraft ?? {}) as Partial<Workflow>;
|
||||
const title = (liveDraft.title as string) || 'New workflow';
|
||||
const description = (liveDraft.description as string) || '';
|
||||
// Track step text edits locally so the textarea stays uncontrolled-ish
|
||||
// (no remote round-trip on every keystroke). On Save we pass the
|
||||
// edited values through.
|
||||
const [editedSteps, setEditedSteps] = useState<Workflow['steps'] | null>(null);
|
||||
const liveSteps = editedSteps || steps;
|
||||
|
||||
const onSave = useCallback(async () => {
|
||||
if (busy) return;
|
||||
@@ -90,7 +124,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
const result = await dispatch(createWorkflow({
|
||||
title,
|
||||
description,
|
||||
steps: steps.map((s) => ({ id: s.id, text: s.text })),
|
||||
steps: liveSteps.map((s) => ({ id: s.id, text: s.text })),
|
||||
source_session_id: sourceSessionId,
|
||||
use_synced_prompt: true,
|
||||
} as Partial<Workflow>));
|
||||
@@ -99,33 +133,125 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, dispatch, title, description, steps, sourceSessionId, onSaved]);
|
||||
}, [busy, dispatch, title, description, liveSteps, sourceSessionId, onSaved]);
|
||||
|
||||
const onDiscard = useCallback(() => {
|
||||
dispatch(closeWorkflowCard(workflowId));
|
||||
dispatch(removeWorkflowCard(workflowId));
|
||||
}, [dispatch, workflowId]);
|
||||
|
||||
const onChangeDescription = useCallback((value: string) => {
|
||||
dispatch(updateWorkflowCard({ workflowId, patch: { draft: { ...liveDraft, description: value } } }));
|
||||
}, [dispatch, workflowId, liveDraft]);
|
||||
|
||||
const onChangeStep = useCallback((idx: number, value: string) => {
|
||||
const next = (liveSteps || []).slice();
|
||||
if (!next[idx]) return;
|
||||
next[idx] = { ...next[idx], text: value };
|
||||
setEditedSteps(next);
|
||||
}, [liveSteps]);
|
||||
|
||||
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>
|
||||
<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} />
|
||||
// minHeight: 100% so the bottom-right Discard/Save cluster pins to
|
||||
// the bottom of the card body, not just below the last step. Without
|
||||
// this, mt:auto has nothing to push against and the buttons floated
|
||||
// up next to step 1 (image #68 bug).
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
<InputBase
|
||||
multiline
|
||||
minRows={1}
|
||||
value={description}
|
||||
placeholder="Describe what this workflow does."
|
||||
onChange={(e) => onChangeDescription(e.target.value)}
|
||||
sx={{
|
||||
fontSize: '0.92rem', color: c.text.secondary, lineHeight: 1.55,
|
||||
border: `1px solid transparent`, borderRadius: `${c.radius.md}px`,
|
||||
px: 0.5, py: 0.25,
|
||||
'&:hover': { borderColor: c.border.subtle },
|
||||
'&.Mui-focused': { borderColor: c.border.medium },
|
||||
'& textarea::placeholder': { color: c.text.ghost, opacity: 1 },
|
||||
}}
|
||||
/>
|
||||
<StepList steps={liveSteps} framed onChangeStep={onChangeStep} />
|
||||
{/* Bottom-right cluster: Discard then Save, both pill-shaped with
|
||||
their respective trash + check glyphs. Matches target #58 / #63.
|
||||
mt:auto = pinned to the bottom of the flex column regardless of
|
||||
how little content lives above. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 1, mt: 'auto' }}>
|
||||
<ActionBtn label="Discard" tone="danger" icon="trash" onClick={onDiscard} />
|
||||
<ActionBtn label="Save" tone="success" icon="check" onClick={onSave} disabled={busy} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Render the workflow's permission tiers as a flat prose line so the
|
||||
// SavedView reads like a sentence, not a chip salad. Mirrors target #54.
|
||||
function describePermissions(workflow: Workflow): string {
|
||||
const tiers = workflow.permissions || [];
|
||||
if (tiers.length === 0) return 'Notify me in Open Swarm';
|
||||
const parts: string[] = [];
|
||||
for (const t of tiers) {
|
||||
if (t.kind === 'notify') parts.push('notify in app');
|
||||
else if (t.kind === 'text') parts.push('text');
|
||||
else if (t.kind === 'call') parts.push('call');
|
||||
}
|
||||
return `First ${parts.join(', then ')}`;
|
||||
}
|
||||
|
||||
function describeSchedule(workflow: Workflow): string {
|
||||
const s = workflow.schedule;
|
||||
if (!s.enabled) return 'Not scheduled';
|
||||
const h12 = ((s.hour + 11) % 12) + 1;
|
||||
const ampm = s.hour < 12 ? 'am' : 'pm';
|
||||
const time = s.minute === 0 ? `${h12}${ampm}` : `${h12}:${String(s.minute).padStart(2, '0')}${ampm}`;
|
||||
if (s.repeat_unit === 'day') return s.repeat_every === 1 ? `Every day at ${time}` : `Every ${s.repeat_every} days at ${time}`;
|
||||
if (s.repeat_unit === 'month') return s.repeat_every === 1 ? `Every month at ${time}` : `Every ${s.repeat_every} months at ${time}`;
|
||||
if (s.on_days.length === 5 && [1,2,3,4,5].every((d) => s.on_days.includes(d))) return `Weekdays at ${time}`;
|
||||
if (s.on_days.length === 2 && [0,6].every((d) => s.on_days.includes(d))) return `Weekends at ${time}`;
|
||||
if (s.on_days.length === 1) {
|
||||
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
return `Every ${labels[s.on_days[0]]} at ${time}`;
|
||||
}
|
||||
return `Weekly at ${time}`;
|
||||
}
|
||||
|
||||
export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Workflow; steps: Workflow['steps']; runs?: WorkflowRun[]; activeRunId?: string | null }) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const connectionMode = useAppSelector((s) => (s as { settings?: { data?: { connection_mode?: string } } }).settings?.data?.connection_mode);
|
||||
void c; void connectionMode;
|
||||
|
||||
// Inline step-1 edit per target image #63: the first framed step is
|
||||
// editable in place; once the user touches it the Discard/Save buttons
|
||||
// surface at the bottom right. Saving issues a steps PATCH against
|
||||
// the workflow.
|
||||
const [localFirstStep, setLocalFirstStep] = useState<string | null>(null);
|
||||
const [savingFirst, setSavingFirst] = useState(false);
|
||||
const firstStepDirty = localFirstStep != null && steps[0] && localFirstStep !== steps[0].text;
|
||||
const editableSteps = firstStepDirty && steps[0]
|
||||
? [{ ...steps[0], text: localFirstStep! }, ...steps.slice(1)]
|
||||
: steps;
|
||||
const onChangeFirstStep = useCallback((idx: number, text: string) => {
|
||||
if (idx !== 0) return;
|
||||
setLocalFirstStep(text);
|
||||
}, []);
|
||||
const onSaveFirstStep = useCallback(async () => {
|
||||
if (!firstStepDirty || savingFirst || !steps[0]) return;
|
||||
setSavingFirst(true);
|
||||
try {
|
||||
const nextSteps = [{ ...steps[0], text: localFirstStep! }, ...steps.slice(1)];
|
||||
await dispatch(updateWorkflow({
|
||||
id: workflow.id,
|
||||
patch: { steps: nextSteps },
|
||||
ifMatch: workflow.updated_at || null,
|
||||
}));
|
||||
setLocalFirstStep(null);
|
||||
} finally {
|
||||
setSavingFirst(false);
|
||||
}
|
||||
}, [firstStepDirty, savingFirst, steps, localFirstStep, dispatch, workflow.id, workflow.updated_at]);
|
||||
const onDiscardFirstStep = useCallback(() => setLocalFirstStep(null), []);
|
||||
// Habit suggestion: 3+ manual runs in the last 7 days on a workflow
|
||||
// that isn't scheduled → quietly offer to schedule it. One click flips
|
||||
// the schedule on at the most common time. Auto-disappears once the
|
||||
@@ -155,17 +281,33 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo
|
||||
ifMatch: workflow.updated_at || null,
|
||||
}));
|
||||
}, [habitSuggestion, dispatch, workflow.id, workflow.schedule, workflow.updated_at]);
|
||||
// Audit trigger lazy-loads the edit log; only show it when the
|
||||
// workflow has actually been edited. Skips the noisy "0 edits" link
|
||||
// on freshly created cards. We trigger the fetch on mount once so the
|
||||
// "edits"/no-edits decision is honest by the time the user reads.
|
||||
// minHeight: 100% lets the bottom-right cluster pin to the bottom of
|
||||
// the card body via mt:auto below.
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{/* 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} />
|
||||
<StreakBadge runs={runs} />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<AuditTraceLink workflowId={workflow.id} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
{/* Prose lines per target #54: "Scheduled:" + "Permissions:".
|
||||
Reads like a sentence the user can skim instead of a pill row
|
||||
that needs hovering to decode. Cost stays as a small inline
|
||||
chip on the right when there's anything to say. */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
<Typography sx={{ fontSize: '0.88rem', fontWeight: 700, color: c.text.primary }}>Scheduled:</Typography>
|
||||
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary }}>{describeSchedule(workflow)}</Typography>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{workflow.cost_estimate && workflow.cost_estimate.fires_per_month > 0 && (
|
||||
<CostChip workflow={workflow} connectionMode={connectionMode} />
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
|
||||
<Typography sx={{ fontSize: '0.88rem', fontWeight: 700, color: c.text.primary }}>Permissions:</Typography>
|
||||
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary }}>{describePermissions(workflow)}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<StreakBadgeRow runs={runs} />
|
||||
{habitSuggestion && (
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 0.75,
|
||||
@@ -178,12 +320,47 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo
|
||||
You've run this {habitSuggestion.count}× this week. Schedule it {habitSuggestion.label}?
|
||||
</Typography>
|
||||
<Box onClick={enableHabit} role="button" sx={{ fontSize: '0.74rem', fontWeight: 700, color: c.accent.primary, cursor: 'pointer', px: 0.5, '&:hover': { textDecoration: 'underline' } }}>
|
||||
Yes →
|
||||
Yes
|
||||
</Box>
|
||||
</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} />
|
||||
{workflow.description && (
|
||||
<Typography sx={{ fontSize: '0.92rem', color: c.text.secondary, lineHeight: 1.55, mt: 0.5 }}>
|
||||
{workflow.description}
|
||||
</Typography>
|
||||
)}
|
||||
<StepList
|
||||
workflow={workflow}
|
||||
steps={editableSteps}
|
||||
runs={runs}
|
||||
activeRunId={activeRunId}
|
||||
framed
|
||||
onChangeStep={onChangeFirstStep}
|
||||
/>
|
||||
{/* Bottom-right cluster matching target image #63. Discard + Save
|
||||
only surface when the user has actually edited the first step
|
||||
inline; otherwise we don't crowd the card with idle buttons. */}
|
||||
{firstStepDirty ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 1, mt: 'auto' }}>
|
||||
<ActionBtn label="Discard" tone="danger" icon="trash" onClick={onDiscardFirstStep} />
|
||||
<ActionBtn label={savingFirst ? 'Saving…' : 'Save'} tone="success" icon="check" disabled={savingFirst} onClick={onSaveFirstStep} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 'auto' }}>
|
||||
<AuditTraceLink workflowId={workflow.id} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Splits StreakBadge out so the SavedView body doesn't have to ferry
|
||||
// the runs array through both the chip row (gone) and the step list.
|
||||
function StreakBadgeRow({ runs }: { runs?: WorkflowRun[] }) {
|
||||
if (!runs || runs.length === 0) return null;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<StreakBadge runs={runs} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -196,6 +373,31 @@ function AuditTraceLink({ workflowId }: { workflowId: string }) {
|
||||
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);
|
||||
// Probe the audit log once on mount so we can hide the trigger entirely
|
||||
// when there are no edits (item #21 in target #54 diff). Fire-and-forget;
|
||||
// a failure leaves entries=null which renders nothing.
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
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();
|
||||
if (alive) setEntries(Array.isArray(data?.entries) ? data.entries : []);
|
||||
} catch {
|
||||
if (alive) setEntries([]);
|
||||
}
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [workflowId]);
|
||||
// The popover open handler must be declared BEFORE the conditional
|
||||
// return below; otherwise React sees a different hook-count between
|
||||
// the "loading" render (returns early) and the "loaded with entries"
|
||||
// render (calls useCallback), which triggers the "Rendered more hooks
|
||||
// than during the previous render" crash.
|
||||
const open = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setAnchor(e.currentTarget);
|
||||
if (entries !== null) return;
|
||||
@@ -214,6 +416,8 @@ function AuditTraceLink({ workflowId }: { workflowId: string }) {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [entries, workflowId]);
|
||||
// Hide entirely until we know whether there are edits to surface.
|
||||
if (entries === null || entries.length === 0) return null;
|
||||
const close = () => setAnchor(null);
|
||||
const count = entries?.length ?? 0;
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { updateWorkflow, type Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { validateDraft } from './permissionsUtils';
|
||||
import { ActionBtn, LABEL_FS, HINT_FS } from './workflowEditCommon';
|
||||
import { ActionBtn, HINT_FS, LABEL_FS } from './workflowEditCommon';
|
||||
import GeneralFacet from './GeneralFacet';
|
||||
import ActionsFacet from './ActionsFacet';
|
||||
import ScheduleFacet from './ScheduleFacet';
|
||||
@@ -95,25 +95,44 @@ export default function WorkflowEditViews({ workflow, facet, onChangeFacet, onDi
|
||||
setSaveError(null);
|
||||
}, [workflow]);
|
||||
|
||||
// Right-edge save indicator. dirty + busy + savedFlash collapse to a
|
||||
// single state so the button doesn't flicker between "Save now" and
|
||||
// "Up to date" mid-keystroke. When idle and clean, show a quiet
|
||||
// check-mark "Saved" label that's identical to the post-flash state.
|
||||
const saveState: 'dirty' | 'busy' | 'saved' = busy ? 'busy' : dirty ? 'dirty' : 'saved';
|
||||
const _flash = savedFlash; void _flash;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{/* Top control row, target image #67:
|
||||
"Currently Editing [Select▾]" spacer [Discard] [Save]
|
||||
Discard + Save are the same pill-style buttons used at the
|
||||
bottom of SavedView; placing them here gives the user a single
|
||||
place to commit OR throw away whatever they just edited. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, fontWeight: 500 }}>Currently Editing</Typography>
|
||||
<Select
|
||||
size="small"
|
||||
value={facet}
|
||||
onChange={(e) => onChangeFacet(e.target.value as Props['facet'])}
|
||||
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
|
||||
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
|
||||
<MenuItem value="General">General</MenuItem>
|
||||
<MenuItem value="Actions">Actions</MenuItem>
|
||||
<MenuItem value="Schedule">Schedule</MenuItem>
|
||||
</Select>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<ActionBtn label="Undo changes" tone="muted" disabled={!dirty || busy} onClick={onDiscard} />
|
||||
<ActionBtn
|
||||
label={busy ? 'Saving…' : savedFlash ? '✓ Saved' : dirty ? 'Save now' : '✓ Up to date'}
|
||||
tone="success"
|
||||
label="Discard"
|
||||
tone="danger"
|
||||
icon="trash"
|
||||
disabled={!dirty || busy}
|
||||
onClick={onDiscard}
|
||||
/>
|
||||
<ActionBtn
|
||||
label={busy ? 'Saving…' : 'Save'}
|
||||
tone="success"
|
||||
icon="check"
|
||||
disabled={!dirty || busy || saveState === 'saved'}
|
||||
onClick={onSave}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -10,6 +10,7 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import CallSplitRoundedIcon from '@mui/icons-material/CallSplitRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import {
|
||||
@@ -18,7 +19,10 @@ import {
|
||||
setWorkflowsHubPosition,
|
||||
setWorkflowsHubSize,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { openWorkflowCard, fetchPausedState, setPausedAll } from '@/shared/state/workflowsSlice';
|
||||
import { openWorkflowCard, fetchPausedState, setPausedAll, updateWorkflow, deleteWorkflow, runWorkflowNow } from '@/shared/state/workflowsSlice';
|
||||
import type { Workflow } from '@/shared/state/workflowsSlice';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { useEffect } from 'react';
|
||||
@@ -85,19 +89,25 @@ function TimeSavedBadge() {
|
||||
}
|
||||
if (count === 0) return null;
|
||||
const totalMin = count * 3;
|
||||
const label = totalMin >= 60 ? `${(totalMin / 60).toFixed(1)} hrs back` : `${totalMin} min back`;
|
||||
const hours = totalMin / 60;
|
||||
// Show "X done · ~Y hrs" so the user gets both the run count and a
|
||||
// sense of time. Dot-separator reads quieter than the old green pill.
|
||||
const timeLabel = hours >= 1 ? `~${hours.toFixed(1)} hrs` : `~${totalMin} min`;
|
||||
return (
|
||||
<Tooltip title={`${count} workflow runs completed for you. Quiet estimate of ~3 min saved per run vs. doing it by hand.`}>
|
||||
<Tooltip title={`${count} workflow runs completed for you. Rough estimate of ~3 min saved per run vs. doing it by hand.`}>
|
||||
<Box sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
ml: 1, px: 0.85, py: 0.25,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
ml: 1, px: 0.85, py: 0.2,
|
||||
fontSize: '0.74rem', fontWeight: 600,
|
||||
color: c.status.success || c.accent.primary,
|
||||
bgcolor: (c.status.success || c.accent.primary) + '14',
|
||||
border: `1px solid ${(c.status.success || c.accent.primary) + '40'}`,
|
||||
color: c.text.secondary,
|
||||
bgcolor: 'transparent',
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: 999,
|
||||
}}>
|
||||
✓ {label}
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 14, height: 14, borderRadius: '50%', bgcolor: (c.status.success || c.accent.primary) + '22', color: c.status.success || c.accent.primary, fontSize: 9, fontWeight: 800 }}>✓</Box>
|
||||
<span style={{ color: c.text.primary }}>{count}</span>
|
||||
<span style={{ color: c.text.muted }}>·</span>
|
||||
<span style={{ color: c.text.secondary }}>{timeLabel} back</span>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -122,9 +132,21 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
const [viewOpen, setViewOpen] = useState(false);
|
||||
const [refDate, setRefDate] = useState(new Date());
|
||||
const [search, setSearch] = useState('');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
// Right-click on a sidebar row opens this menu pinned to the cursor.
|
||||
// Mirrors the calendar pill context menu so the two surfaces feel
|
||||
// consistent. closeMenu wipes both state + DOM-focus.
|
||||
const [sidebarCtxMenu, setSidebarCtxMenu] = useState<{ x: number; y: number; workflow: Workflow } | null>(null);
|
||||
const closeSidebarCtxMenu = useCallback(() => setSidebarCtxMenu(null), []);
|
||||
|
||||
const scheduled = useMemo(() => Object.values(workflows).filter((w) => w.schedule.enabled), [workflows]);
|
||||
const unscheduled = useMemo(() => Object.values(workflows).filter((w) => !w.schedule.enabled), [workflows]);
|
||||
// "Scheduled" = the workflow has a real cadence configured at any
|
||||
// point (even if currently paused via the checkbox). Filtering by
|
||||
// `enabled` would yank rows out from under the user the moment they
|
||||
// unticked the box, which feels wrong. on_days/hour/minute being set
|
||||
// is a good proxy for "user already configured this." Falls back to
|
||||
// enabled flag for legacy records.
|
||||
const scheduled = useMemo(() => Object.values(workflows).filter((w) => isSchedulable(w)), [workflows]);
|
||||
const unscheduled = useMemo(() => Object.values(workflows).filter((w) => !isSchedulable(w)), [workflows]);
|
||||
|
||||
const monthLabel = refDate.toLocaleString('en', { month: 'long', year: 'numeric' });
|
||||
|
||||
@@ -297,7 +319,11 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 16, height: 16, color: c.accent.primary, fontSize: 14 }}>⚡</Box>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 18, height: 18, color: c.accent.primary }}>
|
||||
{/* CallSplit natively forks upward; rotated 90deg the fork
|
||||
points right, matching the Workflows brand mark. */}
|
||||
<CallSplitRoundedIcon sx={{ fontSize: 16, transform: 'rotate(90deg)' }} />
|
||||
</Box>
|
||||
<Typography sx={{ flex: 1, fontWeight: 700, fontSize: '0.88rem', color: c.text.primary }}>Workflows</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -312,9 +338,11 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
|
||||
{/* ===== Toolbar row (matches Figma image #8 header) ===== */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.65, px: 1.5, py: 0.7, borderBottom: `1px solid ${c.border.subtle}`, flexShrink: 0 }}>
|
||||
<IconButton size="small" data-no-drag sx={{ p: 0.5, color: c.text.muted }}>
|
||||
<MenuIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
<Tooltip title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}>
|
||||
<IconButton size="small" data-no-drag onClick={() => setSidebarOpen((v) => !v)} sx={{ p: 0.5, color: sidebarOpen ? c.text.secondary : c.text.muted, '&:hover': { color: c.text.primary } }}>
|
||||
<MenuIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Box
|
||||
onClick={onNew}
|
||||
role="button"
|
||||
@@ -403,6 +431,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
{/* ===== Body: sidebar + main calendar ===== */}
|
||||
<Box sx={{ flex: 1, display: 'flex', minHeight: 0 }}>
|
||||
{/* Sidebar */}
|
||||
{sidebarOpen && (
|
||||
<Box sx={{ width: 240, flexShrink: 0, borderRight: `1px solid ${c.border.subtle}`, display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ px: 1.5, pt: 1.25, pb: 0.75 }}>
|
||||
<InputBase
|
||||
@@ -415,10 +444,11 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
</Box>
|
||||
<MiniMonth refDate={refDate} onPick={setRefDate} />
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 1.5, pb: 1.5 }}>
|
||||
<SidebarSection title="Scheduled workflows" items={scheduled.filter((w) => match(w.title, search))} onPick={onSelectWorkflow} scheduled />
|
||||
<SidebarSection title="Un-scheduled workflows" items={unscheduled.filter((w) => match(w.title, search))} onPick={onSelectWorkflow} scheduled={false} />
|
||||
<SidebarSection title="Scheduled workflows" items={scheduled.filter((w) => match(w.title, search))} onPick={onSelectWorkflow} scheduled onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} />
|
||||
<SidebarSection title="Un-scheduled workflows" items={unscheduled.filter((w) => match(w.title, search))} onPick={onSelectWorkflow} scheduled={false} onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Main calendar area */}
|
||||
<Box sx={{ flex: 1, minWidth: 0, overflow: 'auto', p: 1.5 }}>
|
||||
@@ -426,6 +456,45 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right-click menu shared across all sidebar workflow rows */}
|
||||
<Menu
|
||||
open={Boolean(sidebarCtxMenu)}
|
||||
onClose={closeSidebarCtxMenu}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={sidebarCtxMenu ? { top: sidebarCtxMenu.y, left: sidebarCtxMenu.x } : undefined}>
|
||||
<MenuItem onClick={() => {
|
||||
if (!sidebarCtxMenu) return;
|
||||
dispatch(runWorkflowNow(sidebarCtxMenu.workflow.id));
|
||||
closeSidebarCtxMenu();
|
||||
}}>Run now</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
if (!sidebarCtxMenu) return;
|
||||
const wf = sidebarCtxMenu.workflow;
|
||||
dispatch(updateWorkflow({
|
||||
id: wf.id,
|
||||
patch: { schedule: { ...wf.schedule, enabled: !wf.schedule.enabled } as any },
|
||||
ifMatch: wf.updated_at || null,
|
||||
}));
|
||||
closeSidebarCtxMenu();
|
||||
}}>{sidebarCtxMenu?.workflow.schedule.enabled ? 'Pause schedule' : 'Resume schedule'}</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
if (!sidebarCtxMenu) return;
|
||||
dispatch(addWorkflowCard({ workflowId: sidebarCtxMenu.workflow.id }));
|
||||
dispatch(openWorkflowCard({ workflowId: sidebarCtxMenu.workflow.id, view: 'edit', editFacet: 'Schedule' }));
|
||||
closeSidebarCtxMenu();
|
||||
}}>Edit…</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
if (!sidebarCtxMenu) return;
|
||||
const ok = window.confirm(`Delete "${sidebarCtxMenu.workflow.title}"? Scheduled runs will stop.`);
|
||||
if (ok) dispatch(deleteWorkflow(sidebarCtxMenu.workflow.id));
|
||||
closeSidebarCtxMenu();
|
||||
}}
|
||||
sx={{ color: c.status.error }}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* Resize handles */}
|
||||
{HANDLE_DEFS.map(({ dir, sx }) => (
|
||||
<Box
|
||||
@@ -473,14 +542,26 @@ function MiniMonth({ refDate, onPick }: { refDate: Date; onPick: (d: Date) => vo
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSection({ title, items, onPick, scheduled }: {
|
||||
function SidebarSection({ title, items, onPick, scheduled, onContext }: {
|
||||
title: string;
|
||||
items: { id: string; title: string; schedule: { enabled: boolean } }[];
|
||||
items: Workflow[];
|
||||
onPick: (id: string) => void;
|
||||
scheduled: boolean;
|
||||
onContext: (workflow: Workflow, e: React.MouseEvent) => void;
|
||||
}) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const toggleEnabled = useCallback((wf: Workflow, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
dispatch(updateWorkflow({
|
||||
id: wf.id,
|
||||
patch: { schedule: { ...wf.schedule, enabled: !wf.schedule.enabled } as any },
|
||||
ifMatch: wf.updated_at || null,
|
||||
}));
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Box
|
||||
@@ -498,20 +579,44 @@ function SidebarSection({ title, items, onPick, scheduled }: {
|
||||
<Box
|
||||
key={w.id}
|
||||
onClick={() => onPick(w.id)}
|
||||
onContextMenu={(e) => { e.preventDefault(); onContext(w, e); }}
|
||||
data-no-drag
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.4, pl: 0.5, color: c.text.primary, borderRadius: 0.5, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}>
|
||||
{scheduled ? (
|
||||
<Box sx={{ width: 11, height: 11, border: `1.5px solid ${c.accent.primary}`, bgcolor: c.accent.primary, borderRadius: 0.25, flexShrink: 0 }} />
|
||||
<Tooltip title={w.schedule.enabled ? 'Pause this schedule' : 'Resume this schedule'}>
|
||||
<Box
|
||||
onClick={(e) => toggleEnabled(w, e)}
|
||||
sx={{
|
||||
width: 14, height: 14, borderRadius: '3px', flexShrink: 0,
|
||||
border: `1.5px solid ${w.schedule.enabled ? c.accent.primary : c.border.medium}`,
|
||||
bgcolor: w.schedule.enabled ? c.accent.primary : 'transparent',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', fontSize: 10, lineHeight: 1, fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { borderColor: c.accent.primary },
|
||||
}}>
|
||||
{w.schedule.enabled ? '✓' : ''}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<AddIcon sx={{ fontSize: 13, color: c.text.muted, flexShrink: 0 }} />
|
||||
)}
|
||||
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{w.title}</Typography>
|
||||
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textDecoration: scheduled && !w.schedule.enabled ? 'line-through' : 'none', opacity: scheduled && !w.schedule.enabled ? 0.6 : 1 }}>{w.title}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function isSchedulable(w: Workflow): boolean {
|
||||
if (w.schedule.enabled) return true;
|
||||
// Heuristic: any prior config means the user already opened the
|
||||
// Schedule facet and committed something. Pure defaults stay in
|
||||
// "Un-scheduled" so brand-new workflows don't pollute the list.
|
||||
const s = w.schedule;
|
||||
return Boolean(s.on_days?.length || s.ends_at || s.max_runs || s.runs_count);
|
||||
}
|
||||
|
||||
function match(title: string, query: string): boolean {
|
||||
if (!query.trim()) return true;
|
||||
return title.toLowerCase().includes(query.trim().toLowerCase());
|
||||
|
||||
@@ -17,8 +17,12 @@ const HOUR_WORDS: Record<string, number> = {
|
||||
morning: 9, noon: 12, afternoon: 14, evening: 18, night: 21, midnight: 0,
|
||||
};
|
||||
|
||||
// Match "9am" / "9 a.m." / "10:30 PM" / "at 7" (defaults am).
|
||||
const HOUR_RE = /\b(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.|p\.m\.)?\b/i;
|
||||
// Match "9am" / "9 a.m." / "10:30 PM" / "at 7am" — but ONLY when there's
|
||||
// either an explicit am/pm suffix or an "at " prefix. Plain digits with
|
||||
// no time context ("3 new messages", "May 16", "$50 offer") used to slip
|
||||
// through and we'd misread them as the schedule hour. Anchoring on
|
||||
// `(am|pm)` OR `at ` blocks that.
|
||||
const HOUR_RE = /\b(?:at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.|p\.m\.)?|(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.|p\.m\.))\b/i;
|
||||
|
||||
const DAY_RE = /\b(sun|mon|tue|wed|thu|fri|sat)(?:day)?s?\b/gi;
|
||||
const DAY_MAP: Record<string, number> = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
|
||||
@@ -45,17 +49,17 @@ export function detectSchedule(text: string): DetectedSchedule | null {
|
||||
}
|
||||
const hm = t.match(HOUR_RE);
|
||||
if (hm) {
|
||||
const raw = parseInt(hm[1], 10);
|
||||
const m = hm[2] ? parseInt(hm[2], 10) : 0;
|
||||
const ampm = (hm[3] || '').toLowerCase();
|
||||
// The two branches of HOUR_RE give us hour/minute/ampm in either
|
||||
// capture group 1-3 (the "at H" branch) or 4-6 (the "Ham/pm" branch).
|
||||
const rawStr = hm[1] || hm[4];
|
||||
const minStr = hm[2] || hm[5];
|
||||
const ampm = (hm[3] || hm[6] || '').toLowerCase();
|
||||
const raw = rawStr ? parseInt(rawStr, 10) : NaN;
|
||||
const m = minStr ? parseInt(minStr, 10) : 0;
|
||||
let h = raw;
|
||||
if (ampm.startsWith('p') && h < 12) h += 12;
|
||||
if (ampm.startsWith('a') && h === 12) h = 0;
|
||||
// Only accept the regex hit if it's a plausible hour AND we didn't
|
||||
// already get a confident word-based hour. Words win because "every
|
||||
// morning at 9" should be 9am, not the literal "9" with no ampm
|
||||
// bumped into pm.
|
||||
if (h >= 0 && h < 24) {
|
||||
if (Number.isFinite(h) && h >= 0 && h < 24) {
|
||||
if (!presetTimeWord) { hour = h; minute = m; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,15 @@ export function fireTimesWithin(workflow: Workflow, from: Date, to: Date, cap =
|
||||
if (!Number.isNaN(endsAt.getTime()) && endsAt.getTime() <= from.getTime()) return [];
|
||||
if (!Number.isNaN(endsAt.getTime()) && endsAt.getTime() < to.getTime()) to = endsAt;
|
||||
}
|
||||
// Don't paint fires for days that predate the workflow itself. A
|
||||
// workflow created this Wednesday shouldn't show pills on Sun/Mon/Tue
|
||||
// of the same week. created_at is an ISO string; only floor on success.
|
||||
if (workflow.created_at) {
|
||||
const createdAt = new Date(workflow.created_at);
|
||||
if (!Number.isNaN(createdAt.getTime()) && createdAt.getTime() > from.getTime()) {
|
||||
from = createdAt;
|
||||
}
|
||||
}
|
||||
if (sched.max_runs != null && sched.runs_count >= sched.max_runs) return [];
|
||||
const remainingRuns = sched.max_runs != null ? Math.max(0, sched.max_runs - sched.runs_count) : Infinity;
|
||||
const effectiveCap = Math.min(cap, remainingRuns);
|
||||
|
||||
@@ -18,23 +18,32 @@ export function FieldRow({ label, children, align }: { label: string; children:
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionBtn({ label, tone, disabled, onClick }: { label: string; tone: 'muted' | 'success'; disabled?: boolean; onClick: () => void }) {
|
||||
type ActionBtnTone = 'muted' | 'success' | 'danger';
|
||||
|
||||
export function ActionBtn({ label, tone, disabled, onClick, icon }: { label: string; tone: ActionBtnTone; disabled?: boolean; onClick: () => void; icon?: 'trash' | 'check' }) {
|
||||
const c = useClaudeTokens();
|
||||
const isSuccess = tone === 'success';
|
||||
const palette = tone === 'success'
|
||||
? { color: c.status.success, bg: c.status.successBg, border: c.status.success + '60', hover: c.status.success + '30' }
|
||||
: tone === 'danger'
|
||||
? { color: c.status.error, bg: c.status.errorBg, border: c.status.error + '60', hover: c.status.error + '30' }
|
||||
: { color: c.text.secondary, bg: c.bg.secondary, border: c.border.subtle, hover: c.bg.elevated };
|
||||
return (
|
||||
<Box
|
||||
onClick={disabled ? undefined : onClick}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.45,
|
||||
fontSize: LABEL_FS, fontWeight: 600, px: 1.25, py: 0.5,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
borderRadius: 999,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
color: isSuccess ? c.status.success : c.text.secondary,
|
||||
bgcolor: isSuccess ? c.status.successBg : c.bg.secondary,
|
||||
border: `1px solid ${isSuccess ? c.status.success + '60' : c.border.subtle}`,
|
||||
color: palette.color,
|
||||
bgcolor: palette.bg,
|
||||
border: `1px solid ${palette.border}`,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
'&:hover': { bgcolor: isSuccess ? c.status.success + '30' : c.bg.elevated },
|
||||
'&:hover': { bgcolor: palette.hover },
|
||||
}}>
|
||||
{icon === 'trash' && <Box component="span" sx={{ fontSize: 13, lineHeight: 1 }}>{'\u{1F5D1}'}</Box>}
|
||||
{icon === 'check' && <Box component="span" sx={{ fontSize: 13, lineHeight: 1 }}>{'✓'}</Box>}
|
||||
{label}
|
||||
</Box>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user