mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-28 04:24:51 +02:00
[aidan] feat/scheduled-tasks: agent workflow scheduling and in-chat convert (#98)
* [aidan] feat/workflow-suggest: nudge user to convert repeatable chat to workflow Add SuggestConvertToWorkflow MCP tool that agents call at the end of a task when they've completed something worth repeating (daily report, weekly check, recurring data pull). Frontend detects the tool call and glows the "Convert to workflow" button 3 times to draw the eye. When user clicks it, the suggested cadence (e.g. "every weekday at 9am") is stored in the draft and seeded into the scheduling agent's first prompt, so the agent can act on the suggestion rather than asking the user again. Tool is never auto-called — agents decide when a task is genuinely repeatable (not debugging, creative work, one-off lookup). Tool description emphasizes sparse, high-confidence use only (once per session max). Files changed: - backend/apps/agents/schedule_mcp_server.py: add SuggestConvertToWorkflow tool - frontend/src/shared/mcpToolMeta.ts: add label for new tool - frontend/src/app/pages/Dashboard/cards/AgentCard.tsx: detect suggestion in session messages, show+glow "Convert to workflow" button, pass cadence to draft - frontend/src/shared/state/workflowsSlice.ts: add suggested_cadence field to Workflow interface - frontend/src/app/pages/Workflows/SchedulingView.tsx: seed scheduling agent prompt with suggested cadence hint * [aidan] feat/agent-scheduling: route recurring asks through native workflows, deny claude cron skill * [aidan] feat/workflow-convert: in-chat convert popup and auto-open scheduled workflow card
This commit is contained in:
@@ -244,6 +244,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
{/* Tether lines between branched cards */}
|
||||
<TetherLayer tethers={tethers} c={c} />
|
||||
<DashboardCardLayer
|
||||
dashboardId={dashboardId}
|
||||
cards={cards}
|
||||
viewCards={viewCards}
|
||||
browserCards={browserCards}
|
||||
|
||||
@@ -28,6 +28,7 @@ type GlowingAgentCard = { sourceId: string; fading: boolean; sourceYRatio?: numb
|
||||
type Direction = 'left' | 'right' | 'up' | 'down';
|
||||
|
||||
interface DashboardCardLayerProps {
|
||||
dashboardId: string;
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
@@ -64,6 +65,7 @@ interface DashboardCardLayerProps {
|
||||
}
|
||||
|
||||
const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
dashboardId,
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
@@ -265,6 +267,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
))}
|
||||
{workflowsHub && (
|
||||
<WorkflowsHubCard
|
||||
dashboardId={dashboardId}
|
||||
cardX={workflowsHub.x}
|
||||
cardY={workflowsHub.y}
|
||||
cardWidth={workflowsHub.width}
|
||||
|
||||
@@ -6,6 +6,8 @@ import IconButton from '@mui/material/IconButton';
|
||||
import Button from '@mui/material/Button';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Fade from '@mui/material/Fade';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
@@ -68,6 +70,93 @@ function extractStepsFromSession(session: { messages: Array<{ role: string; cont
|
||||
return out;
|
||||
}
|
||||
|
||||
function isWorkflowSuggestionTool(toolName: unknown, mcpServer?: unknown): boolean {
|
||||
const normalizedTool = String(toolName || '').toLowerCase();
|
||||
const normalizedServer = String(mcpServer || '').toLowerCase();
|
||||
if (!normalizedTool) return false;
|
||||
if (normalizedTool === 'suggestconverttoworkflow') return true;
|
||||
if (normalizedTool.endsWith('__suggestconverttoworkflow')) return true;
|
||||
return normalizedTool.includes('suggestconverttoworkflow') && (
|
||||
normalizedTool.includes('openswarm-schedule') ||
|
||||
normalizedServer.includes('openswarm-schedule')
|
||||
);
|
||||
}
|
||||
|
||||
function isScheduleWorkflowTool(toolName: unknown): boolean {
|
||||
const normalizedTool = String(toolName || '').toLowerCase();
|
||||
if (!normalizedTool) return false;
|
||||
return normalizedTool === 'scheduleworkflow' || normalizedTool.endsWith('__scheduleworkflow');
|
||||
}
|
||||
|
||||
function parseWorkflowSuggestion(text: unknown): { reason: string; cadence: string } | null {
|
||||
if (typeof text !== 'string' || !text.trim()) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (!parsed?.reason || typeof parsed.reason !== 'string') return null;
|
||||
return {
|
||||
reason: parsed.reason,
|
||||
cadence: typeof parsed.cadence === 'string'
|
||||
? parsed.cadence
|
||||
: (typeof parsed.suggested_cadence === 'string' ? parsed.suggested_cadence : ''),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseWorkflowSuggestionFromContent(content: any): { reason: string; cadence: string } | null {
|
||||
return parseWorkflowSuggestion(
|
||||
content?.text ??
|
||||
content?.content?.[0]?.text ??
|
||||
content?.result ??
|
||||
content?.output,
|
||||
);
|
||||
}
|
||||
|
||||
/** Detect if the session has a completed SuggestConvertToWorkflow tool call. */
|
||||
function findWorkflowSuggestion(session: AgentSession): { reason: string; cadence: string } | null {
|
||||
let found: { reason: string; cadence: string } | null = null;
|
||||
for (const msg of session.messages || []) {
|
||||
const msgAny = msg as any;
|
||||
const directContent = msgAny.content;
|
||||
if (msgAny.role === 'tool_result') {
|
||||
const toolName = directContent?.tool_name ?? directContent?.tool ?? directContent?.name ?? msgAny.tool_name;
|
||||
if (isWorkflowSuggestionTool(toolName, directContent?.mcpServer ?? msgAny.mcpServer)) {
|
||||
found = parseWorkflowSuggestionFromContent(directContent) || found;
|
||||
}
|
||||
}
|
||||
|
||||
const blocks = Array.isArray(directContent) ? directContent : [];
|
||||
for (const block of blocks) {
|
||||
if (block?.type !== 'tool_result') continue;
|
||||
const toolName = block?.tool_name ?? block?.tool ?? block?.name;
|
||||
if (isWorkflowSuggestionTool(toolName, block?.mcpServer)) {
|
||||
found = parseWorkflowSuggestionFromContent(block) || found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Count completed ScheduleWorkflow tool calls so a new one (vs the mount baseline) can pop the workflow open. */
|
||||
function countScheduleWorkflowCalls(session: AgentSession): number {
|
||||
let count = 0;
|
||||
for (const msg of session.messages || []) {
|
||||
const msgAny = msg as any;
|
||||
const directContent = msgAny.content;
|
||||
if (msgAny.role === 'tool_result') {
|
||||
const toolName = directContent?.tool_name ?? directContent?.tool ?? directContent?.name ?? msgAny.tool_name;
|
||||
if (isScheduleWorkflowTool(toolName)) count += 1;
|
||||
}
|
||||
const blocks = Array.isArray(directContent) ? directContent : [];
|
||||
for (const block of blocks) {
|
||||
if (block?.type !== 'tool_result') continue;
|
||||
if (isScheduleWorkflowTool(block?.tool_name ?? block?.tool ?? block?.name)) count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ service, size = 16 }) => {
|
||||
if (service === 'gmail') {
|
||||
return (
|
||||
@@ -259,6 +348,10 @@ const AgentCard: React.FC<Props> = ({
|
||||
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
|
||||
const defaultMode = useAppSelector((s) => s.settings.data.default_mode);
|
||||
const [converting, setConverting] = useState(false);
|
||||
const [suggestGlowCycle, setSuggestGlowCycle] = useState(0);
|
||||
const [workflowToast, setWorkflowToast] = useState('');
|
||||
const [dismissedWorkflowPromptKey, setDismissedWorkflowPromptKey] = useState('');
|
||||
const workflowSuggestion = useMemo(() => findWorkflowSuggestion(session), [session]);
|
||||
// Hide the "Convert to workflow" button when this chat is already
|
||||
// entangled with a workflow (Image #44 note). Two cases:
|
||||
// (a) The session is one of a workflow's runner sessions, OR
|
||||
@@ -302,6 +395,76 @@ const AgentCard: React.FC<Props> = ({
|
||||
!sourceWorkflow.schedule?.enabled &&
|
||||
(session.status === 'completed' || session.status === 'stopped') &&
|
||||
session.messages.length >= 2;
|
||||
const hasUserPrompt = useMemo(
|
||||
() => (session.messages || []).some((m) => m.role === 'user' && !m.hidden),
|
||||
[session.messages],
|
||||
);
|
||||
const isConvertBlockedByTurn = session.status !== 'completed' && session.status !== 'stopped';
|
||||
const showConvertToWorkflow =
|
||||
!session.is_welcome_draft &&
|
||||
!isWorkflowRunnerSession &&
|
||||
hasUserPrompt &&
|
||||
(session.messages.length >= 2 || isConvertBlockedByTurn || !!workflowSuggestion);
|
||||
const canConvertToWorkflow = showConvertToWorkflow && !isConvertBlockedByTurn && !converting;
|
||||
const workflowSuggestionKey = workflowSuggestion
|
||||
? `${session.id}:${workflowSuggestion.reason}:${workflowSuggestion.cadence}`
|
||||
: '';
|
||||
const showWorkflowSuggestionPrompt = Boolean(
|
||||
workflowSuggestion &&
|
||||
canConvertToWorkflow &&
|
||||
workflowSuggestionKey &&
|
||||
dismissedWorkflowPromptKey !== workflowSuggestionKey,
|
||||
);
|
||||
const convertChatToWorkflow = useCallback(() => {
|
||||
if (converting) return;
|
||||
const steps = extractStepsFromSession(session);
|
||||
if (steps.length === 0) {
|
||||
setWorkflowToast('Add a prompt before converting this chat to a workflow.');
|
||||
return;
|
||||
}
|
||||
setConverting(true);
|
||||
const draftId = `draft-${session.id}-${Date.now()}`;
|
||||
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));
|
||||
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,
|
||||
suggested_cadence: workflowSuggestion?.cadence || undefined,
|
||||
} as Partial<Workflow>,
|
||||
}));
|
||||
}, [
|
||||
cardHeight,
|
||||
cardWidth,
|
||||
cardX,
|
||||
cardY,
|
||||
converting,
|
||||
defaultMode,
|
||||
defaultModel,
|
||||
dispatch,
|
||||
expandedSessionIds,
|
||||
isConvertBlockedByTurn,
|
||||
session,
|
||||
workflowSuggestion?.cadence,
|
||||
]);
|
||||
const handleConvertToWorkflow = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
||||
e.stopPropagation();
|
||||
if (isConvertBlockedByTurn) {
|
||||
setWorkflowToast('Cannot convert to a workflow while the agent is responding.');
|
||||
return;
|
||||
}
|
||||
convertChatToWorkflow();
|
||||
}, [convertChatToWorkflow, isConvertBlockedByTurn]);
|
||||
// Curated picker label with a tidy fallback for unknowns.
|
||||
const friendlyModelLabel = useMemo(() => {
|
||||
const value = session.model;
|
||||
@@ -318,6 +481,43 @@ const AgentCard: React.FC<Props> = ({
|
||||
}, [session.model, modelsByProvider]);
|
||||
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
|
||||
|
||||
const suggestionPulseRef = useRef('');
|
||||
const readyPulseRef = useRef('');
|
||||
useEffect(() => {
|
||||
if (!workflowSuggestion) return;
|
||||
const key = `${workflowSuggestion.reason}|${workflowSuggestion.cadence}`;
|
||||
if (canConvertToWorkflow) {
|
||||
if (readyPulseRef.current === key) return;
|
||||
readyPulseRef.current = key;
|
||||
} else {
|
||||
if (suggestionPulseRef.current === key) return;
|
||||
suggestionPulseRef.current = key;
|
||||
}
|
||||
setSuggestGlowCycle((n) => n + 1);
|
||||
dispatch(fadeGlowingAgentCard(session.id, 3200));
|
||||
}, [workflowSuggestion, canConvertToWorkflow, dispatch, session.id]);
|
||||
|
||||
// When the agent schedules a workflow from this chat, pop its card open
|
||||
// next to the chat. Baseline the count once on mount so historical
|
||||
// schedules (e.g. after an app reload) don't re-open on their own.
|
||||
const scheduleWorkflowCount = useMemo(() => countScheduleWorkflowCalls(session), [session]);
|
||||
const baselineScheduleCountRef = useRef<number | null>(null);
|
||||
const autoOpenedWorkflowIdsRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (baselineScheduleCountRef.current === null) {
|
||||
baselineScheduleCountRef.current = scheduleWorkflowCount;
|
||||
return;
|
||||
}
|
||||
if (scheduleWorkflowCount <= baselineScheduleCountRef.current) return;
|
||||
for (const wf of Object.values(workflowItems || {})) {
|
||||
if (wf.source_session_id !== session.id) continue;
|
||||
if (autoOpenedWorkflowIdsRef.current.has(wf.id)) continue;
|
||||
autoOpenedWorkflowIdsRef.current.add(wf.id);
|
||||
dispatch(addWorkflowCard({ workflowId: wf.id, sourceSessionId: session.id, expandedSessionIds }));
|
||||
dispatch(openWorkflowCard({ workflowId: wf.id, view: 'saved' }));
|
||||
}
|
||||
}, [scheduleWorkflowCount, workflowItems, session.id, dispatch, expandedSessionIds]);
|
||||
|
||||
const cardBoxRef = useRef<HTMLDivElement>(null);
|
||||
// Ref so ResizeObserver sees latest value without re-attaching when active flips.
|
||||
const isDashboardActiveRef = useRef(isDashboardActive);
|
||||
@@ -907,58 +1107,6 @@ const AgentCard: React.FC<Props> = ({
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2 && !isWorkflowRunnerSession && (
|
||||
<Tooltip title="Turn this chat into a reusable, schedulable workflow">
|
||||
<Box
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (converting) return;
|
||||
const steps = extractStepsFromSession(session);
|
||||
if (steps.length === 0) return;
|
||||
setConverting(true);
|
||||
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));
|
||||
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={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
color: '#fff',
|
||||
bgcolor: c.accent.primary,
|
||||
border: `1px solid ${c.accent.primary}`,
|
||||
fontSize: '0.78rem', fontWeight: 700,
|
||||
px: 1.1, py: 0.5,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
cursor: converting ? 'wait' : 'pointer',
|
||||
opacity: converting ? 0.7 : 1,
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeOutlinedIcon sx={{ fontSize: 14 }} />
|
||||
{converting ? 'Converting…' : 'Convert to workflow'}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={isDraft ? 'Remove' : 'Close chat'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -976,22 +1124,77 @@ const AgentCard: React.FC<Props> = ({
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
display: isDraft && !expanded ? 'none' : 'flex',
|
||||
gap: 1.5,
|
||||
flexShrink: 0,
|
||||
...(isDraft && { visibility: 'hidden' }),
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
|
||||
{friendlyModelLabel}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
|
||||
<ElapsedTimer messages={session.messages} status={session.status} />
|
||||
</Typography>
|
||||
{session.cost_usd > 0 && hasApiKey && (
|
||||
<Typography variant="caption" sx={{ color: c.accent.primary }}>
|
||||
${session.cost_usd.toFixed(4)}
|
||||
<Box
|
||||
sx={{
|
||||
display: isDraft && !expanded ? 'none' : 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
flexShrink: 0,
|
||||
minWidth: 0,
|
||||
...(isDraft && { visibility: 'hidden' }),
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, minWidth: 0, overflow: 'hidden' }}>
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary, whiteSpace: 'nowrap' }}>
|
||||
{friendlyModelLabel}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary, whiteSpace: 'nowrap' }}>
|
||||
<ElapsedTimer messages={session.messages} status={session.status} />
|
||||
</Typography>
|
||||
{session.cost_usd > 0 && hasApiKey && (
|
||||
<Typography variant="caption" sx={{ color: c.accent.primary, whiteSpace: 'nowrap' }}>
|
||||
${session.cost_usd.toFixed(4)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{showConvertToWorkflow && (
|
||||
<Tooltip title={canConvertToWorkflow ? 'Turn this chat into a reusable workflow' : 'Wait for the current response to finish before converting'}>
|
||||
<Box
|
||||
key={`convert-workflow-${suggestGlowCycle}-${canConvertToWorkflow ? 'ready' : 'blocked'}`}
|
||||
component={motion.div}
|
||||
role="button"
|
||||
aria-disabled={!canConvertToWorkflow}
|
||||
onClick={handleConvertToWorkflow}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
animate={suggestGlowCycle > 0 ? {
|
||||
scale: [1, 1.06, 1, 1.045, 1],
|
||||
filter: ['brightness(1)', 'brightness(1.18)', 'brightness(1)', 'brightness(1.12)', 'brightness(1)'],
|
||||
boxShadow: [
|
||||
`0 0 0 0 ${c.accent.primary}00`,
|
||||
`0 0 0 4px ${c.accent.primary}99, 0 0 20px 6px ${c.accent.primary}66`,
|
||||
`0 0 0 8px ${c.accent.primary}00`,
|
||||
`0 0 0 3px ${c.accent.primary}88, 0 0 16px 4px ${c.accent.primary}55`,
|
||||
canConvertToWorkflow ? c.shadow.sm : 'none',
|
||||
],
|
||||
} : undefined}
|
||||
transition={{ duration: 2.4, ease: 'easeInOut' }}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.35,
|
||||
color: canConvertToWorkflow ? '#fff' : c.text.tertiary,
|
||||
bgcolor: canConvertToWorkflow ? c.accent.primary : c.bg.secondary,
|
||||
border: `1px solid ${canConvertToWorkflow ? c.accent.primary : c.border.medium}`,
|
||||
fontSize: '0.68rem',
|
||||
lineHeight: 1,
|
||||
fontWeight: 700,
|
||||
px: 0.8,
|
||||
py: 0.35,
|
||||
minHeight: 22,
|
||||
borderRadius: `${c.radius.sm}px`,
|
||||
cursor: canConvertToWorkflow ? (converting ? 'wait' : 'pointer') : 'not-allowed',
|
||||
opacity: converting ? 0.7 : 1,
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
boxShadow: canConvertToWorkflow ? c.shadow.sm : 'none',
|
||||
'&:hover': canConvertToWorkflow ? { filter: 'brightness(1.05)' } : { bgcolor: c.bg.secondary },
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeOutlinedIcon sx={{ fontSize: 13 }} />
|
||||
{converting ? 'Converting...' : 'Convert to workflow'}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -1204,6 +1407,95 @@ const AgentCard: React.FC<Props> = ({
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<Fade in={showWorkflowSuggestionPrompt} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
|
||||
<Box
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDismissedWorkflowPromptKey(workflowSuggestionKey);
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 30,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 2,
|
||||
bgcolor: 'rgba(0,0,0,0.28)',
|
||||
backdropFilter: 'blur(1.5px)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 340,
|
||||
bgcolor: c.bg.surface,
|
||||
color: c.text.primary,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
boxShadow: c.shadow.lg,
|
||||
p: 2.25,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.98rem', fontWeight: 700, mb: 0.75 }}>
|
||||
Would you like to make this a workflow?
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.85rem', lineHeight: 1.5, color: c.text.secondary }}>
|
||||
I can open a workflow draft from this chat. You can review the steps and choose the schedule there.
|
||||
</Typography>
|
||||
{workflowSuggestion?.cadence && (
|
||||
<Typography sx={{ mt: 1, fontSize: '0.78rem', color: c.text.tertiary }}>
|
||||
Suggested cadence: {workflowSuggestion.cadence}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 2 }}>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={() => setDismissedWorkflowPromptKey(workflowSuggestionKey)}
|
||||
sx={{ textTransform: 'none', color: c.text.tertiary, fontWeight: 700 }}
|
||||
>
|
||||
No, keep chatting
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<AutoAwesomeOutlinedIcon sx={{ fontSize: 14 }} />}
|
||||
onClick={() => {
|
||||
setDismissedWorkflowPromptKey(workflowSuggestionKey);
|
||||
convertChatToWorkflow();
|
||||
}}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
bgcolor: c.accent.primary,
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
boxShadow: c.shadow.sm,
|
||||
'&:hover': { bgcolor: c.accent.hover },
|
||||
}}
|
||||
>
|
||||
Yes, open workflow
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Fade>
|
||||
<Snackbar
|
||||
open={!!workflowToast}
|
||||
autoHideDuration={3200}
|
||||
onClose={() => setWorkflowToast('')}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
severity="info"
|
||||
variant="filled"
|
||||
onClose={() => setWorkflowToast('')}
|
||||
sx={{ fontSize: '0.78rem' }}
|
||||
>
|
||||
{workflowToast}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -66,11 +66,13 @@ export default function SchedulingView({ workflow, steps }: Props) {
|
||||
(async () => {
|
||||
try {
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
const cadenceHint = workflow.suggested_cadence ? ` I think it should run ${workflow.suggested_cadence}.` : '';
|
||||
const prompt = `Greet me in one short sentence, then ask exactly: "When should this workflow run (e.g. every Wednesday at 1pm)?"${cadenceHint}`;
|
||||
await fetch(`${API_BASE}/agents/sessions/${encodeURIComponent(scheduleSessionId)}/message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) },
|
||||
body: JSON.stringify({
|
||||
prompt: 'Greet me in one short sentence, then ask exactly: "When should this workflow run (e.g. every Wednesday at 1pm)?"',
|
||||
prompt,
|
||||
hidden: true,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
setWorkflowsHubPosition,
|
||||
setWorkflowsHubSize,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { openWorkflowCard, createWorkflow, fetchPausedState, setPausedAll, updateWorkflow, deleteWorkflow, runWorkflowNow } from '@/shared/state/workflowsSlice';
|
||||
import { openWorkflowCard, createWorkflow, fetchPausedState, fetchWorkflows, setPausedAll, updateWorkflow, deleteWorkflow, runWorkflowNow } from '@/shared/state/workflowsSlice';
|
||||
import type { Workflow } from '@/shared/state/workflowsSlice';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
@@ -56,6 +56,7 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
|
||||
];
|
||||
|
||||
interface Props {
|
||||
dashboardId?: string;
|
||||
cardX: number;
|
||||
cardY: number;
|
||||
cardWidth: number;
|
||||
@@ -125,6 +126,7 @@ function TimeSavedBadge() {
|
||||
}
|
||||
|
||||
const WorkflowsHubCard: React.FC<Props> = ({
|
||||
dashboardId,
|
||||
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
|
||||
zoom = 1, panX = 0, panY = 0,
|
||||
isSelected = false, isHighlighted = false, multiDragDelta = null,
|
||||
@@ -136,7 +138,10 @@ const WorkflowsHubCard: React.FC<Props> = ({
|
||||
const paused = useAppSelector((s) => s.workflows.paused);
|
||||
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
|
||||
|
||||
useEffect(() => { dispatch(fetchPausedState()); }, [dispatch]);
|
||||
useEffect(() => {
|
||||
dispatch(fetchPausedState());
|
||||
dispatch(fetchWorkflows(dashboardId));
|
||||
}, [dispatch, dashboardId]);
|
||||
|
||||
const togglePaused = useCallback(() => {
|
||||
dispatch(setPausedAll(!paused));
|
||||
|
||||
@@ -121,6 +121,7 @@ export function getWorkflowToolLabel(action: string): string | null {
|
||||
if (lower === 'testworkflow') return 'Test workflow';
|
||||
if (lower === 'readtesttranscript') return 'Read test results';
|
||||
if (lower === 'listworkflows') return 'List workflows';
|
||||
if (lower === 'suggestconverttoworkflow') return 'Suggest workflow';
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,9 @@ export interface Workflow {
|
||||
* conversion). Compared against the current steps to decide whether to warn
|
||||
* before scheduling. See scheduleUtils.needsScheduleTestWarning. */
|
||||
tested_signature?: string | null;
|
||||
/** Suggested cadence from a SuggestConvertToWorkflow tool call (e.g. "every weekday at 9am").
|
||||
* Used to seed the scheduling agent's prompt. Transient draft field only. */
|
||||
suggested_cadence?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowRun {
|
||||
|
||||
Reference in New Issue
Block a user