mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
[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,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));
|
||||
|
||||
Reference in New Issue
Block a user