[aidan] feat/workflow-convert: in-chat convert popup and auto-open scheduled workflow card

This commit is contained in:
abccodes
2026-06-19 03:41:50 -07:00
parent 7608d0bc17
commit 6c3bfcde3c
4 changed files with 358 additions and 104 deletions
@@ -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,27 +70,91 @@ 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 || []) {
if (msg.role !== 'assistant') continue;
const content = Array.isArray(msg.content) ? msg.content : [];
for (const block of content) {
if (block?.type === 'tool_result' && block?.tool_name === 'SuggestConvertToWorkflow') {
const mcpServer = (block as any)?.mcpServer || '';
if (!mcpServer.includes('openswarm-schedule')) continue;
const text = block?.content?.[0]?.text;
if (!text) continue;
try {
const parsed = JSON.parse(text);
if (parsed?.reason) return { reason: parsed.reason, cadence: parsed.cadence || '' };
} catch {
// Ignore parse errors
}
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 null;
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 }) => {
@@ -282,7 +348,9 @@ 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 [suggestAnimationFired, setSuggestAnimationFired] = 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:
@@ -327,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;
@@ -343,14 +481,42 @@ const AgentCard: React.FC<Props> = ({
}, [session.model, modelsByProvider]);
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
const suggestGlowRef = useRef<boolean>(false);
const suggestionPulseRef = useRef('');
const readyPulseRef = useRef('');
useEffect(() => {
if (workflowSuggestion && !suggestAnimationFired && !suggestGlowRef.current) {
suggestGlowRef.current = true;
setSuggestAnimationFired(true);
dispatch(fadeGlowingAgentCard(session.id, 3000));
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;
}
}, [workflowSuggestion, suggestAnimationFired, dispatch, session.id]);
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.
@@ -941,71 +1107,6 @@ const AgentCard: React.FC<Props> = ({
</Box>
</Tooltip>
)}
{((session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2 && !isWorkflowRunnerSession || !!workflowSuggestion) && (
<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()}`;
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>,
}));
}}
onMouseDown={(e) => e.stopPropagation()}
className={workflowSuggestion && suggestAnimationFired ? 'workflow-suggest-glow' : undefined}
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)' },
'&.workflow-suggest-glow': {
animation: 'workflow-suggest-glow 600ms ease-in-out 3',
'@keyframes workflow-suggest-glow': {
'0%': {
boxShadow: `0 0 0 2px ${c.accent.primary}, 0 0 8px 2px ${c.accent.primary}88`,
},
'50%': {
boxShadow: `0 0 0 4px ${c.accent.primary}66, 0 0 16px 4px ${c.accent.primary}44`,
},
'100%': {
boxShadow: `0 0 0 2px ${c.accent.primary}, 0 0 8px 2px ${c.accent.primary}88`,
},
},
},
}}
>
<AutoAwesomeOutlinedIcon sx={{ fontSize: 14 }} />
{converting ? 'Converting…' : 'Convert to workflow'}
</Box>
</Tooltip>
)}
<Tooltip title={isDraft ? 'Remove' : 'Close chat'}>
<IconButton
size="small"
@@ -1023,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>
@@ -1251,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>
);
@@ -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));