From 6c3bfcde3cc5d1d34b4e34f542d9a71bfb190ed5 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 19 Jun 2026 03:41:50 -0700 Subject: [PATCH] [aidan] feat/workflow-convert: in-chat convert popup and auto-open scheduled workflow card --- .../Dashboard/canvas/DashboardCanvas.tsx | 1 + .../Dashboard/canvas/DashboardCardLayer.tsx | 3 + .../app/pages/Dashboard/cards/AgentCard.tsx | 449 ++++++++++++++---- .../app/pages/Workflows/WorkflowsHubCard.tsx | 9 +- 4 files changed, 358 insertions(+), 104 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index a449a853..06821c35 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -244,6 +244,7 @@ const DashboardCanvas: React.FC = ({ {/* Tether lines between branched cards */} ; viewCards: Record; browserCards: Record; @@ -64,6 +65,7 @@ interface DashboardCardLayerProps { } const DashboardCardLayer: React.FC = ({ + dashboardId, cards, viewCards, browserCards, @@ -265,6 +267,7 @@ const DashboardCardLayer: React.FC = ({ ))} {workflowsHub && ( = ({ service, size = 16 }) => { @@ -282,7 +348,9 @@ const AgentCard: React.FC = ({ 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 = ({ !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, + })); + }, [ + cardHeight, + cardWidth, + cardX, + cardY, + converting, + defaultMode, + defaultModel, + dispatch, + expandedSessionIds, + isConvertBlockedByTurn, + session, + workflowSuggestion?.cadence, + ]); + const handleConvertToWorkflow = useCallback((e: React.MouseEvent) => { + 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 = ({ }, [session.model, modelsByProvider]); const scrollOverlayRef = useOverlayScrollPassthrough(isSelected); - const suggestGlowRef = useRef(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(null); + const autoOpenedWorkflowIdsRef = useRef>(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(null); // Ref so ResizeObserver sees latest value without re-attaching when active flips. @@ -941,71 +1107,6 @@ const AgentCard: React.FC = ({ )} - {((session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2 && !isWorkflowRunnerSession || !!workflowSuggestion) && ( - - { - 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, - })); - }} - 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`, - }, - }, - }, - }} - > - - {converting ? 'Converting…' : 'Convert to workflow'} - - - )} = ({ - - - {friendlyModelLabel} - - - - - {session.cost_usd > 0 && hasApiKey && ( - - ${session.cost_usd.toFixed(4)} + + + + {friendlyModelLabel} + + + + {session.cost_usd > 0 && hasApiKey && ( + + ${session.cost_usd.toFixed(4)} + + )} + + {showConvertToWorkflow && ( + + 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 }, + }} + > + + {converting ? 'Converting...' : 'Convert to workflow'} + + )} @@ -1251,6 +1407,95 @@ const AgentCard: React.FC = ({ ) : null} )} + + { + 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)', + }} + > + 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, + }} + > + + Would you like to make this a workflow? + + + I can open a workflow draft from this chat. You can review the steps and choose the schedule there. + + {workflowSuggestion?.cadence && ( + + Suggested cadence: {workflowSuggestion.cadence} + + )} + + + + + + + + setWorkflowToast('')} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setWorkflowToast('')} + sx={{ fontSize: '0.78rem' }} + > + {workflowToast} + + ); diff --git a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx index 853f4043..c3a463ce 100644 --- a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx @@ -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 }[] = [ ]; interface Props { + dashboardId?: string; cardX: number; cardY: number; cardWidth: number; @@ -125,6 +126,7 @@ function TimeSavedBadge() { } const WorkflowsHubCard: React.FC = ({ + 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 = ({ 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));