mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 20:27:44 +02:00
Merge remote-tracking branch 'origin/aidan/feat/scheduled-tasks' into aidan/feat/workflow-auto-naming
# Conflicts: # frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx
This commit is contained in:
@@ -101,8 +101,14 @@ def _js_weekday(d: datetime) -> int:
|
||||
return (d.weekday() + 1) % 7
|
||||
|
||||
|
||||
def is_schedule_configured(sched: ScheduleConfig) -> bool:
|
||||
if sched.repeat_unit == "week":
|
||||
return bool(sched.on_days)
|
||||
return True
|
||||
|
||||
|
||||
def _next_fire_after(sched: ScheduleConfig, ref_utc: datetime) -> Optional[datetime]:
|
||||
if not sched.enabled:
|
||||
if not sched.enabled or not is_schedule_configured(sched):
|
||||
return None
|
||||
tz = _resolve_tz(sched.timezone)
|
||||
ref_local = ref_utc.astimezone(tz)
|
||||
@@ -135,7 +141,7 @@ def _next_fire_after(sched: ScheduleConfig, ref_utc: datetime) -> Optional[datet
|
||||
return candidate.astimezone(timezone.utc)
|
||||
|
||||
if sched.repeat_unit == "week":
|
||||
allowed = sched.on_days or [_js_weekday(ref_local)]
|
||||
allowed = sched.on_days
|
||||
for _ in range(0, 14):
|
||||
if _js_weekday(candidate) in allowed and candidate > ref_local:
|
||||
return candidate.astimezone(timezone.utc)
|
||||
@@ -216,6 +222,9 @@ async def _tick() -> None:
|
||||
for wf in storage.list_workflows():
|
||||
if not wf.schedule.enabled:
|
||||
continue
|
||||
if not is_schedule_configured(wf.schedule):
|
||||
_disable_schedule(wf)
|
||||
continue
|
||||
if _end_condition_hit(wf, now_utc):
|
||||
_disable_schedule(wf)
|
||||
continue
|
||||
@@ -304,6 +313,10 @@ def reconcile_on_startup() -> None:
|
||||
storage.save_workflow(wf)
|
||||
continue
|
||||
|
||||
if not is_schedule_configured(wf.schedule):
|
||||
_disable_schedule(wf)
|
||||
continue
|
||||
|
||||
if _end_condition_hit(wf, now_utc):
|
||||
_disable_schedule(wf)
|
||||
continue
|
||||
|
||||
@@ -143,6 +143,12 @@ async def list_workflows(dashboard_id: Optional[str] = None):
|
||||
return {"workflows": [_enriched(w) for w in items]}
|
||||
|
||||
|
||||
def _normalize_schedule_state(wf: Workflow) -> None:
|
||||
if wf.schedule.enabled and not scheduler.is_schedule_configured(wf.schedule):
|
||||
wf.schedule.enabled = False
|
||||
wf.next_run_at = scheduler.compute_next_fire(wf) if wf.schedule.enabled else None
|
||||
|
||||
|
||||
@workflows.router.post("/create")
|
||||
async def create_workflow(body: WorkflowCreate):
|
||||
actions = body.actions
|
||||
@@ -151,7 +157,7 @@ async def create_workflow(body: WorkflowCreate):
|
||||
# Source-session creates inherit the chat's tool choices so we leave
|
||||
# them alone there (the source session itself already vetted the
|
||||
# blast radius).
|
||||
if body.schedule.enabled and not actions.freeze and not body.source_session_id:
|
||||
if body.schedule.enabled and scheduler.is_schedule_configured(body.schedule) and not actions.freeze and not body.source_session_id:
|
||||
actions = actions.model_copy(update={"freeze": True})
|
||||
wf = Workflow(
|
||||
title=body.title,
|
||||
@@ -175,8 +181,7 @@ async def create_workflow(body: WorkflowCreate):
|
||||
wf.remembered_approvals = p_source_session_approvals(body.source_session_id)
|
||||
if not wf.icon:
|
||||
wf.icon = _derive_icon(wf)
|
||||
if wf.schedule.enabled:
|
||||
wf.next_run_at = scheduler.compute_next_fire(wf)
|
||||
_normalize_schedule_state(wf)
|
||||
# Force-generate title + description + per-step labels from the steps
|
||||
# in a single aux call. Previously we only filled missing description,
|
||||
# leaving stale session names ("Inbox check") as titles. Step labels
|
||||
@@ -560,6 +565,7 @@ async def update_workflow(
|
||||
if k != "steps":
|
||||
setattr(wf, k, v)
|
||||
wf.updated_at = datetime.now()
|
||||
_normalize_schedule_state(wf)
|
||||
storage.save_workflow(wf)
|
||||
enriched = _enriched(wf)
|
||||
try:
|
||||
@@ -579,7 +585,7 @@ async def update_workflow(
|
||||
wf.updated_at = datetime.now()
|
||||
if not wf.icon:
|
||||
wf.icon = _derive_icon(wf)
|
||||
wf.next_run_at = scheduler.compute_next_fire(wf) if wf.schedule.enabled else None
|
||||
_normalize_schedule_state(wf)
|
||||
storage.save_workflow(wf)
|
||||
audit.log_change(wf.id, "user", before, wf.model_dump(mode="json"))
|
||||
scheduler.kick()
|
||||
@@ -748,7 +754,7 @@ async def commit_draft(workflow_id: str):
|
||||
wf.updated_at = datetime.now()
|
||||
if not wf.icon:
|
||||
wf.icon = _derive_icon(wf)
|
||||
wf.next_run_at = scheduler.compute_next_fire(wf) if wf.schedule.enabled else None
|
||||
_normalize_schedule_state(wf)
|
||||
await p_end_edit_session(wf)
|
||||
storage.save_workflow(wf)
|
||||
audit.log_change(wf.id, "user", before, wf.model_dump(mode="json"))
|
||||
|
||||
@@ -102,6 +102,44 @@ def test_dst_fall_back_no_double_fire():
|
||||
assert after.astimezone(tz).date() == datetime(2025, 11, 3).date()
|
||||
|
||||
|
||||
def test_unconfigured_weekly_schedule_has_no_next_fire():
|
||||
from backend.apps.workflows.models import ScheduleConfig
|
||||
from backend.apps.workflows.scheduler import _next_fire_after
|
||||
sched = ScheduleConfig(
|
||||
enabled=True,
|
||||
repeat_unit="week",
|
||||
repeat_every=1,
|
||||
on_days=[],
|
||||
hour=9,
|
||||
minute=0,
|
||||
timezone="America/Los_Angeles",
|
||||
)
|
||||
ref = datetime(2026, 6, 17, 8, 0, tzinfo=timezone.utc)
|
||||
assert _next_fire_after(sched, ref) is None
|
||||
|
||||
|
||||
def test_reconcile_disables_enabled_weekly_without_days():
|
||||
from backend.apps.workflows import storage, scheduler
|
||||
from backend.apps.workflows.models import ScheduleConfig
|
||||
wf = _make_wf(
|
||||
schedule=ScheduleConfig(
|
||||
enabled=True,
|
||||
repeat_unit="week",
|
||||
repeat_every=1,
|
||||
on_days=[],
|
||||
hour=9,
|
||||
minute=0,
|
||||
timezone="America/Los_Angeles",
|
||||
)
|
||||
)
|
||||
wf.next_run_at = datetime.now(timezone.utc) + timedelta(days=1)
|
||||
storage.save_workflow(wf)
|
||||
scheduler.reconcile_on_startup()
|
||||
after = storage.get_workflow(wf.id)
|
||||
assert after.schedule.enabled is False
|
||||
assert after.next_run_at is None
|
||||
|
||||
|
||||
# --- End condition tests -----------------------------------------------------
|
||||
|
||||
def test_max_runs_disables_schedule():
|
||||
|
||||
@@ -63,6 +63,7 @@ import { ContextPath } from '@/app/components/editor/DirectoryBrowser';
|
||||
import { setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, removeCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { setCardSidecar, commitDraft, updateWorkflowCard } from '@/shared/state/workflowsSlice';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { parseMcpToolName, getMcpInputSummary } from '@/shared/mcpToolMeta';
|
||||
|
||||
const CONTEXT_WINDOWS: Record<string, number> = {
|
||||
'opus-4-8': 1_000_000,
|
||||
@@ -1285,7 +1286,9 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
const c = p.call.content;
|
||||
const tool = typeof c === 'object' ? c.tool || '' : '';
|
||||
const input = typeof c === 'object' ? c.input : '';
|
||||
const summary = typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120);
|
||||
const mcp = parseMcpToolName(tool);
|
||||
const friendly = mcp.isMcp ? getMcpInputSummary(input, mcp.action, mcp.serverSlug) : '';
|
||||
const summary = friendly || (typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120));
|
||||
return { tool, input_summary: summary };
|
||||
});
|
||||
dispatch(generateGroupMeta({ sessionId: id, groupId: group.id, toolCalls }));
|
||||
@@ -1297,7 +1300,9 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
const c = p.call.content;
|
||||
const tool = typeof c === 'object' ? c.tool || '' : '';
|
||||
const input = typeof c === 'object' ? c.input : '';
|
||||
const summary = typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120);
|
||||
const mcp = parseMcpToolName(tool);
|
||||
const friendly = mcp.isMcp ? getMcpInputSummary(input, mcp.action, mcp.serverSlug) : '';
|
||||
const summary = friendly || (typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120));
|
||||
return { tool, input_summary: summary };
|
||||
});
|
||||
const resultsSummary = group.pairs
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AgentMessage } from '@/shared/state/agentsSlice';
|
||||
import { prettyPath, prettyUrl, quoteQuery, bashCommandDetail } from './toolLabels';
|
||||
import { parseMcpToolName, getMcpInputSummary, getGmailHeader } from '@/shared/mcpToolMeta';
|
||||
import { parseMcpToolName, getMcpInputSummary, getGmailHeader, getWorkflowToolInputDisplay } from '@/shared/mcpToolMeta';
|
||||
|
||||
export function getToolData(call: AgentMessage) {
|
||||
const content = typeof call.content === 'object' ? call.content : {};
|
||||
@@ -19,7 +19,7 @@ export function isBashTool(name: string) {
|
||||
export function getInputSummary(toolName: string, input: any): string {
|
||||
try {
|
||||
const mcp = parseMcpToolName(toolName);
|
||||
if (mcp.isMcp) return getMcpInputSummary(input);
|
||||
if (mcp.isMcp) return getMcpInputSummary(input, mcp.action, mcp.serverSlug);
|
||||
|
||||
const n = toolName.toLowerCase();
|
||||
if (isBashTool(toolName)) {
|
||||
@@ -61,7 +61,10 @@ function formatMcpInputDisplay(input: any): string {
|
||||
export function formatInputDisplay(toolName: string, input: any): string {
|
||||
try {
|
||||
const mcp = parseMcpToolName(toolName);
|
||||
if (mcp.isMcp) return formatMcpInputDisplay(input);
|
||||
if (mcp.isMcp) {
|
||||
const workflowDisplay = getWorkflowToolInputDisplay(input, mcp.action, mcp.serverSlug);
|
||||
return workflowDisplay || formatMcpInputDisplay(input);
|
||||
}
|
||||
|
||||
const n = toolName.toLowerCase();
|
||||
if (isBashTool(toolName)) return input.command || '';
|
||||
|
||||
@@ -25,6 +25,7 @@ import { ApprovalRequest } from '@/shared/state/agentsSlice';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import { ToolDefinition } from '@/shared/state/toolsSlice';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { getMcpInputSummary as getSharedMcpInputSummary, getWorkflowToolInputDisplay, getWorkflowToolLabel } from '@/shared/mcpToolMeta';
|
||||
|
||||
interface IntegrationMeta {
|
||||
label: string;
|
||||
@@ -96,6 +97,9 @@ export function useMcpToolMeta(parsed: ParsedTool): McpToolMeta {
|
||||
);
|
||||
|
||||
if (!toolDef) {
|
||||
if (parsed.serverSlug === 'openswarm-schedule') {
|
||||
return { integration: null, description: '', serverLabel: 'Workflows' };
|
||||
}
|
||||
return { integration: null, description: '', serverLabel: parsed.serverSlug };
|
||||
}
|
||||
|
||||
@@ -110,6 +114,10 @@ export function useMcpToolMeta(parsed: ParsedTool): McpToolMeta {
|
||||
function getMcpInputSummary(actionName: string, toolInput: Record<string, any>): string {
|
||||
const lower = actionName.toLowerCase();
|
||||
|
||||
if (getWorkflowToolLabel(actionName)) {
|
||||
return getSharedMcpInputSummary(toolInput, actionName, 'openswarm-schedule');
|
||||
}
|
||||
|
||||
if (lower.includes('gmail') || lower.includes('email') || lower.includes('mail')) {
|
||||
const query = toolInput.query || toolInput.search_query || toolInput.q || '';
|
||||
const to = toolInput.to || toolInput.recipient || '';
|
||||
@@ -569,7 +577,12 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
|
||||
const meta = useMcpToolMeta(parsed);
|
||||
|
||||
const accentColor = meta.integration?.color || c.status.warning;
|
||||
const displayName = getWorkflowToolLabel(parsed.actionName) || parsed.displayName;
|
||||
const summary = parsed.isMcp ? getMcpInputSummary(parsed.actionName, request.tool_input) : '';
|
||||
const workflowDetails = parsed.isMcp
|
||||
? getWorkflowToolInputDisplay(request.tool_input, parsed.actionName, parsed.serverSlug)
|
||||
: '';
|
||||
const detailText = workflowDetails || JSON.stringify(request.tool_input, null, 2);
|
||||
const isSensitive = !!request.sensitive_pattern;
|
||||
|
||||
if (!parsed.isMcp) {
|
||||
@@ -744,7 +757,7 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.9rem' }}>
|
||||
{parsed.displayName}
|
||||
{displayName}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={meta.serverLabel || parsed.serverSlug}
|
||||
@@ -794,7 +807,6 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
|
||||
sx={{
|
||||
color: c.text.secondary,
|
||||
fontSize: '0.82rem',
|
||||
fontFamily: c.font.mono,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
@@ -812,7 +824,7 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
|
||||
<Collapse in={detailsExpanded || !summary}>
|
||||
<Box sx={{ mt: summary ? 0.75 : 0 }}>
|
||||
<CodeBlock tokens={c}>
|
||||
{JSON.stringify(request.tool_input, null, 2)}
|
||||
{detailText}
|
||||
</CodeBlock>
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { GoogleServiceIcon } from '../mcp-cards/GoogleServiceIcon';
|
||||
import { ElapsedTimer, formatElapsed } from '../parsing/toolBubbleChrome';
|
||||
import { useTermColors } from '../parsing/toolColorize';
|
||||
import { ParsedResult } from '../parsing/toolResultParsing';
|
||||
import { McpToolInfo, getMcpShortAction } from '@/shared/mcpToolMeta';
|
||||
import { McpToolInfo, getMcpShortAction, getMcpInputSummary, getWorkflowToolLabel } from '@/shared/mcpToolMeta';
|
||||
import { McpResultCard } from '../mcp-cards/McpResultCard';
|
||||
|
||||
interface CompactMcpBubbleProps {
|
||||
@@ -47,12 +47,17 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
|
||||
const c = useClaudeTokens();
|
||||
const tc = useTermColors();
|
||||
|
||||
const shortAction = mcpInfo.isMcp ? getMcpShortAction(mcpInfo) : toolName;
|
||||
const workflowLabel = mcpInfo.isMcp ? getWorkflowToolLabel(mcpInfo.action) : null;
|
||||
const shortAction = workflowLabel || (mcpInfo.isMcp ? getMcpShortAction(mcpInfo) : toolName);
|
||||
const mcpVerbLabel = (() => {
|
||||
if (workflowLabel) return workflowLabel;
|
||||
const lbl = getToolLabel(toolName, call.id);
|
||||
return result && !isDenied ? lbl.past : lbl.present;
|
||||
})();
|
||||
const serviceLabel = mcpInfo.isMcp ? mcpVerbLabel : shortAction;
|
||||
const inputSummary = mcpInfo.isMcp ? getMcpInputSummary(input, mcpInfo.action, mcpInfo.serverSlug) : '';
|
||||
const visibleSummary = resultSummary || inputSummary;
|
||||
const canToggleDetails = !!visibleSummary;
|
||||
const ServiceIcon = mcpInfo.isMcp && mcpInfo.service
|
||||
? <GoogleServiceIcon service={mcpInfo.service} size={14} />
|
||||
: null;
|
||||
@@ -60,16 +65,16 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
|
||||
return (
|
||||
<Box {...selectAttrs} sx={{ my: 0 }}>
|
||||
<Box
|
||||
onClick={toggle}
|
||||
onClick={canToggleDetails ? toggle : undefined}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: showBody ? 'flex-start' : 'center',
|
||||
alignItems: showBody && canToggleDetails ? 'flex-start' : 'center',
|
||||
gap: 0.75,
|
||||
px: 1.5,
|
||||
py: 0.6,
|
||||
cursor: 'pointer',
|
||||
borderBottom: showBody ? `1px solid ${c.border.subtle}` : 'none',
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.02)' },
|
||||
cursor: canToggleDetails ? 'pointer' : 'default',
|
||||
borderBottom: showBody && canToggleDetails ? `1px solid ${c.border.subtle}` : 'none',
|
||||
'&:hover': canToggleDetails ? { bgcolor: 'rgba(0,0,0,0.02)' } : undefined,
|
||||
}}
|
||||
>
|
||||
{ServiceIcon}
|
||||
@@ -83,22 +88,22 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
|
||||
>
|
||||
{serviceLabel}
|
||||
</Typography>
|
||||
{resultSummary && !isError && (
|
||||
{visibleSummary && !isError && (
|
||||
<Typography
|
||||
sx={{
|
||||
color: c.text.secondary,
|
||||
fontSize: '0.74rem',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
...(showBody
|
||||
...(showBody && canToggleDetails
|
||||
? { whiteSpace: 'normal', wordBreak: 'break-word' }
|
||||
: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }),
|
||||
}}
|
||||
>
|
||||
{resultSummary}
|
||||
{visibleSummary}
|
||||
</Typography>
|
||||
)}
|
||||
{!resultSummary && !showTimer && <Box sx={{ flex: 1 }} />}
|
||||
{!visibleSummary && !showTimer && <Box sx={{ flex: 1 }} />}
|
||||
{showTimer && (
|
||||
<>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
@@ -123,12 +128,14 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{canToggleDetails && (
|
||||
<IconButton size="small" sx={{ color: c.text.tertiary, p: 0.15, flexShrink: 0 }}>
|
||||
{showBody ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Collapse in={showBody}>
|
||||
<Collapse in={showBody && canToggleDetails}>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: tc.TERM_BG,
|
||||
|
||||
@@ -61,6 +61,7 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
|
||||
// already on screen. mcpCompact rows opt out (the group's row-fade handles them).
|
||||
const reveal = useMountReveal();
|
||||
const enterStyle = (!mcpCompact && !suppressReveal) ? reveal : {};
|
||||
const canToggleDetails = !!inputSummary && !isStreaming;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -89,16 +90,16 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
|
||||
} as any}
|
||||
>
|
||||
<Box
|
||||
onClick={toggle}
|
||||
onClick={canToggleDetails ? toggle : undefined}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
px: 1.5,
|
||||
py: mcpCompact ? 0.6 : 0.75,
|
||||
cursor: isStreaming ? 'default' : 'pointer',
|
||||
borderBottom: mcpCompact && showBody ? `1px solid ${c.border.subtle}` : 'none',
|
||||
'&:hover': isStreaming ? {} : { bgcolor: 'rgba(0,0,0,0.02)' },
|
||||
cursor: canToggleDetails ? 'pointer' : 'default',
|
||||
borderBottom: mcpCompact && showBody && canToggleDetails ? `1px solid ${c.border.subtle}` : 'none',
|
||||
'&:hover': canToggleDetails ? { bgcolor: 'rgba(0,0,0,0.02)' } : {},
|
||||
}}
|
||||
>
|
||||
{mcpInfo.isMcp && mcpInfo.service
|
||||
@@ -186,7 +187,7 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
|
||||
)}
|
||||
{showTimer && <ElapsedTimer startTime={call.timestamp} />}
|
||||
|
||||
{!isStreaming && (
|
||||
{canToggleDetails && (
|
||||
<IconButton size="small" sx={{ color: c.text.tertiary, p: mcpCompact ? 0.15 : 0.25, flexShrink: 0 }}>
|
||||
{showBody ? (
|
||||
<ExpandLessIcon sx={{ fontSize: mcpCompact ? 16 : 18 }} />
|
||||
@@ -197,7 +198,7 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Collapse in={showBody}>
|
||||
<Collapse in={showBody && canToggleDetails}>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: tc.TERM_BG,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AgentMessage, ToolGroupMeta } from '@/shared/state/agentsSlice';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useMountReveal } from './useMountReveal';
|
||||
import { sanitizeSvgString } from '@/shared/sanitizeSvg';
|
||||
import { parseMcpToolName, getWorkflowToolLabel } from '@/shared/mcpToolMeta';
|
||||
import ToolCallBubble, { ToolPair } from './ToolCallBubble';
|
||||
|
||||
export interface ToolGroup {
|
||||
@@ -83,13 +84,21 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
|
||||
).length;
|
||||
const allDone = pendingCount === 0 || !isSessionRunning;
|
||||
|
||||
const displayName = meta?.name || group.label;
|
||||
const hasSvg = !!meta?.svg;
|
||||
|
||||
const toolNames = group.pairs.map((p) => {
|
||||
const c2 = typeof p.call.content === 'object' ? p.call.content : {};
|
||||
return c2.tool || 'unknown';
|
||||
});
|
||||
const workflowGroupLabel = (() => {
|
||||
if (group.mcpServer !== 'openswarm-schedule') return null;
|
||||
const parsedLabels = Array.from(new Set(toolNames.map((name) => {
|
||||
const parsed = parseMcpToolName(name);
|
||||
return parsed.isMcp ? getWorkflowToolLabel(parsed.action) : null;
|
||||
}).filter(Boolean))) as string[];
|
||||
return parsedLabels.length === 1 ? parsedLabels[0] : 'Workflow actions';
|
||||
})();
|
||||
const displayName = workflowGroupLabel || meta?.name || group.label;
|
||||
const hasSvg = !!meta?.svg && !workflowGroupLabel;
|
||||
const canToggleGroup = group.pairs.length > 1;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -116,15 +125,15 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
onClick={canToggleGroup ? () => setExpanded(!expanded) : undefined}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
px: 1.5,
|
||||
py: 0.7,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.02)' },
|
||||
cursor: canToggleGroup ? 'pointer' : 'default',
|
||||
'&:hover': canToggleGroup ? { bgcolor: 'rgba(0,0,0,0.02)' } : undefined,
|
||||
}}
|
||||
>
|
||||
{!meta ? (
|
||||
@@ -171,9 +180,11 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
|
||||
{completedCount}/{group.callCount}
|
||||
</Typography>
|
||||
)}
|
||||
{canToggleGroup && (
|
||||
<IconButton size="small" sx={{ color: c.text.tertiary, p: 0.15 }}>
|
||||
{expanded ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
|
||||
@@ -41,7 +41,7 @@ import { useDashboardActive } from '@/shared/hooks/useDashboardActive';
|
||||
import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough';
|
||||
import { useStreamingMessage } from '@/shared/state/streamingSlice';
|
||||
import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canvasInteractionState';
|
||||
import { createWorkflow, openWorkflowCard, setCardSidecar, type Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { openWorkflowCard, setCardSidecar, type Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { addWorkflowCard, setWorkflowCardPosition, setWorkflowCardSize } from '@/shared/state/dashboardLayoutSlice';
|
||||
import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined';
|
||||
import { getAgentWorkTime, fmtSeconds } from '@/shared/agentWorkTime';
|
||||
@@ -911,42 +911,34 @@ const AgentCard: React.FC<Props> = ({
|
||||
<Tooltip title="Turn this chat into a reusable, schedulable workflow">
|
||||
<Box
|
||||
role="button"
|
||||
onClick={async (e) => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (converting) return;
|
||||
const steps = extractStepsFromSession(session);
|
||||
if (steps.length === 0) return;
|
||||
setConverting(true);
|
||||
// Persist up front while the chat card stays put, then
|
||||
// swap this card's slot to the saved workflow. No preview
|
||||
// interstitial: the steps already exist, so the saved card
|
||||
// (with its one-time schedule nudge) is all we need. On a
|
||||
// failed create the chat card stays so the user can retry.
|
||||
const result = await dispatch(createWorkflow({
|
||||
title: session.name || 'New workflow',
|
||||
description: '',
|
||||
steps,
|
||||
source_session_id: session.id,
|
||||
use_synced_prompt: true,
|
||||
model: defaultModel || session.model,
|
||||
mode: defaultMode || session.mode,
|
||||
} as Partial<Workflow>));
|
||||
if (!createWorkflow.fulfilled.match(result)) {
|
||||
setConverting(false);
|
||||
return;
|
||||
}
|
||||
const wf = result.payload;
|
||||
// The OG chat card BECOMES the workflow card: same slot,
|
||||
// same size, no tether arrow (Image #61 / #62). The chat
|
||||
// session stays accessible via History.
|
||||
dispatch(addWorkflowCard({ workflowId: wf.id, sourceSessionId: null, expandedSessionIds }));
|
||||
dispatch(setWorkflowCardPosition({ workflowId: wf.id, x: cardX, y: cardY }));
|
||||
dispatch(setWorkflowCardSize({ workflowId: wf.id, width: cardWidth, height: cardHeight }));
|
||||
const draftId = `draft-${session.id}-${Date.now()}`;
|
||||
// The chat card becomes a temporary workflow draft in the
|
||||
// same slot. Nothing is persisted until the user chooses
|
||||
// Save Draft or Schedule Workflow from the draft card.
|
||||
dispatch(addWorkflowCard({ workflowId: draftId, sourceSessionId: session.id, expandedSessionIds }));
|
||||
dispatch(setWorkflowCardPosition({ workflowId: draftId, x: cardX, y: cardY }));
|
||||
dispatch(setWorkflowCardSize({ workflowId: draftId, width: cardWidth, height: cardHeight }));
|
||||
dispatch(removeCard(session.id));
|
||||
// showScheduleNudge: the one-shot "Schedule this workflow?"
|
||||
// prompt. "Not now" on it reopens this very chat (the
|
||||
// session lives on via workflow.source_session_id).
|
||||
dispatch(openWorkflowCard({ workflowId: wf.id, sourceSessionId: null, view: 'saved', draft: null, showScheduleNudge: true }));
|
||||
dispatch(openWorkflowCard({
|
||||
workflowId: draftId,
|
||||
sourceSessionId: session.id,
|
||||
view: 'preview',
|
||||
draft: {
|
||||
title: session.name || 'New workflow',
|
||||
description: '',
|
||||
steps,
|
||||
source_session_id: session.id,
|
||||
use_synced_prompt: true,
|
||||
model: defaultModel || session.model,
|
||||
mode: defaultMode || session.mode,
|
||||
} as Partial<Workflow>,
|
||||
}));
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
sx={{
|
||||
|
||||
@@ -3,12 +3,10 @@ import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import CalendarMonthRounded from '@mui/icons-material/CalendarMonthRounded';
|
||||
import TuneRoundedIcon from '@mui/icons-material/TuneRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { openWorkflowCard, updateWorkflow, type Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { describeSchedule } from './scheduleUtils';
|
||||
import { openWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice';
|
||||
|
||||
interface Props {
|
||||
anchorEl: HTMLElement | null;
|
||||
@@ -16,29 +14,13 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Opens off an Un-scheduled workflow's "+" icon. Two paths: keep the cadence
|
||||
// the workflow already carries (just flip enabled on) or open the scheduler
|
||||
// to change it. Enabling moves the row into "Scheduled workflows" since
|
||||
// isSchedulable keys off schedule.enabled.
|
||||
// Opens off an Unscheduled workflow's "+" icon. These workflows have no
|
||||
// real cadence yet, so the only safe action is to create one.
|
||||
export default function AddToSchedulePopover({ anchorEl, workflow, onClose }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// describeSchedule returns "Not scheduled" while disabled; preview the
|
||||
// cadence as if it were on so "Keep" shows what it would commit to.
|
||||
const summary = workflow ? describeSchedule({ ...workflow.schedule, enabled: true }) : '';
|
||||
|
||||
const keep = useCallback(() => {
|
||||
if (!workflow) return;
|
||||
dispatch(updateWorkflow({
|
||||
id: workflow.id,
|
||||
patch: { schedule: { ...workflow.schedule, enabled: true } as any },
|
||||
ifMatch: workflow.updated_at || null,
|
||||
}));
|
||||
onClose();
|
||||
}, [dispatch, workflow, onClose]);
|
||||
|
||||
const change = useCallback(() => {
|
||||
const makeSchedule = useCallback(() => {
|
||||
if (!workflow) return;
|
||||
dispatch(addWorkflowCard({ workflowId: workflow.id }));
|
||||
dispatch(openWorkflowCard({ workflowId: workflow.id, view: 'scheduling' }));
|
||||
@@ -66,20 +48,13 @@ export default function AddToSchedulePopover({ anchorEl, workflow, onClose }: Pr
|
||||
slotProps={{ paper: { sx: { width: 272, p: 1, ml: 0.75 } } }}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', px: 0.75, mb: 0.5 }}>
|
||||
ADD TO SCHEDULE
|
||||
NEEDS SCHEDULE
|
||||
</Typography>
|
||||
<Box role="button" onClick={keep} sx={rowSx}>
|
||||
<Box role="button" onClick={makeSchedule} sx={rowSx}>
|
||||
<Box sx={iconSx}><CalendarMonthRounded sx={{ fontSize: 16 }} /></Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.primary }}>Keep this schedule</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{summary}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box role="button" onClick={change} sx={rowSx}>
|
||||
<Box sx={iconSx}><TuneRoundedIcon sx={{ fontSize: 16 }} /></Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.primary }}>Change schedule…</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>Pick a different time</Typography>
|
||||
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.primary }}>Make a schedule</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>This workflow does not have a schedule yet. Choose when it should run.</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Popover>
|
||||
|
||||
@@ -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_FULL, 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, isScheduleActive } from './scheduleUtils';
|
||||
|
||||
interface Props {
|
||||
view: 'Week' | 'Month' | 'List';
|
||||
@@ -88,7 +88,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
const end = addDays(start, range - 1);
|
||||
const map = new Map<string, { workflow: Workflow; date: Date }[]>();
|
||||
for (const wf of workflows) {
|
||||
if (!wf.schedule.enabled) continue;
|
||||
if (!isScheduleActive(wf.schedule)) continue;
|
||||
const fires = fireTimesWithin(wf, start, end, 60);
|
||||
for (const d of fires) {
|
||||
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||
@@ -268,7 +268,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.lg}px`, overflow: 'hidden', bgcolor: c.bg.surface }}>
|
||||
{upcoming.length === 0 && (
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.muted, textAlign: 'center', py: 3 }}>No scheduled workflows</Typography>
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.muted, textAlign: 'center', py: 3 }}>No scheduled</Typography>
|
||||
)}
|
||||
{upcoming.map(({ date, events, isToday }, rowIdx) => (
|
||||
<Box
|
||||
|
||||
@@ -14,7 +14,7 @@ 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';
|
||||
import { WEEKDAY_LABEL, formatTime } from './scheduleUtils';
|
||||
import { WEEKDAY_LABEL, formatTime, isScheduleActive } from './scheduleUtils';
|
||||
import { nextTierAfter } from './permissionsUtils';
|
||||
import { BODY_FS, LABEL_FS, HINT_FS, INPUT_FS } from './workflowEditCommon';
|
||||
|
||||
@@ -51,7 +51,7 @@ function lastDayOfMonthFE(year: number, monthZeroBased: number): number {
|
||||
// clock. Honors ends_at + max_runs so the "Next run" line doesn't lie
|
||||
// after the schedule has expired.
|
||||
function previewNextRun(sched: ScheduleConfig): Date | null {
|
||||
if (!sched.enabled) return null;
|
||||
if (!isScheduleActive(sched)) return null;
|
||||
const now = new Date();
|
||||
if (sched.ends_at) {
|
||||
const ends = new Date(sched.ends_at);
|
||||
|
||||
@@ -203,6 +203,7 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
const title = workflow?.title || card?.draft?.title || 'Workflow';
|
||||
const isDraft = card?.view === 'preview' && !workflow;
|
||||
const steps = (workflow?.steps || card?.draft?.steps || []) as Workflow['steps'];
|
||||
const [draftCloseNonce, setDraftCloseNonce] = useState(0);
|
||||
|
||||
// Edit-agent chrome lives in the card header: the model/time subtitle.
|
||||
// EditAgentView owns the live session and reports its id up here so the
|
||||
@@ -363,10 +364,30 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [computeResize, dispatch, workflowId]);
|
||||
|
||||
// X just hides the card. Schedule keeps firing in the background; the
|
||||
// user can re-open from the Workflows hub. A confirm dialog here was
|
||||
// more friction than value (users clicked through it without reading).
|
||||
const discardDraft = useCallback(() => {
|
||||
const sourceId = card?.sourceSessionId || (card?.draft?.source_session_id as string | undefined) || null;
|
||||
dispatch(closeWorkflowCard(workflowId));
|
||||
dispatch(removeWorkflowCard(workflowId));
|
||||
if (sourceId) {
|
||||
dispatch(placeCard({
|
||||
sessionId: sourceId,
|
||||
x: cardX,
|
||||
y: cardY,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
expandedSessionIds,
|
||||
}));
|
||||
dispatch(setPendingFocusAgentId(sourceId));
|
||||
}
|
||||
}, [card?.sourceSessionId, card?.draft, dispatch, workflowId, cardX, cardY, cardWidth, cardHeight, expandedSessionIds]);
|
||||
|
||||
// X hides saved cards, but temporary converted workflow drafts need an
|
||||
// explicit save/discard choice because they do not exist server-side yet.
|
||||
const onClose = useCallback(() => {
|
||||
if (isDraft) {
|
||||
setDraftCloseNonce((n) => n + 1);
|
||||
return;
|
||||
}
|
||||
// A 0-step workflow can't run or be scheduled, so a "+ New" card the user
|
||||
// opened and abandoned (without the build agent adding any steps) would
|
||||
// just litter the hub. Delete it on close rather than orphan it.
|
||||
@@ -375,7 +396,7 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
}
|
||||
dispatch(closeWorkflowCard(workflowId));
|
||||
dispatch(removeWorkflowCard(workflowId));
|
||||
}, [dispatch, workflowId, workflow]);
|
||||
}, [dispatch, workflowId, workflow, isDraft]);
|
||||
|
||||
// ---- Display calculations ----
|
||||
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
|
||||
@@ -619,15 +640,22 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
steps={steps}
|
||||
sourceSessionId={card.sourceSessionId || null}
|
||||
initialDraft={card.draft || null}
|
||||
onSaved={(wf) => {
|
||||
closeRequestNonce={draftCloseNonce}
|
||||
onDiscardDraft={discardDraft}
|
||||
onSaved={(wf, options) => {
|
||||
// Migrate transient view state AND layout entry to the
|
||||
// real workflow id so the card stays put visually.
|
||||
dispatch(rekeyOpenCard({ oldId: workflowId, newId: wf.id }));
|
||||
dispatch(rekeyWorkflowCard({ oldId: workflowId, newId: wf.id }));
|
||||
if (options?.close) {
|
||||
dispatch(closeWorkflowCard(wf.id));
|
||||
dispatch(removeWorkflowCard(wf.id));
|
||||
return;
|
||||
}
|
||||
dispatch(openWorkflowCardAction({
|
||||
workflowId: wf.id,
|
||||
sourceSessionId: card.sourceSessionId,
|
||||
view: 'saved',
|
||||
view: options?.view || 'saved',
|
||||
draft: null,
|
||||
}));
|
||||
}}
|
||||
|
||||
@@ -24,11 +24,23 @@ import {
|
||||
type Workflow,
|
||||
type WorkflowRun,
|
||||
} from '@/shared/state/workflowsSlice';
|
||||
import { DEFAULT_CARD_W, DEFAULT_CARD_H, placeCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { DEFAULT_CARD_W, DEFAULT_CARD_H, placeCard, removeCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
|
||||
import { fetchSession } from '@/shared/state/agentsSlice';
|
||||
import { fetchSession, closeSession, collapseSession } from '@/shared/state/agentsSlice';
|
||||
import type { AppDispatch } from '@/shared/state/store';
|
||||
import StepList, { type StepStatus } from './StepList';
|
||||
|
||||
// Unlink the sidecar AND close the chat card it opened. closeSession is
|
||||
// what makes removal stick: a bare removeCard gets re-added by
|
||||
// reconcileSessions since the run session shares the dashboard.
|
||||
function stopViewingSidecar(dispatch: AppDispatch, workflowId: string, sessionId: string | null | undefined) {
|
||||
dispatch(setCardSidecar({ workflowId, sessionId: null, kind: null }));
|
||||
if (!sessionId) return;
|
||||
dispatch(collapseSession(sessionId));
|
||||
dispatch(removeCard(sessionId));
|
||||
void dispatch(closeSession({ sessionId }));
|
||||
}
|
||||
|
||||
// Helper: open a session next to the workflow card AND mark the card as
|
||||
// sidecar-linked so the footer flips to Stop Watching/Viewing and the
|
||||
// dashboard draws an arrow chip between the two cards.
|
||||
@@ -287,8 +299,8 @@ export function CompletedView({ workflow, steps, runs, mode = 'card' }: {
|
||||
if (run?.session_id) void openSidecar(run.session_id, 'viewing-completed');
|
||||
}, [openSidecar, run?.session_id]);
|
||||
const onStopViewing = useCallback(() => {
|
||||
dispatch(setCardSidecar({ workflowId: workflow.id, sessionId: null, kind: null }));
|
||||
}, [dispatch, workflow.id]);
|
||||
stopViewingSidecar(dispatch, workflow.id, card?.sidecarSessionId);
|
||||
}, [dispatch, workflow.id, card?.sidecarSessionId]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
@@ -391,8 +403,8 @@ export function FailedView({ workflow, steps, runs, mode = 'card' }: {
|
||||
if (run?.session_id) void openSidecar(run.session_id, 'viewing-error');
|
||||
}, [openSidecar, run?.session_id]);
|
||||
const onStopViewing = useCallback(() => {
|
||||
dispatch(setCardSidecar({ workflowId: workflow.id, sessionId: null, kind: null }));
|
||||
}, [dispatch, workflow.id]);
|
||||
stopViewingSidecar(dispatch, workflow.id, card?.sidecarSessionId);
|
||||
}, [dispatch, workflow.id, card?.sidecarSessionId]);
|
||||
const onFixWithAgent = useCallback(() => {
|
||||
if (!run) return;
|
||||
const stepLabel = steps[failedIdx]?.label || steps[failedIdx]?.text?.slice(0, 60) || `Step ${failedIdx + 1}`;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import HistoryIcon from '@mui/icons-material/HistoryToggleOffRounded';
|
||||
import CalendarMonthRounded from '@mui/icons-material/CalendarMonthRounded';
|
||||
import EditOutlined from '@mui/icons-material/EditOutlined';
|
||||
@@ -22,6 +26,7 @@ import { placeCard, removeWorkflowCard } from '@/shared/state/dashboardLayoutSli
|
||||
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
|
||||
import { CostChip, humanDuration, routingFor, StreakBadge } from './workflowVisuals';
|
||||
import StepList from './StepList';
|
||||
import { isScheduleConfigured } from './scheduleUtils';
|
||||
|
||||
export function statusColor(s: string, c: ReturnType<typeof useClaudeTokens>): string {
|
||||
if (s === 'success') return c.status.success;
|
||||
@@ -96,16 +101,19 @@ export function ActionBtn({ label, tone, disabled, onClick, icon }: { label: str
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, onSaved }: {
|
||||
export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, onSaved, onDiscardDraft, closeRequestNonce }: {
|
||||
workflowId: string;
|
||||
steps: Workflow['steps'];
|
||||
sourceSessionId: string | null;
|
||||
initialDraft: Partial<Workflow> | null;
|
||||
onSaved: (w: Workflow) => void;
|
||||
onSaved: (w: Workflow, options?: { view?: 'saved' | 'scheduling'; close?: boolean }) => void;
|
||||
onDiscardDraft?: () => void;
|
||||
closeRequestNonce?: number;
|
||||
}) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [savePromptOpen, setSavePromptOpen] = useState(false);
|
||||
// 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
|
||||
@@ -148,10 +156,10 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
dispatch(updateWorkflowCard({ workflowId, patch: { draft: { ...liveDraft, description: value } } }));
|
||||
}, [dispatch, workflowId, liveDraft]);
|
||||
|
||||
// Both buttons persist the workflow; the only difference is where they land.
|
||||
// "Not now" = save and show the saved card. Schedule = save then open the
|
||||
// natural-language scheduling composer. (The schedule prompt is optional,
|
||||
// the workflow isn't, so neither button discards anything.)
|
||||
useEffect(() => {
|
||||
if (closeRequestNonce) setSavePromptOpen(true);
|
||||
}, [closeRequestNonce]);
|
||||
|
||||
const saveWorkflow = useCallback(async (): Promise<Workflow | null> => {
|
||||
const result = await dispatch(createWorkflow({
|
||||
title,
|
||||
@@ -165,26 +173,42 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
mode: defaultMode || (liveDraft.mode as string),
|
||||
} as Partial<Workflow>));
|
||||
const wf = (result as unknown as { payload: Workflow }).payload;
|
||||
if (wf?.id) { onSaved(wf); return wf; }
|
||||
if (wf?.id) return wf;
|
||||
return null;
|
||||
}, [dispatch, title, description, steps, sourceSessionId, onSaved, liveDraft, defaultModel, defaultMode]);
|
||||
}, [dispatch, title, description, steps, sourceSessionId, liveDraft, defaultModel, defaultMode]);
|
||||
|
||||
const onIgnore = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try { await saveWorkflow(); } finally { setBusy(false); }
|
||||
}, [busy, saveWorkflow]);
|
||||
setSavePromptOpen(true);
|
||||
}, [busy]);
|
||||
|
||||
const onSaveThenSchedule = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const wf = await saveWorkflow();
|
||||
if (wf?.id) dispatch(updateWorkflowCard({ workflowId: wf.id, patch: { view: 'scheduling' } }));
|
||||
if (wf?.id) onSaved(wf, { view: 'scheduling' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, saveWorkflow, dispatch]);
|
||||
}, [busy, saveWorkflow, onSaved]);
|
||||
|
||||
const onSaveDraft = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const wf = await saveWorkflow();
|
||||
if (wf?.id) onSaved(wf, { view: 'saved', close: true });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setSavePromptOpen(false);
|
||||
}
|
||||
}, [busy, saveWorkflow, onSaved]);
|
||||
|
||||
const onDontSave = useCallback(() => {
|
||||
setSavePromptOpen(false);
|
||||
onDiscardDraft?.();
|
||||
}, [onDiscardDraft]);
|
||||
|
||||
void onChangeDescription;
|
||||
return (
|
||||
@@ -242,6 +266,34 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
|
||||
Schedule Workflow
|
||||
</Box>
|
||||
</Box>
|
||||
<Dialog open={savePromptOpen} onClose={() => setSavePromptOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontSize: '1rem', fontWeight: 700 }}>Save workflow?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography sx={{ fontSize: '0.86rem', color: c.text.secondary }}>
|
||||
Save this workflow under Unscheduled. It will not run until you choose a schedule.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onDontSave}
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.status.error, cursor: busy ? 'wait' : 'pointer', px: 1, py: 0.5, opacity: busy ? 0.6 : 1 }}>
|
||||
Don't Save
|
||||
</Box>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={() => setSavePromptOpen(false)}
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.secondary, cursor: busy ? 'wait' : 'pointer', px: 1, py: 0.5, opacity: busy ? 0.6 : 1 }}>
|
||||
Cancel
|
||||
</Box>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onSaveDraft}
|
||||
sx={{ fontSize: '0.84rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary, borderRadius: 999, cursor: busy ? 'wait' : 'pointer', px: 1.5, py: 0.6, opacity: busy ? 0.6 : 1, '&:hover': { filter: 'brightness(1.06)' } }}>
|
||||
Save
|
||||
</Box>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -331,11 +383,12 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo
|
||||
}
|
||||
}, [dispatch, sourceId, sourceExists, wfCardPos, expandedSessionIds, workflow.id]);
|
||||
|
||||
const scheduleLine = workflow.schedule.enabled ? describeSchedule(workflow) : 'Schedule this workflow';
|
||||
const scheduleClickable = !workflow.schedule.enabled;
|
||||
const scheduleConfigured = isScheduleConfigured(workflow.schedule);
|
||||
const scheduleLine = workflow.schedule.enabled && scheduleConfigured ? describeSchedule(workflow) : 'Schedule this workflow';
|
||||
const scheduleClickable = !scheduleConfigured;
|
||||
// One-shot prompt right after a convert; hub-opened cards never set the flag,
|
||||
// so they fall straight to the quiet schedule line below.
|
||||
const showNudge = !!card?.showScheduleNudge && !workflow.schedule.enabled;
|
||||
const showNudge = !!card?.showScheduleNudge && !scheduleConfigured;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
|
||||
@@ -28,7 +28,7 @@ import Tooltip from '@mui/material/Tooltip';
|
||||
import { useEffect } from 'react';
|
||||
import ScheduleCalendar from './ScheduleCalendar';
|
||||
import AddToSchedulePopover from './AddToSchedulePopover';
|
||||
import { WEEKDAY_LABEL, addDays, sameDay, startOfMonthGrid } from './scheduleUtils';
|
||||
import { WEEKDAY_LABEL, addDays, sameDay, startOfMonthGrid, isWorkflowSchedulable } from './scheduleUtils';
|
||||
|
||||
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
|
||||
|
||||
@@ -149,8 +149,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
// consistent. closeMenu wipes both state + DOM-focus.
|
||||
const [sidebarCtxMenu, setSidebarCtxMenu] = useState<{ x: number; y: number; workflow: Workflow } | null>(null);
|
||||
const closeSidebarCtxMenu = useCallback(() => setSidebarCtxMenu(null), []);
|
||||
// Anchored off an Un-scheduled row's "+" icon: offers keep-this-cadence vs
|
||||
// open-the-scheduler, then enabling moves the row into Scheduled.
|
||||
// Anchored off an Unscheduled row's "+" icon: opens the scheduler.
|
||||
const [schedulePopover, setSchedulePopover] = useState<{ anchorEl: HTMLElement; workflow: Workflow } | null>(null);
|
||||
const closeSchedulePopover = useCallback(() => setSchedulePopover(null), []);
|
||||
|
||||
@@ -163,8 +162,8 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
// Hide brand-new "+ New" workflows that the user is still building and
|
||||
// hasn't saved yet; commit (Save) clears `unsaved` and they appear.
|
||||
const saved = useMemo(() => Object.values(workflows).filter((w) => !w.unsaved), [workflows]);
|
||||
const scheduled = useMemo(() => saved.filter((w) => isSchedulable(w)), [saved]);
|
||||
const unscheduled = useMemo(() => saved.filter((w) => !isSchedulable(w)), [saved]);
|
||||
const scheduled = useMemo(() => saved.filter((w) => isWorkflowSchedulable(w)), [saved]);
|
||||
const unscheduled = useMemo(() => saved.filter((w) => !isWorkflowSchedulable(w)), [saved]);
|
||||
|
||||
const monthLabel = refDate.toLocaleString('en', { month: 'long', year: 'numeric' });
|
||||
|
||||
@@ -501,8 +500,8 @@ 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 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 })} onSchedule={(wf, el) => setSchedulePopover({ anchorEl: el, workflow: wf })} />
|
||||
<SidebarSection title="Scheduled" 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="Unscheduled" items={unscheduled.filter((w) => match(w.title, search))} onPick={onSelectWorkflow} scheduled={false} onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} onSchedule={(wf, el) => setSchedulePopover({ anchorEl: el, workflow: wf })} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
@@ -524,6 +523,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
dispatch(runWorkflowNow(sidebarCtxMenu.workflow.id));
|
||||
closeSidebarCtxMenu();
|
||||
}}>Run now</MenuItem>
|
||||
{sidebarCtxMenu && isWorkflowSchedulable(sidebarCtxMenu.workflow) && (
|
||||
<MenuItem onClick={() => {
|
||||
if (!sidebarCtxMenu) return;
|
||||
const wf = sidebarCtxMenu.workflow;
|
||||
@@ -533,7 +533,8 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
ifMatch: wf.updated_at || null,
|
||||
}));
|
||||
closeSidebarCtxMenu();
|
||||
}}>{sidebarCtxMenu?.workflow.schedule.enabled ? 'Pause schedule' : 'Resume schedule'}</MenuItem>
|
||||
}}>{sidebarCtxMenu.workflow.schedule.enabled ? 'Pause schedule' : 'Resume schedule'}</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={() => {
|
||||
if (!sidebarCtxMenu) return;
|
||||
dispatch(addWorkflowCard({ workflowId: sidebarCtxMenu.workflow.id }));
|
||||
@@ -552,7 +553,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* "+" on an Un-scheduled row -> keep cadence or open the scheduler */}
|
||||
{/* "+" on an Unscheduled row -> open the scheduler */}
|
||||
<AddToSchedulePopover
|
||||
anchorEl={schedulePopover?.anchorEl ?? null}
|
||||
workflow={schedulePopover?.workflow ?? null}
|
||||
@@ -612,8 +613,8 @@ function SidebarSection({ title, items, onPick, scheduled, onContext, onSchedule
|
||||
onPick: (id: string) => void;
|
||||
scheduled: boolean;
|
||||
onContext: (workflow: Workflow, e: React.MouseEvent) => void;
|
||||
// Only the Un-scheduled section wires this: clicking the "+" opens the
|
||||
// add-to-schedule popover anchored to the icon.
|
||||
// Only the Unscheduled section wires this: clicking the "+" opens the
|
||||
// schedule creation popover anchored to the icon.
|
||||
onSchedule?: (workflow: Workflow, anchorEl: HTMLElement) => void;
|
||||
}) {
|
||||
const c = useClaudeTokens();
|
||||
@@ -679,22 +680,16 @@ function SidebarSection({ title, items, onPick, scheduled, onContext, onSchedule
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
<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>
|
||||
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: scheduled && !w.schedule.enabled ? 0.7 : 1 }}>{w.title}</Typography>
|
||||
{scheduled && !w.schedule.enabled && (
|
||||
<Box sx={{ flexShrink: 0, px: 0.6, py: 0.1, borderRadius: '3px', bgcolor: c.bg.elevated, color: c.text.muted, fontSize: '0.62rem', fontWeight: 600, lineHeight: 1.5, letterSpacing: '0.02em' }}>Paused</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</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());
|
||||
|
||||
@@ -25,6 +25,20 @@ export function defaultSchedule(): ScheduleConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export function isScheduleConfigured(sched: ScheduleConfig | null | undefined): boolean {
|
||||
if (!sched) return false;
|
||||
if (sched.repeat_unit === 'week') return sched.on_days.length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isScheduleActive(sched: ScheduleConfig | null | undefined): boolean {
|
||||
return !!sched?.enabled && isScheduleConfigured(sched);
|
||||
}
|
||||
|
||||
export function isWorkflowSchedulable(workflow: Workflow): boolean {
|
||||
return isScheduleConfigured(workflow.schedule);
|
||||
}
|
||||
|
||||
export function formatTime(hour: number, minute: number): string {
|
||||
const h12 = ((hour + 11) % 12) + 1;
|
||||
const suffix = hour < 12 ? 'am' : 'pm';
|
||||
@@ -41,7 +55,7 @@ export function formatHourLabel(hour: number): string {
|
||||
}
|
||||
|
||||
export function describeSchedule(sched: ScheduleConfig): string {
|
||||
if (!sched.enabled) return 'Not scheduled';
|
||||
if (!sched.enabled || !isScheduleConfigured(sched)) return 'Not scheduled';
|
||||
const time = formatTime(sched.hour, sched.minute);
|
||||
if (sched.repeat_unit === 'minute') {
|
||||
return `Every ${sched.repeat_every} minutes`;
|
||||
@@ -109,7 +123,7 @@ function lastDayOfMonth(year: number, monthZeroBased: number): number {
|
||||
|
||||
export function fireTimesWithin(workflow: Workflow, from: Date, to: Date, cap = 40): Date[] {
|
||||
const sched = workflow.schedule;
|
||||
if (!sched.enabled) return [];
|
||||
if (!isScheduleActive(sched)) return [];
|
||||
// Honor end conditions on the FE preview too, so the calendar doesn't
|
||||
// paint pills for fires the backend will refuse to run. ends_at is an
|
||||
// ISO string in workflow state; max_runs/runs_count are numbers.
|
||||
@@ -185,7 +199,8 @@ export function fireTimesWithin(workflow: Workflow, from: Date, to: Date, cap =
|
||||
return out;
|
||||
}
|
||||
|
||||
const allowed = sched.on_days.length ? sched.on_days : [from.getDay()];
|
||||
const allowed = sched.on_days;
|
||||
if (allowed.length === 0) return [];
|
||||
for (let i = 0; i < 60 && out.length < effectiveCap; i += 1) {
|
||||
const day = new Date(cursor);
|
||||
day.setDate(day.getDate() + i);
|
||||
|
||||
@@ -29,7 +29,7 @@ import CodeIcon from '@mui/icons-material/CodeRounded';
|
||||
import SearchIcon from '@mui/icons-material/SearchRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import type { Workflow, WorkflowRun, ScheduleConfig, PermissionTier } from '@/shared/state/workflowsSlice';
|
||||
import { formatTime, WEEKDAY_LABEL } from './scheduleUtils';
|
||||
import { formatTime, WEEKDAY_LABEL, isScheduleConfigured } from './scheduleUtils';
|
||||
|
||||
// ---------- Status colors ----------
|
||||
|
||||
@@ -82,7 +82,7 @@ export function StatusDot({ status }: { status: LastRunStatus | null | undefined
|
||||
// ---------- Pill chips ----------
|
||||
|
||||
function scheduleShort(sched: ScheduleConfig): string {
|
||||
if (!sched.enabled) return 'Not scheduled';
|
||||
if (!sched.enabled || !isScheduleConfigured(sched)) return 'Not scheduled';
|
||||
const time = formatTime(sched.hour, sched.minute);
|
||||
if (sched.repeat_unit === 'minute') return `Every ${sched.repeat_every}m`;
|
||||
if (sched.repeat_unit === 'hour') return sched.repeat_every === 1 ? 'Hourly' : `Every ${sched.repeat_every}h`;
|
||||
@@ -98,7 +98,6 @@ function scheduleShort(sched: ScheduleConfig): string {
|
||||
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
return `${labels[sched.on_days[0]]} ${time}`;
|
||||
}
|
||||
if (sched.on_days.length === 0) return `Weekly ${time}`;
|
||||
return `${sched.on_days.length}×/wk ${time}`;
|
||||
}
|
||||
|
||||
@@ -170,7 +169,7 @@ export function PermissionChip({ workflow }: { workflow: Workflow }) {
|
||||
export function ScheduleChip({ workflow }: { workflow: Workflow }) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const enabled = workflow.schedule.enabled;
|
||||
const enabled = workflow.schedule.enabled && isScheduleConfigured(workflow.schedule);
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
// Inline edit: time + AM/PM only. Anything richer should open the
|
||||
// full editor. Saves on change with optimistic updated_at If-Match.
|
||||
|
||||
@@ -26,8 +26,149 @@ export function parseMcpToolName(rawName: string): McpToolInfo {
|
||||
return { isMcp: true, serverSlug, action, service, displayName: display };
|
||||
}
|
||||
|
||||
export function getMcpInputSummary(input: any): string {
|
||||
function formatTime(hour: unknown, minute: unknown): string {
|
||||
const h = typeof hour === 'number' ? hour : Number(hour);
|
||||
const m = typeof minute === 'number' ? minute : Number(minute || 0);
|
||||
if (!Number.isFinite(h)) return '';
|
||||
const h12 = ((h + 11) % 12) + 1;
|
||||
const suffix = h < 12 ? 'am' : 'pm';
|
||||
return Number.isFinite(m) && m > 0 ? `${h12}:${String(m).padStart(2, '0')}${suffix}` : `${h12}${suffix}`;
|
||||
}
|
||||
|
||||
function weekdayLabel(day: number): string {
|
||||
return ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][day] || '';
|
||||
}
|
||||
|
||||
function compactWorkflowSchedule(input: any): string {
|
||||
if (!input || typeof input !== 'object') return '';
|
||||
const enabled = input.schedule_enabled ?? input.enabled;
|
||||
if (enabled === false) return 'Turn schedule off';
|
||||
|
||||
const unit = input.repeat_unit || input.unit;
|
||||
const every = Math.max(1, Number(input.repeat_every || 1));
|
||||
const time = formatTime(input.hour, input.minute);
|
||||
const days = Array.isArray(input.on_days) ? input.on_days.filter((d: any) => Number.isInteger(d) && d >= 0 && d <= 6) : [];
|
||||
|
||||
if (unit === 'minute') return `Run every ${Math.max(15, Number(input.repeat_every || 15))} minutes`;
|
||||
if (unit === 'hour') {
|
||||
const minute = Number(input.minute || 0);
|
||||
const suffix = minute > 0 ? ` at :${String(minute).padStart(2, '0')}` : '';
|
||||
return every === 1 ? `Run every hour${suffix}` : `Run every ${every} hours${suffix}`;
|
||||
}
|
||||
if (unit === 'day') return `Run ${every === 1 ? 'daily' : `every ${every} days`}${time ? ` at ${time}` : ''}`;
|
||||
if (unit === 'month') return `Run ${every === 1 ? 'monthly' : `every ${every} months`}${time ? ` at ${time}` : ''}`;
|
||||
if (unit === 'week') {
|
||||
const dayText = days.length === 1
|
||||
? weekdayLabel(days[0])
|
||||
: days.length === 5 && [1, 2, 3, 4, 5].every((d) => days.includes(d))
|
||||
? 'weekdays'
|
||||
: days.length > 1
|
||||
? days.map(weekdayLabel).filter(Boolean).join(', ')
|
||||
: '';
|
||||
if (!dayText) return time ? `Choose weekly days at ${time}` : 'Choose weekly days';
|
||||
return `Run ${every === 1 ? `every ${dayText}` : `every ${every} weeks on ${dayText}`}${time ? ` at ${time}` : ''}`;
|
||||
}
|
||||
if (input.title) return `Update "${input.title}"`;
|
||||
return '';
|
||||
}
|
||||
|
||||
function firstText(input: any, keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = input?.[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function stepNumber(input: any): string {
|
||||
const raw = input?.step_idx ?? input?.step_index ?? input?.index;
|
||||
const idx = typeof raw === 'number' ? raw : Number(raw);
|
||||
return Number.isInteger(idx) && idx >= 0 ? String(idx + 1) : '';
|
||||
}
|
||||
|
||||
function compactWorkflowAction(input: any, action: string): string {
|
||||
const lower = action.toLowerCase();
|
||||
const step = stepNumber(input);
|
||||
const text = firstText(input, ['new_text', 'text', 'prompt', 'description']);
|
||||
const label = firstText(input, ['new_label', 'label', 'title', 'name']);
|
||||
const preview = text || label;
|
||||
const shortPreview = preview.length > 80 ? preview.slice(0, 77) + '...' : preview;
|
||||
|
||||
if (lower === 'addworkflowstep') return shortPreview ? `Add step: ${shortPreview}` : 'Add a workflow step';
|
||||
if (lower === 'editworkflowstep') return step ? `Edit step ${step}` : 'Edit workflow step';
|
||||
if (lower === 'deleteworkflowstep') return step ? `Delete step ${step}` : 'Delete workflow step';
|
||||
if (lower === 'runworkflow') return 'Run this workflow now';
|
||||
if (lower === 'testworkflow') return 'Test this workflow';
|
||||
if (lower === 'readtesttranscript') return 'Read latest test results';
|
||||
if (lower === 'deletescheduledworkflow') return 'Delete this workflow';
|
||||
if (lower === 'pauseallworkflows') return 'Pause all scheduled workflows';
|
||||
if (lower === 'resumeallworkflows') return 'Resume scheduled workflows';
|
||||
if (lower === 'listworkflows') return 'Show workflows';
|
||||
return '';
|
||||
}
|
||||
|
||||
export function getWorkflowToolLabel(action: string): string | null {
|
||||
const lower = action.toLowerCase();
|
||||
if (lower === 'scheduleworkflow') return 'Schedule workflow';
|
||||
if (lower === 'updatescheduledworkflow') return 'Update schedule';
|
||||
if (lower === 'deletescheduledworkflow') return 'Delete workflow';
|
||||
if (lower === 'pauseallworkflows') return 'Pause workflows';
|
||||
if (lower === 'resumeallworkflows') return 'Resume workflows';
|
||||
if (lower === 'runworkflow') return 'Run workflow';
|
||||
if (lower === 'editworkflowstep') return 'Edit workflow step';
|
||||
if (lower === 'addworkflowstep') return 'Add workflow step';
|
||||
if (lower === 'deleteworkflowstep') return 'Delete workflow step';
|
||||
if (lower === 'testworkflow') return 'Test workflow';
|
||||
if (lower === 'readtesttranscript') return 'Read test results';
|
||||
if (lower === 'listworkflows') return 'List workflows';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getWorkflowToolInputDisplay(input: any, action?: string, serverSlug?: string): string {
|
||||
if (!input || typeof input !== 'object') return '';
|
||||
const isWorkflowTool = serverSlug === 'openswarm-schedule' || (action && getWorkflowToolLabel(action));
|
||||
if (!isWorkflowTool || !action) return '';
|
||||
|
||||
const lower = action.toLowerCase();
|
||||
const lines: string[] = [];
|
||||
const schedule = compactWorkflowSchedule(input);
|
||||
const step = stepNumber(input);
|
||||
const text = firstText(input, ['new_text', 'text', 'prompt', 'description']);
|
||||
const label = firstText(input, ['new_label', 'label', 'title', 'name']);
|
||||
|
||||
if (lower === 'scheduleworkflow' || lower === 'updatescheduledworkflow') {
|
||||
if (schedule) lines.push(`Schedule: ${schedule}`);
|
||||
if (label) lines.push(`Name: ${label}`);
|
||||
return lines.join('\n') || getWorkflowToolLabel(action) || 'Workflow action';
|
||||
}
|
||||
|
||||
if (lower === 'addworkflowstep') {
|
||||
if (text) lines.push(`Step: ${text}`);
|
||||
if (label && label !== text) lines.push(`Label: ${label}`);
|
||||
return lines.join('\n') || compactWorkflowAction(input, action);
|
||||
}
|
||||
|
||||
if (lower === 'editworkflowstep') {
|
||||
if (step) lines.push(`Step: ${step}`);
|
||||
if (text) lines.push(`Prompt: ${text}`);
|
||||
if (label && label !== text) lines.push(`Label: ${label}`);
|
||||
return lines.join('\n') || compactWorkflowAction(input, action);
|
||||
}
|
||||
|
||||
return compactWorkflowAction(input, action) || getWorkflowToolLabel(action) || 'Workflow action';
|
||||
}
|
||||
|
||||
export function getMcpInputSummary(input: any, action?: string, serverSlug?: string): string {
|
||||
if (!input || typeof input !== 'object') return '';
|
||||
if (serverSlug === 'openswarm-schedule' || (action && getWorkflowToolLabel(action))) {
|
||||
const workflowSummary = compactWorkflowSchedule(input);
|
||||
if (workflowSummary) return workflowSummary;
|
||||
if (action) {
|
||||
const actionSummary = compactWorkflowAction(input, action);
|
||||
if (actionSummary) return actionSummary;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const keys = Object.keys(input);
|
||||
if (keys.length === 0) return '';
|
||||
if (keys.length === 1) {
|
||||
|
||||
Reference in New Issue
Block a user