[eric] workflows as canvas-resident cards + hub + schedule UX pass, unstable version

This commit is contained in:
ciregenz
2026-05-17 11:54:19 -07:00
parent 490a3047cf
commit e189ee98e1
24 changed files with 3851 additions and 108 deletions
@@ -34,6 +34,32 @@ import { useDashboardActive } from '@/shared/hooks/useDashboardActive';
import { useOverlayScrollPassthrough } from './useOverlayScrollPassthrough';
import { useStreamingMessage } from '@/shared/state/streamingSlice';
import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canvasInteractionState';
import { openWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice';
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesomeOutlined';
// Extract up to 3 user-prompt steps from a completed chat to seed a workflow.
// Skips trivial messages (one-liners, single-word replies) so the preview
// card matches the substantive steps the agent actually executed.
function extractStepsFromSession(session: { messages: Array<{ role: string; content: unknown; hidden?: boolean }> }): Array<{ id: string; text: string }> {
const out: Array<{ id: string; text: string }> = [];
for (const msg of session.messages || []) {
if (msg.role !== 'user' || msg.hidden) continue;
const text = typeof msg.content === 'string' ? msg.content : (Array.isArray(msg.content) ? msg.content.map((b: any) => (typeof b === 'string' ? b : b?.text || '')).join(' ') : '');
const trimmed = text.trim();
if (trimmed.length < 6) continue;
out.push({ id: `step-${out.length + 1}-${Date.now().toString(36)}`, text: trimmed.slice(0, 400) });
if (out.length === 3) break;
}
if (out.length === 0 && session.messages?.length) {
const fallback = session.messages.find((m) => m.role === 'user');
if (fallback) {
const text = typeof fallback.content === 'string' ? fallback.content : '';
out.push({ id: `step-1-${Date.now().toString(36)}`, text: text.slice(0, 400) || 'Run the original task' });
}
}
return out;
}
// ---------------------------------------------------------------------------
// Helper components & functions (unchanged)
@@ -992,6 +1018,54 @@ const AgentCard: React.FC<Props> = ({
onPointerDown={(e) => e.stopPropagation()}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }}
>
{(session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2 && (
<Tooltip title="Turn this chat into a reusable, schedulable workflow">
<Box
role="button"
onClick={(e) => {
e.stopPropagation();
const steps = extractStepsFromSession(session);
if (steps.length === 0) return;
const draft: Partial<Workflow> = {
title: session.name || 'New workflow',
description: 'Auto-generated from this chat. Edit anytime in Workflows.',
steps,
source_session_id: session.id,
dashboard_id: session.dashboard_id || null,
model: session.model,
mode: session.mode,
provider: session.provider,
};
const tempId = `draft-${session.id}`;
dispatch(addWorkflowCard({
workflowId: tempId,
sourceSessionId: session.id,
}));
dispatch(openWorkflowCard({
workflowId: tempId,
sourceSessionId: session.id,
view: 'preview',
draft,
}));
}}
onMouseDown={(e) => e.stopPropagation()}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
color: c.accent.primary,
bgcolor: c.accent.primary + '12',
border: `1px solid ${c.accent.primary}40`,
fontSize: '0.78rem', fontWeight: 600,
px: 1, py: 0.45,
borderRadius: `${c.radius.md}px`,
cursor: 'pointer',
'&:hover': { bgcolor: c.accent.primary + '22' },
}}
>
<AutoAwesomeIcon sx={{ fontSize: 14 }} />
Make workflow
</Box>
</Tooltip>
)}
<Tooltip title={isDraft ? 'Remove' : 'Close chat'}>
<IconButton
size="small"
+198 -6
View File
@@ -32,6 +32,7 @@ import {
setGlowingBrowserCards,
removeViewCard,
removeBrowserCard,
removeWorkflowCard,
pasteBrowserCard,
placeCard,
setCardPosition,
@@ -40,6 +41,8 @@ import {
setGlowingAgentCard,
clearGlowingAgentCard,
clearPendingFocusBrowserId,
clearPendingFocusWorkflowId,
clearPendingFocusWorkflowsHub,
addNote,
removeNote,
clearPendingFocusNoteId,
@@ -49,6 +52,7 @@ import {
GRID_GAP,
} from '@/shared/state/dashboardLayoutSlice';
import { fetchOutputs } from '@/shared/state/outputsSlice';
import { fetchWorkflows, closeWorkflowCard } from '@/shared/state/workflowsSlice';
import { generateDashboardName, updateDashboardThumbnail } from '@/shared/state/dashboardsSlice';
import { dashboardWs } from '@/shared/ws/WebSocketManager';
import { initBrowserCommandHandler } from '@/shared/browserCommandHandler';
@@ -64,6 +68,8 @@ import DirectionHints from './DirectionHints';
// (mounted in Main.tsx) replaces it. Keeping this banner to prevent stale
// imports from sneaking back in via auto-completion.
import DashboardToolbar from './DashboardToolbar';
import WorkflowCard from '@/app/pages/Workflows/WorkflowCard';
import WorkflowsHubCard from '@/app/pages/Workflows/WorkflowsHubCard';
import { captureDashboardThumbnail } from './captureDashboardThumbnail';
import { useCanvasControls } from './useCanvasControls';
import { useDashboardSelection } from './useDashboardSelection';
@@ -110,6 +116,8 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
const cards = useAppSelector((state) => state.dashboardLayout.cards);
const viewCards = useAppSelector((state) => state.dashboardLayout.viewCards);
const browserCards = useAppSelector((state) => state.dashboardLayout.browserCards);
const workflowCards = useAppSelector((state) => state.dashboardLayout.workflowCards);
const workflowsHub = useAppSelector((state) => state.dashboardLayout.workflowsHub);
const notes = useAppSelector((state) => state.dashboardLayout.notes);
const pendingFocusNoteId = useAppSelector((state) => state.dashboardLayout.pendingFocusNoteId);
const layoutInitialized = useAppSelector((state) => state.dashboardLayout.initialized);
@@ -133,6 +141,8 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
...Object.values(cards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })),
...Object.values(viewCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })),
...Object.values(browserCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })),
...Object.values(workflowCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })),
...(workflowsHub ? [{ x: workflowsHub.x, y: workflowsHub.y, w: workflowsHub.width, h: workflowsHub.height }] : []),
];
if (allRects.length === 0) return undefined;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
@@ -143,7 +153,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
maxY = Math.max(maxY, r.y + r.h);
}
return { minX, minY, maxX, maxY };
}, [cards, viewCards, browserCards]);
}, [cards, viewCards, browserCards, workflowCards, workflowsHub]);
const canvas = useCanvasControls(zoomSensitivity, contentBounds, isActive);
const selection = useDashboardSelection(
@@ -152,6 +162,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
viewCards,
browserCards,
notes,
workflowCards,
);
const toolbarRef = useRef<HTMLDivElement>(null);
@@ -341,6 +352,10 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
const n = layoutState.notes[id];
if (!n) return undefined;
return { x: n.x, y: n.y, width: n.width, height: n.height };
} else if (type === 'workflow') {
const wc = layoutState.workflowCards[id];
if (!wc) return undefined;
return { x: wc.x, y: wc.y, width: wc.width, height: wc.height };
}
return undefined;
}, []);
@@ -495,11 +510,13 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
? (window as any).requestIdleCallback(() => {
dispatch(fetchHistory({ dashboardId }));
dispatch(fetchOutputs());
dispatch(fetchWorkflows(dashboardId));
dashboardWs.connect();
}, { timeout: 2000 })
: window.setTimeout(() => {
dispatch(fetchHistory({ dashboardId }));
dispatch(fetchOutputs());
dispatch(fetchWorkflows(dashboardId));
dashboardWs.connect();
}, 200);
@@ -552,6 +569,8 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
const pendingBrowserUrl = useAppSelector((state) => state.tempState.pendingBrowserUrl);
const pendingFocusAgentId = useAppSelector((state) => state.tempState.pendingFocusAgentId);
const pendingFocusBrowserId = useAppSelector((state) => state.dashboardLayout.pendingFocusBrowserId);
const pendingFocusWorkflowId = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowId);
const pendingFocusWorkflowsHub = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowsHub);
useEffect(() => {
if (!dashboardId) return;
@@ -673,6 +692,53 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
}, 200);
}, [isActive, pendingFocusBrowserId, layoutInitialized, dispatch, canvas.actions, handleHighlightCard]);
// Same pan/highlight choreography for newly-spawned workflow cards
// ("Make workflow", "+ New workflow", or list-picker → canvas).
useEffect(() => {
if (!isActive) return;
if (!pendingFocusWorkflowId || !layoutInitialized) return;
const workflowId = pendingFocusWorkflowId;
dispatch(clearPendingFocusWorkflowId());
setTimeout(() => {
const card = store.getState().dashboardLayout.workflowCards[workflowId];
if (card) {
canvas.actions.fitToCards(
[{ x: card.x, y: card.y, width: card.width, height: card.height }],
1.15,
true,
);
handleHighlightCard(workflowId);
}
}, 200);
}, [isActive, pendingFocusWorkflowId, layoutInitialized, dispatch, canvas.actions, handleHighlightCard]);
// Pan/zoom to the Workflows Hub singleton when Expand is clicked. Without
// this the hub spawns at an open grid cell, which can be far from the
// current viewport — making the click look like a no-op.
//
// We chain rAFs so the fit runs after Dashboard's next render has actually
// committed the hub <div> with its new coordinates. A bare setTimeout(100)
// raced the layout on slower mounts.
useEffect(() => {
if (!isActive) return;
if (!pendingFocusWorkflowsHub || !layoutInitialized) return;
dispatch(clearPendingFocusWorkflowsHub());
const fit = () => {
const hub = store.getState().dashboardLayout.workflowsHub;
if (!hub) return;
canvas.actions.fitToCards(
[{ x: hub.x, y: hub.y, width: hub.width, height: hub.height }],
1.1,
true,
);
};
// Two rAFs: one for the workflowsHub state to land in the rendered tree,
// one for layout to settle. Then a fallback timeout for slow boots.
requestAnimationFrame(() => requestAnimationFrame(fit));
const fallback = setTimeout(fit, 300);
return () => clearTimeout(fallback);
}, [isActive, pendingFocusWorkflowsHub, layoutInitialized, dispatch, canvas.actions]);
useEffect(() => {
if (!layoutInitialized || restoredExpandedRef.current) return;
restoredExpandedRef.current = true;
@@ -822,7 +888,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
skipInitialSave.current = false;
return;
}
const payload = { dashboardId, cards, viewCards, browserCards, notes, expandedSessionIds };
const payload = { dashboardId, cards, viewCards, browserCards, workflowCards, workflowsHub, notes, expandedSessionIds };
pendingSaveRef.current = payload;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
@@ -831,7 +897,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
saveTimerRef.current = null;
captureNow();
}, 500);
}, [isActive, cards, viewCards, browserCards, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]);
}, [isActive, cards, viewCards, browserCards, workflowCards, workflowsHub, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]);
useEffect(() => {
return () => {
@@ -901,6 +967,10 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
dispatch(removeBrowserCard(id));
} else if (type === 'note') {
dispatch(removeNote(id));
} else if (type === 'workflow') {
dispatch(removeWorkflowCard(id));
// also drop transient view state so re-opening starts fresh
dispatch(closeWorkflowCard(id));
}
}
selection.deselectAll();
@@ -1051,6 +1121,9 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
for (const bc of Object.values(browserCards)) {
allCardEntries.push({ id: bc.browser_id, type: 'browser', cx: bc.x + bc.width / 2, cy: bc.y + bc.height / 2 });
}
for (const wc of Object.values(workflowCards)) {
allCardEntries.push({ id: wc.workflow_id, type: 'workflow', cx: wc.x + wc.width / 2, cy: wc.y + wc.height / 2 });
}
const current = allCardEntries.find((c) => c.id === currentId);
if (!current) return null;
@@ -1083,7 +1156,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
}
return best ? { id: best.id, type: best.type } : null;
}, [cards, viewCards, browserCards]);
}, [cards, viewCards, browserCards, workflowCards]);
// Compute which directions have neighbors from the focused card
const neighborDirections = useMemo(() => {
@@ -1725,9 +1798,93 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
const browserTethers = Array.from(glowTethers.values()).filter(Boolean) as Array<{ key: string; path: string; labelX: number; labelY: number; label: string; fading: boolean }>;
return [...agentTethers, ...browserTethers];
// Workflow tethers: every workflow card with a source agent gets a
// persistent "Make workflow" arrow back to the agent it was generated
// from. Reuses the same anchor-picking + elbow-path math as the
// browser tether so visual style stays uniform across card kinds.
const workflowTethers: Array<{ key: string; path: string; labelX: number; labelY: number; label: string; fading: boolean }> = [];
for (const wc of Object.values(workflowCards)) {
const sourceId = wc.source_session_id;
if (!sourceId) continue;
const src = cards[sourceId];
if (!src) continue;
let srcX = src.x, srcY = src.y;
let dstX = wc.x, dstY = wc.y;
if (liveDragInfo) {
if (liveDragInfo.cardId === sourceId) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; }
if (liveDragInfo.cardId === wc.workflow_id) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; }
}
const srcMeasured = measuredHeightsRef.current[sourceId];
const srcH = srcMeasured ?? (expandedSessionIds.includes(sourceId)
? Math.max(EXPANDED_CARD_MIN_H, src.height)
: src.height);
const srcCx = srcX + src.width / 2;
const dstCx = dstX + wc.width / 2;
const srcAnchors: Anchor[] = [
{ x: srcX + src.width, y: srcY + srcH * 0.54, side: 'right' },
{ x: srcX, y: srcY + srcH * 0.54, side: 'left' },
{ x: srcCx, y: srcY, side: 'top' },
{ x: srcCx, y: srcY + srcH, side: 'bottom' },
];
const dstAnchors: Anchor[] = [
{ x: dstX, y: dstY + wc.height * 0.54, side: 'left' },
{ x: dstX + wc.width, y: dstY + wc.height * 0.54, side: 'right' },
{ x: dstCx, y: dstY, side: 'top' },
{ x: dstCx, y: dstY + wc.height, side: 'bottom' },
];
let bestSrc = srcAnchors[0], bestDst = dstAnchors[0];
let bestDist = Infinity;
for (const sa of srcAnchors) {
for (const da of dstAnchors) {
const d = Math.hypot(sa.x - da.x, sa.y - da.y);
if (d < bestDist) { bestDist = d; bestSrc = sa; bestDst = da; }
}
}
const x1 = bestSrc.x, y1 = bestSrc.y;
const x2 = bestDst.x, y2 = bestDst.y;
const isVertical = (bestSrc.side === 'top' || bestSrc.side === 'bottom')
&& (bestDst.side === 'top' || bestDst.side === 'bottom');
let pathD: string;
if (isVertical) {
const dx = x2 - x1;
const dy = y2 - y1;
const midY = y1 + dy / 2;
const r = (Math.abs(dx) < 1 || Math.abs(dy) < ELBOW_RADIUS * 2)
? 0
: Math.min(ELBOW_RADIUS, Math.abs(dx) / 2, Math.abs(dy) / 4);
const sx = dx >= 0 ? 1 : -1;
const sy = dy >= 0 ? 1 : -1;
pathD = [
`M ${x1},${y1}`,
`V ${midY - sy * r}`,
`Q ${x1},${midY} ${x1 + sx * r},${midY}`,
`H ${x2 - sx * r}`,
`Q ${x2},${midY} ${x2},${midY + sy * r}`,
`V ${y2}`,
].join(' ');
} else {
pathD = elbowPath(x1, y1, x2, y2);
}
const midX = x1 + (x2 - x1) / 2;
const midY = y1 + (y2 - y1) / 2;
const labelX = isVertical ? midX : midX + (x2 - midX) * 0.15;
const labelY = isVertical ? midY + (y2 - midY) * 0.15 : y2;
workflowTethers.push({
key: `workflow-${wc.workflow_id}`,
path: pathD,
labelX,
labelY,
label: 'Make workflow',
fading: false,
});
}
return [...agentTethers, ...browserTethers, ...workflowTethers];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]);
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]);
const dotSize = Math.max(1, 1.5 * canvas.zoom);
const dotSpacing = 24 * canvas.zoom;
@@ -2089,6 +2246,41 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
onBringToFront={handleBringToFront}
/>
))}
{workflowsHub && (
<WorkflowsHubCard
cardX={workflowsHub.x}
cardY={workflowsHub.y}
cardWidth={workflowsHub.width}
cardHeight={workflowsHub.height}
cardZOrder={workflowsHub.zOrder ?? 0}
zoom={canvas.zoom}
panX={canvas.panX}
panY={canvas.panY}
/>
)}
{Object.values(workflowCards).map((wc) => (
<WorkflowCard
key={`workflow-${wc.workflow_id}`}
workflowId={wc.workflow_id}
cardX={wc.x}
cardY={wc.y}
cardWidth={wc.width}
cardHeight={wc.height}
cardZOrder={wc.zOrder ?? 0}
zoom={canvas.zoom}
panX={canvas.panX}
panY={canvas.panY}
isSelected={selection.isSelected(wc.workflow_id)}
isHighlighted={highlightedCardId === wc.workflow_id}
multiDragDelta={multiDragDelta}
onCardSelect={handleCardSelect}
onDragStart={handleCardDragStart}
onDragMove={handleCardDragMove}
onDragEnd={handleCardDragEnd}
onDoubleClick={handleCardDoubleClick}
onBringToFront={handleBringToFront}
/>
))}
{Object.values(notes).map((n) => (
<NoteCard
key={`note-${n.note_id}`}
@@ -15,6 +15,9 @@ import SearchIcon from '@mui/icons-material/Search';
import { motion } from 'framer-motion';
import ChatInput from '@/app/pages/AgentChat/ChatInput';
import type { ContextPath } from '@/app/components/DirectoryBrowser';
import SchedulePopover from '@/app/pages/Workflows/SchedulePopover';
import { openWorkflowCard } from '@/shared/state/workflowsSlice';
import { addWorkflowCard, openWorkflowsHub } from '@/shared/state/dashboardLayoutSlice';
import { useElementSelection } from '@/app/components/ElementSelectionContext';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
@@ -159,6 +162,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
const [viewSearch, setViewSearch] = useState('');
const [historyOpen, setHistoryOpen] = useState(false);
const [historyQuery, setHistoryQuery] = useState('');
const [popoverMode, setPopoverMode] = useState<'search' | 'schedule'>('search');
const shortcut = useAppSelector((s) => s.settings.data.new_agent_shortcut);
const outputs = useAppSelector((s) => s.outputs.items);
const historySearch = useAppSelector((s) => s.agents.historySearch);
@@ -390,6 +394,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
const placeholderItems: Array<{ icon: typeof StickyNote2OutlinedIcon; label: string; sub: string }> = [];
return (
<>
<MotionBox
ref={containerRef}
layout
@@ -397,14 +402,23 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
style={{
display: 'flex',
flexDirection: 'column',
background: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
// When the schedule popover is open the chips render OUTSIDE the
// popover's own card (Figma image #30). The toolbar wrapper must
// drop its own card chrome in that mode so we don't end up with a
// double-card sandwich; the popover supplies its own surface +
// border + shadow.
background: historyOpen ? 'transparent' : c.bg.surface,
border: historyOpen ? '1px solid transparent' : `1px solid ${c.border.subtle}`,
borderRadius: `${c.radius.xl}px`,
boxShadow: c.shadow.lg,
boxShadow: historyOpen ? 'none' : c.shadow.lg,
padding: isExpanded ? '6px' : '5px',
userSelect: 'none' as const,
overflow: inputOpen || newAgentBounce ? 'visible' : 'hidden',
width: viewPickerOpen ? 580 : isExpanded ? 540 : undefined,
overflow: inputOpen || newAgentBounce || historyOpen ? 'visible' : 'hidden',
// When historyOpen, width is owned by SchedulePopover (POPOVER_W
// constant) so Search and Schedule modes share an identical
// fixed pixel width. Leave width=undefined here so framer-motion
// measures the popover's intrinsic size and animates to it.
width: viewPickerOpen ? 580 : historyOpen ? undefined : isExpanded ? 540 : undefined,
}}
>
{inputOpen ? (
@@ -433,97 +447,33 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
</div>
) : historyOpen ? (
<div style={{ width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1 }}>
<SearchIcon sx={{ fontSize: 18, color: c.text.muted }} />
<InputBase
inputRef={historyInputRef}
value={historyQuery}
onChange={(e) => setHistoryQuery(e.target.value)}
placeholder="Search past chats..."
sx={{
flex: 1,
fontSize: '0.85rem',
color: c.text.primary,
fontFamily: c.font.sans,
'& input::placeholder': { color: c.text.ghost, opacity: 1 },
}}
/>
{historySearch.loading && historySearch.results.length === 0 && (
<CircularProgress size={16} sx={{ color: c.text.muted }} />
)}
</Box>
<Box
ref={historyListRef}
onScroll={handleHistoryScroll}
sx={{
maxHeight: 320,
overflow: 'auto',
borderTop: `1px solid ${c.border.subtle}`,
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 2 },
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
<SchedulePopover
mode={popoverMode}
onModeChange={setPopoverMode}
historyResults={historySearch.results.map((e) => ({ id: e.id, name: e.name, closed_at: e.closed_at }))}
historyLoading={historySearch.loading}
historyQuery={historyQuery}
onHistoryQueryChange={setHistoryQuery}
onHistorySelect={handleHistorySelect}
onNewChat={() => { handleCloseHistory(); onNewAgent(); }}
onWorkflowSelect={(wid) => {
dispatch(addWorkflowCard({ workflowId: wid }));
dispatch(openWorkflowCard({
workflowId: wid,
view: 'saved',
}));
handleCloseHistory();
}}
>
{historySearch.results.length === 0 && !historySearch.loading ? (
<Box sx={{ px: 2, py: 3, textAlign: 'center' }}>
<Typography sx={{ fontSize: '0.82rem', color: c.text.muted }}>
{historyQuery ? 'No matching chats' : 'No chat history yet'}
</Typography>
</Box>
) : (
<>
{historySearch.results.map((entry) => (
<Box
key={entry.id}
onClick={() => handleHistorySelect(entry.id)}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1.5,
px: 1.5,
py: 0.9,
cursor: 'pointer',
transition: 'background-color 0.1s',
'&:hover': { bgcolor: c.bg.elevated },
}}
>
<Typography
sx={{
fontSize: '0.82rem',
fontWeight: 500,
color: c.text.primary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}
>
{entry.name}
</Typography>
<Typography
sx={{
fontSize: '0.7rem',
color: c.text.ghost,
flexShrink: 0,
whiteSpace: 'nowrap',
}}
>
{formatRelativeTime(entry.closed_at)}
</Typography>
</Box>
))}
{historySearch.loading && historySearch.results.length > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1.5 }}>
<CircularProgress size={16} sx={{ color: c.text.muted }} />
</Box>
)}
</>
)}
</Box>
onExpand={() => {
// Expand → spawn the Workflows Hub as a canvas card and close
// the popover. The hub is a singleton per dashboard so a
// second Expand just brings the existing card forward.
dispatch(openWorkflowsHub({ expandedSessionIds: [] }));
handleCloseHistory();
}}
historyScrollRef={historyListRef as React.RefObject<HTMLDivElement>}
onHistoryScroll={handleHistoryScroll}
/>
</div>
) : viewPickerOpen ? (
<div style={{ width: '100%' }}>
@@ -859,6 +809,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
</div>
)}
</MotionBox>
</>
);
},
);
@@ -1,7 +1,7 @@
import { useState, useCallback, useRef, useEffect, RefObject } from 'react';
import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition } from '@/shared/state/dashboardLayoutSlice';
import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition, WorkflowCardPosition } from '@/shared/state/dashboardLayoutSlice';
export type CardType = 'agent' | 'view' | 'browser' | 'note';
export type CardType = 'agent' | 'view' | 'browser' | 'note' | 'workflow';
export interface SelectedCard {
id: string;
@@ -42,6 +42,7 @@ export function useDashboardSelection(
viewCards: Record<string, ViewCardPosition>,
browserCards: Record<string, BrowserCardPosition> = {},
notes: Record<string, NotePosition> = {},
workflowCards: Record<string, WorkflowCardPosition> = {},
) {
const [selectedIds, setSelectedIds] = useState<Map<string, CardType>>(new Map());
const [marquee, setMarquee] = useState<MarqueeRect | null>(null);
@@ -149,6 +150,19 @@ export function useDashboardSelection(
}
}
for (const wc of Object.values(workflowCards)) {
if (
rectsIntersect(rect, {
x: wc.x,
y: wc.y,
width: wc.width,
height: wc.height,
})
) {
intersecting.set(wc.workflow_id, 'workflow');
}
}
if (shiftKey) {
const base = selectionBeforeMarqueeRef.current;
const next = new Map(base);
@@ -164,7 +178,7 @@ export function useDashboardSelection(
return intersecting;
},
[cards, viewCards, browserCards, notes],
[cards, viewCards, browserCards, notes, workflowCards],
);
const handleCanvasMouseDown = useCallback(
@@ -0,0 +1,212 @@
import React, { useMemo } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppSelector } from '@/shared/hooks';
import type { Workflow } from '@/shared/state/workflowsSlice';
import { WEEKDAY_LABEL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, fireTimesWithin, formatTime, formatHourLabel } from './scheduleUtils';
interface Props {
view: 'Week' | 'Month' | 'List';
density: 'compact' | 'roomy';
onSelectWorkflow?: (id: string) => void;
refDate?: Date;
}
// Both compact (popover) and roomy (hub) show the full 24 hours scrollable —
// the user explicitly wants midnight visible at the top, not "9am" as the
// starting hour. The scroll container caps the visible window.
const HOURS_24 = Array.from({ length: 24 }, (_, i) => i);
export default function ScheduleCalendar({ view, density, onSelectWorkflow, refDate }: Props) {
const c = useClaudeTokens();
const workflows = useAppSelector((s) => Object.values(s.workflows.items));
// refDate is recreated on every render unless the caller memoizes it,
// which then trips the eventsByDay memo every paint. Pin the calendar
// to a day-precision key so the heavy fireTimesWithin loop only re-runs
// when the day or workflow set actually changed.
const today = refDate || new Date();
const dayKey = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
const compact = density === 'compact';
const eventsByDay = useMemo(() => {
const range = view === 'Month' ? 35 : view === 'Week' ? 7 : 14;
const start = view === 'Month' ? startOfMonthGrid(today) : view === 'Week' ? startOfWeek(today) : today;
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;
const fires = fireTimesWithin(wf, start, end, 60);
for (const d of fires) {
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const arr = map.get(key) || [];
arr.push({ workflow: wf, date: d });
map.set(key, arr);
}
}
return { map, start, end };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflows, view, dayKey]);
const SLOT_H = compact ? 28 : 36;
const ROW_LABEL = compact ? '0.72rem' : '0.78rem';
const DAY_NUM = compact ? '0.85rem' : '0.95rem';
const DAY_LABEL = compact ? '0.7rem' : '0.78rem';
const EVENT_FS = compact ? '0.72rem' : '0.82rem';
if (view === 'Week') {
const start = startOfWeek(today);
const days = Array.from({ length: 7 }, (_, i) => addDays(start, i));
const HOURS = HOURS_24;
const TZ_LABEL = (() => {
try {
const offset = -new Date().getTimezoneOffset() / 60;
return `GMT${offset >= 0 ? '+' : ''}${offset.toString().padStart(2, '0').replace('.', ':')}`;
} catch { return ''; }
})();
return (
<Box sx={{ display: 'flex', flexDirection: 'column', color: c.text.secondary }}>
{/* Day headers — full names in roomy, single letter in compact */}
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', gap: 0, position: 'sticky', top: 0, bgcolor: c.bg.surface, zIndex: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'flex-end', pr: 1, pb: 0.5 }}>
{!compact && (
<Typography sx={{ fontSize: '0.66rem', color: c.text.ghost, fontWeight: 500 }}>{TZ_LABEL}</Typography>
)}
</Box>
{days.map((d) => {
const isToday = sameDay(d, today);
return (
<Box key={d.toISOString()} sx={{ textAlign: 'center', pb: 0.5 }}>
<Typography sx={{ fontSize: DAY_LABEL, color: isToday ? c.accent.primary : c.text.muted, fontWeight: 700, letterSpacing: '0.06em', lineHeight: 1.3 }}>
{WEEKDAY_LABEL_SHORT[d.getDay()]}
</Typography>
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: compact ? 28 : 34, height: compact ? 28 : 34, borderRadius: '50%', bgcolor: isToday ? c.accent.primary : 'transparent', color: isToday ? '#fff' : c.text.primary, fontWeight: 700, fontSize: DAY_NUM, mt: 0.25 }}>{d.getDate()}</Box>
</Box>
);
})}
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', borderTop: `1px solid ${c.border.subtle}` }}>
{HOURS.map((hour) => (
<React.Fragment key={hour}>
<Box sx={{
height: SLOT_H, fontSize: ROW_LABEL,
color: c.text.ghost, fontWeight: 500,
textAlign: 'right', pr: 1,
position: 'relative', top: -7, // tuck label so it sits on the gridline, not in the cell
borderTop: `1px solid ${c.border.subtle}`,
}}>
{formatHourLabel(hour)}
</Box>
{days.map((d) => {
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const evs = (eventsByDay.map.get(key) || []).filter((e) => e.date.getHours() === hour);
return (
<Box key={`${d.toISOString()}-${hour}`} sx={{ height: SLOT_H, borderLeft: `1px solid ${c.border.subtle}`, borderTop: `1px solid ${c.border.subtle}`, position: 'relative' }}>
{evs.map((e) => (
<Box
key={`${e.workflow.id}-${e.date.toISOString()}`}
onClick={() => onSelectWorkflow?.(e.workflow.id)}
sx={{
position: 'absolute',
left: 3, right: 3, top: 3, bottom: 3,
bgcolor: c.accent.primary + '1f',
color: c.accent.primary,
border: `1px solid ${c.accent.primary}`,
borderRadius: 999,
px: 1.1, py: 0,
fontSize: EVENT_FS, fontWeight: 600,
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
cursor: 'pointer', display: 'flex', alignItems: 'center',
'&:hover': { bgcolor: c.accent.primary + '33' },
}}>
{e.workflow.title}
</Box>
))}
</Box>
);
})}
</React.Fragment>
))}
</Box>
</Box>
);
}
if (view === 'Month') {
const start = startOfMonthGrid(today);
const cells = Array.from({ length: 35 }, (_, i) => addDays(start, i));
return (
<Box sx={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', mb: 0.5 }}>
{WEEKDAY_LABEL_SHORT.map((l, i) => (
<Typography key={`${l}-${i}`} sx={{ textAlign: 'center', fontSize: DAY_LABEL, color: c.text.muted, fontWeight: 600, letterSpacing: '0.06em' }}>{l}</Typography>
))}
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 0 }}>
{cells.map((d) => {
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const evs = eventsByDay.map.get(key) || [];
const isToday = sameDay(d, today);
const inMonth = d.getMonth() === today.getMonth();
return (
<Box key={d.toISOString()} sx={{ minHeight: compact ? 64 : 88, borderRight: `1px solid ${c.border.subtle}`, borderBottom: `1px solid ${c.border.subtle}`, p: 0.5, opacity: inMonth ? 1 : 0.45, position: 'relative', overflow: 'hidden' }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', minWidth: 22, height: 22, borderRadius: '50%', bgcolor: isToday ? c.accent.primary : 'transparent', color: isToday ? '#fff' : c.text.secondary, fontWeight: isToday ? 700 : 500, fontSize: DAY_NUM, px: 0.5 }}>{d.getDate()}</Box>
</Box>
{evs.slice(0, compact ? 3 : 5).map((e, idx) => (
<Box
key={`${e.workflow.id}-${idx}`}
onClick={() => onSelectWorkflow?.(e.workflow.id)}
sx={{ mt: 0.3, display: 'flex', alignItems: 'center', gap: 0.4, fontSize: EVENT_FS, color: c.text.secondary, cursor: 'pointer', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', '&:hover': { color: c.accent.primary } }}>
<Box sx={{ width: 5, height: 5, borderRadius: '50%', bgcolor: c.accent.primary, flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
{formatTime(e.date.getHours(), e.date.getMinutes())} {e.workflow.title}
</span>
</Box>
))}
{evs.length > (compact ? 3 : 5) && (
<Typography sx={{ fontSize: EVENT_FS, color: c.text.muted, mt: 0.3 }}>+{evs.length - (compact ? 3 : 5)} more</Typography>
)}
</Box>
);
})}
</Box>
</Box>
);
}
const upcoming: { date: Date; events: { workflow: Workflow; date: Date }[] }[] = [];
for (let i = 0; i < 14; i += 1) {
const day = addDays(today, i);
const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`;
const arr = eventsByDay.map.get(key) || [];
if (arr.length) upcoming.push({ date: day, events: arr });
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{upcoming.length === 0 && (
<Typography sx={{ fontSize: '0.85rem', color: c.text.muted, textAlign: 'center', py: 2 }}>No scheduled workflows</Typography>
)}
{upcoming.map(({ date, events }) => (
<Box key={date.toISOString()} sx={{ display: 'flex', gap: 1.25 }}>
<Box sx={{ width: 52, flexShrink: 0, textAlign: 'center', borderRight: `1px solid ${c.border.subtle}`, pr: 0.75 }}>
<Typography sx={{ fontSize: '1.1rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.1 }}>{date.getDate()}</Typography>
<Typography sx={{ fontSize: '0.7rem', color: c.text.muted, fontWeight: 600 }}>{date.toLocaleString('en', { month: 'short' })}</Typography>
<Typography sx={{ fontSize: '0.7rem', color: c.text.muted }}>{WEEKDAY_LABEL[date.getDay()]}</Typography>
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 0.4 }}>
{events.map((e, idx) => (
<Box
key={`${e.workflow.id}-${idx}`}
onClick={() => onSelectWorkflow?.(e.workflow.id)}
sx={{ fontSize: '0.85rem', color: c.text.secondary, cursor: 'pointer', '&:hover': { color: c.accent.primary } }}>
<strong style={{ color: c.text.primary }}>{e.workflow.title}</strong>
<span style={{ color: c.text.muted, marginLeft: 8 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</span>
</Box>
))}
</Box>
</Box>
))}
</Box>
);
}
@@ -0,0 +1,176 @@
import React, { useCallback, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import InputBase from '@mui/material/InputBase';
import SearchIcon from '@mui/icons-material/Search';
import CalendarMonthIcon from '@mui/icons-material/CalendarMonthRounded';
import OpenInFullIcon from '@mui/icons-material/OpenInFullRounded';
import AddIcon from '@mui/icons-material/Add';
import { AnimatePresence, motion } from 'framer-motion';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppSelector } from '@/shared/hooks';
import ScheduleCalendar from './ScheduleCalendar';
type Mode = 'search' | 'schedule';
interface Props {
mode: Mode;
onModeChange: (m: Mode) => void;
historyResults: { id: string; name: string; closed_at: string | null }[];
historyLoading: boolean;
historyQuery: string;
onHistoryQueryChange: (q: string) => void;
onHistorySelect: (id: string) => void;
onNewChat: () => void;
onWorkflowSelect: (id: string) => void;
onExpand: () => void;
historyScrollRef?: React.RefObject<HTMLDivElement>;
onHistoryScroll?: () => void;
}
export default function SchedulePopover({
mode, onModeChange, historyResults, historyLoading, historyQuery, onHistoryQueryChange,
onHistorySelect, onNewChat, onWorkflowSelect, onExpand, historyScrollRef, onHistoryScroll,
}: Props) {
const c = useClaudeTokens();
const [calendarView, setCalendarView] = useState<'Week' | 'Month' | 'List'>('Week');
const workflows = useAppSelector((s) => s.workflows.items);
const workflowIconMap = useMemo(() => {
const m: Record<string, string> = {};
for (const wf of Object.values(workflows)) {
if (wf.source_session_id) m[wf.source_session_id] = wf.icon || wf.title.slice(0, 1).toUpperCase();
}
return m;
}, [workflows]);
// Both Search and Schedule modes render at the same fixed dimensions so
// toggling chips doesn't resize the popover. Schedule sets the floor:
// its 7-day calendar needs ~620w x ~420h, search inherits the same.
const POPOVER_W = 620;
const CONTENT_H = 420;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', width: POPOVER_W, maxWidth: POPOVER_W, gap: 0.75, flexShrink: 0 }}>
{/* Floating mode chips OUTSIDE the content card (Figma image #30) */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 0.5 }}>
<ModeChip label="Search" icon={<SearchIcon sx={{ fontSize: 14 }} />} active={mode === 'search'} onClick={() => onModeChange('search')} />
<ModeChip label="Schedule" icon={<CalendarMonthIcon sx={{ fontSize: 14 }} />} active={mode === 'schedule'} onClick={() => onModeChange('schedule')} />
</Box>
{/* Content card — separately bordered/rounded, like image #30.
Inner content crossfades on tab switch so search↔schedule isn't
a jarring jump. Outer card stays fixed-size (W×H) so the toolbar
doesn't reflow. */}
<Box sx={{
width: '100%',
height: CONTENT_H,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: `${c.radius.lg}px`,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
position: 'relative',
}}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={mode}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.12, ease: 'easeOut' }}
style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column' }}>
{mode === 'search' && (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, flexShrink: 0 }}>
<SearchIcon sx={{ fontSize: 18, color: c.text.muted }} />
<InputBase
value={historyQuery}
onChange={(e) => onHistoryQueryChange(e.target.value)}
placeholder="Search past chats..."
sx={{ flex: 1, fontSize: '0.85rem', color: c.text.primary, '& input::placeholder': { color: c.text.ghost, opacity: 1 } }}
/>
<Box onClick={onNewChat} role="button" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: '0.78rem', fontWeight: 500, color: c.text.secondary, px: 1, py: 0.45, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated } }}>
<AddIcon sx={{ fontSize: 12 }} />
New
</Box>
</Box>
<Box ref={historyScrollRef} onScroll={onHistoryScroll} sx={{ flex: 1, overflowY: 'auto', borderTop: `1px solid ${c.border.subtle}` }}>
{historyResults.length === 0 && !historyLoading && (
<Typography sx={{ px: 1.5, py: 2.5, fontSize: '0.82rem', color: c.text.muted, textAlign: 'center' }}>{historyQuery ? 'No matching chats' : 'No chat history yet'}</Typography>
)}
{historyResults.map((entry) => {
const icon = workflowIconMap[entry.id];
return (
<Box key={entry.id} onClick={() => onHistorySelect(entry.id)} sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.9, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}>
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{entry.name}</Typography>
{icon && (
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 20, height: 20, borderRadius: '4px', bgcolor: c.accent.primary + '22', color: c.accent.primary, fontSize: '0.7rem', fontWeight: 700 }}>{icon}</Box>
)}
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, flexShrink: 0, whiteSpace: 'nowrap' }}>{relTime(entry.closed_at)}</Typography>
</Box>
);
})}
</Box>
</Box>
)}
{mode === 'schedule' && (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, pt: 1, pb: 0.5, flexShrink: 0 }}>
{(['Week', 'Month', 'List'] as const).map((v) => (
<Box key={v} onClick={() => setCalendarView(v)} role="button" sx={{ fontSize: '0.85rem', fontWeight: calendarView === v ? 700 : 500, px: 0.75, pt: 0.4, pb: 0.55, color: calendarView === v ? c.text.primary : c.text.muted, borderBottom: `2px solid ${calendarView === v ? c.accent.primary : 'transparent'}`, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>{v}</Box>
))}
<Box sx={{ flex: 1 }} />
<Box onClick={onExpand} role="button" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: '0.78rem', fontWeight: 500, color: c.text.secondary, px: 1, py: 0.35, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated } }}>
<OpenInFullIcon sx={{ fontSize: 12 }} />
Expand
</Box>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', px: 1.5, py: 1, borderTop: `1px solid ${c.border.subtle}`, minHeight: 0 }}>
<ScheduleCalendar view={calendarView} density="roomy" onSelectWorkflow={onWorkflowSelect} />
</Box>
</Box>
)}
</motion.div>
</AnimatePresence>
</Box>
</Box>
);
}
// Floating chip rendered ABOVE the popover card (image #30). Active gets a
// subtle filled-elevated bg + 1px border; inactive is borderless ghost.
function ModeChip({ label, icon, active, onClick }: { label: string; icon: React.ReactNode; active: boolean; onClick: () => void }) {
const c = useClaudeTokens();
return (
<Box
onClick={onClick}
role="button"
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
fontSize: '0.82rem', fontWeight: active ? 700 : 500,
px: 1.1, py: 0.45,
cursor: 'pointer',
color: active ? c.text.primary : c.text.muted,
bgcolor: active ? c.bg.surface : 'transparent',
border: `1px solid ${active ? c.border.subtle : 'transparent'}`,
borderRadius: `${c.radius.md}px`,
boxShadow: active ? c.shadow.sm : 'none',
'&:hover': { color: c.text.primary, bgcolor: active ? c.bg.surface : c.bg.elevated },
}}>
{icon}
{label}
</Box>
);
}
function relTime(iso: string | null): string {
if (!iso) return '';
const sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (sec < 60) return 'just now';
const m = Math.floor(sec / 60); if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
@@ -0,0 +1,508 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import EditIcon from '@mui/icons-material/EditOutlined';
import HistoryIcon from '@mui/icons-material/HistoryRounded';
import PlayArrowIcon from '@mui/icons-material/PlayArrowRounded';
import ScheduleIcon from '@mui/icons-material/ScheduleRounded';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
closeWorkflowCard,
fetchRuns,
openWorkflowCard as openWorkflowCardAction,
rekeyOpenCard,
runWorkflowNow,
updateWorkflowCard,
type Workflow,
} from '@/shared/state/workflowsSlice';
import {
rekeyWorkflowCard,
removeWorkflowCard,
setWorkflowCardPosition,
setWorkflowCardSize,
} from '@/shared/state/dashboardLayoutSlice';
import { AnimatePresence, motion } from 'framer-motion';
import WorkflowEditViews from './WorkflowEditViews';
import { HistoryDetail, HistoryList, PreviewView, SavedView, statusBg, statusColor } from './WorkflowCardSubviews';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
const EDGE_THICKNESS = 6;
const CORNER_SIZE = 14;
const MIN_W = 360;
const MIN_H = 280;
const CURSOR_MAP: Record<ResizeDir, string> = {
n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize',
nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize',
};
// Resize handles sit at zIndex 25 so they win against the drag-header
// (zIndex 16). Same fix that landed on BrowserCard for the top edge.
const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
{ dir: 'n', sx: { top: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } },
{ dir: 's', sx: { bottom: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } },
{ dir: 'w', sx: { left: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } },
{ dir: 'e', sx: { right: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } },
{ dir: 'nw', sx: { top: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
{ dir: 'ne', sx: { top: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
{ dir: 'sw', sx: { bottom: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
{ dir: 'se', sx: { bottom: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
];
interface Props {
workflowId: string;
cardX: number;
cardY: number;
cardWidth: number;
cardHeight: number;
cardZOrder?: number;
zoom?: number;
panX?: number;
panY?: number;
isSelected?: boolean;
isHighlighted?: boolean;
multiDragDelta?: { dx: number; dy: number } | null;
onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser' | 'note' | 'workflow', shiftKey: boolean) => void;
onDragStart?: (id: string, type: 'agent' | 'view' | 'browser' | 'note' | 'workflow') => void;
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
onDoubleClick?: (id: string, type: 'agent' | 'view' | 'browser' | 'note' | 'workflow') => void;
onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser' | 'note' | 'workflow') => void;
}
const WorkflowCard: React.FC<Props> = ({
workflowId,
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
zoom = 1, panX = 0, panY = 0,
isSelected = false, isHighlighted = false, multiDragDelta,
onCardSelect, onDragStart, onDragMove, onDragEnd, onDoubleClick, onBringToFront,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const card = useAppSelector((s) => s.workflows.openCards[workflowId]);
const workflow = useAppSelector((s) => s.workflows.items[workflowId]);
const runs = useAppSelector((s) => s.workflows.runs[workflowId]);
// Transient "Starting…" label state on the Run button. See onClick handler
// for the full rationale (avoid no-feedback flicker on fast manual runs).
const [runStarting, setRunStarting] = useState(false);
// ---- Lazy-load runs for the history view ----
useEffect(() => {
if (!card) return;
if ((card.view === 'history' || card.view === 'history_detail') && workflow && !runs) {
dispatch(fetchRuns(workflow.id));
}
}, [card?.view, workflow?.id, runs, dispatch]);
const title = workflow?.title || card?.draft?.title || 'Workflow';
const isDraft = card?.view === 'preview' && !workflow;
const steps = (workflow?.steps || card?.draft?.steps || []) as Workflow['steps'];
// ---- Card drag via title bar ----
const DRAG_THRESHOLD = 3;
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
const didDrag = useRef(false);
const justDraggedRef = useRef(false);
const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 });
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
// Don't start a card-drag when the press lands on an interactive
// child (the close button, action chips, step inputs). The header
// also hosts the X icon — bailing here is what makes the X actually
// clickable (the old overlay's setPointerCapture swallowed the click).
const target = e.target as HTMLElement;
if (target.closest('[data-no-drag], button, [role="button"], input, textarea, select')) return;
e.preventDefault();
e.stopPropagation();
dragState.current = {
startX: e.clientX, startY: e.clientY,
origX: cardX, origY: cardY,
startPanX: panRef.current.panX, startPanY: panRef.current.panY,
};
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
onDragStart?.(workflowId, 'workflow');
}, [cardX, cardY, onDragStart, workflowId]);
const recomputeDragPos = useCallback(() => {
const ds = dragState.current;
if (!ds || !didDrag.current) return;
const { clientX, clientY } = lastPointerRef.current;
const z = zoomRef.current;
const panDx = (panRef.current.panX - ds.startPanX) / z;
const panDy = (panRef.current.panY - ds.startPanY) / z;
const dx = (clientX - ds.startX) / z - panDx;
const dy = (clientY - ds.startY) / z - panDy;
setLocalDragPos({ x: ds.origX + dx, y: ds.origY + dy });
onDragMove?.(dx, dy, clientX, clientY);
}, [onDragMove]);
useEffect(() => {
if (isDragging && didDrag.current) recomputeDragPos();
}, [panX, panY, isDragging, recomputeDragPos]);
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const rawDx = e.clientX - dragState.current.startX;
const rawDy = e.clientY - dragState.current.startY;
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
didDrag.current = true;
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
recomputeDragPos();
}, [recomputeDragPos]);
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
let finalX = dragState.current.origX + dx;
let finalY = dragState.current.origY + dy;
if (!e.shiftKey) {
finalX = Math.round(finalX / 24) * 24;
finalY = Math.round(finalY / 24) * 24;
}
dispatch(setWorkflowCardPosition({ workflowId, x: finalX, y: finalY }));
justDraggedRef.current = true;
requestAnimationFrame(() => { justDraggedRef.current = false; });
}
onDragEnd?.(dx, dy, didDrag.current);
dragState.current = null;
didDrag.current = false;
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, workflowId, onDragEnd]);
// ---- Resize ----
const resizeRef = useRef<{
dir: ResizeDir; startX: number; startY: number;
origX: number; origY: number; origW: number; origH: number;
} | null>(null);
const [isResizing, setIsResizing] = useState(false);
const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const handleResizeDown = useCallback(
(dir: ResizeDir) => (e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
resizeRef.current = {
dir, startX: e.clientX, startY: e.clientY,
origX: cardX, origY: cardY, origW: cardWidth, origH: cardHeight,
};
setIsResizing(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[cardX, cardY, cardWidth, cardHeight],
);
const computeResize = useCallback(
(e: React.PointerEvent) => {
if (!resizeRef.current) return null;
const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current;
const dx = (e.clientX - startX) / zoom;
const dy = (e.clientY - startY) / zoom;
let newX = origX, newY = origY, newW = origW, newH = origH;
if (dir.includes('e')) newW = origW + dx;
if (dir.includes('w')) { newW = origW - dx; newX = origX + dx; }
if (dir.includes('s')) newH = origH + dy;
if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; }
if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; }
if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; }
return { x: newX, y: newY, w: newW, h: newH };
},
[zoom],
);
const handleResizeMove = useCallback(
(e: React.PointerEvent) => {
const result = computeResize(e);
if (result) setLocalResize(result);
},
[computeResize],
);
const handleResizeUp = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return;
const result = computeResize(e);
if (result) {
dispatch(setWorkflowCardPosition({ workflowId, x: result.x, y: result.y }));
dispatch(setWorkflowCardSize({ workflowId, width: result.w, height: result.h }));
}
resizeRef.current = null;
setLocalResize(null);
setIsResizing(false);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
}, [computeResize, dispatch, workflowId]);
// ---- Close: drop transient view state AND remove from layout ----
const onClose = useCallback(() => {
dispatch(closeWorkflowCard(workflowId));
dispatch(removeWorkflowCard(workflowId));
}, [dispatch, workflowId]);
// ---- Display calculations ----
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
const displayX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx);
const displayY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy);
const displayW = localResize?.w ?? cardWidth;
const displayH = localResize?.h ?? cardHeight;
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
if (!card) return null;
const border = isHighlighted
? `2px solid ${c.accent.primary}`
: isSelected
? '2px solid #3b82f6'
: `1px solid ${c.border.medium}`;
const shadow = isHighlighted
? `0 0 0 3px ${c.accent.primary}50, 0 0 20px ${c.accent.primary}35, 0 0 40px ${c.accent.primary}15`
: isDragging || isResizing
? c.shadow.lg
: isSelected
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
: c.shadow.md;
return (
<Box
data-select-type="workflow-card"
data-select-id={workflowId}
data-select-meta={JSON.stringify({ name: title })}
onPointerDownCapture={() => onBringToFront?.(workflowId, 'workflow')}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
onCardSelect?.(workflowId, 'workflow', e.shiftKey);
}}
onDoubleClick={(e: React.MouseEvent) => {
e.stopPropagation();
onDoubleClick?.(workflowId, 'workflow');
}}
sx={{
position: 'absolute',
contain: 'layout style',
willChange: 'transform',
left: displayX,
top: displayY,
width: displayW,
height: displayH,
borderRadius: `${c.radius.lg}px`,
border,
bgcolor: c.bg.surface,
boxShadow: shadow,
display: 'flex',
flexDirection: 'column',
zIndex: (isDragging || isResizing) ? 999999 : cardZOrder,
transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease',
'&:hover .resize-handle': { opacity: 1 },
}}
>
{/* ===== Title bar / drag handle ===== */}
<Box
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={handleDragPointerUp}
sx={{
display: 'flex', alignItems: 'center', gap: 1,
px: 1.75, py: 1.1,
borderBottom: `1px solid ${c.border.subtle}`,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none', userSelect: 'none',
flexShrink: 0,
zIndex: 16,
position: 'relative',
}}
>
<DragIndicatorIcon sx={{ fontSize: 16, color: c.text.ghost }} />
<Typography sx={{ flex: 1, fontWeight: 700, fontSize: '0.95rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{title}
</Typography>
{workflow?.last_run_status && (
<Box sx={{ fontSize: '0.68rem', fontWeight: 700, color: statusColor(workflow.last_run_status, c), bgcolor: statusBg(workflow.last_run_status, c), px: 0.8, py: 0.3, borderRadius: 0.75 }}>
{workflow.last_run_status}
</Box>
)}
<IconButton
size="small"
data-no-drag
onClick={(e) => { e.stopPropagation(); onClose(); }}
onPointerDown={(e) => e.stopPropagation()}
sx={{ p: 0.5, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
{/* ===== Action bar ===== */}
{!isDraft && workflow && (
<Box sx={{ display: 'flex', gap: 0.6, px: 2, py: 1, borderBottom: `1px solid ${c.border.subtle}`, flexWrap: 'wrap', flexShrink: 0 }}>
<TabBtn
label={runStarting ? 'Starting…' : 'Run'}
icon={<PlayArrowIcon sx={{ fontSize: 16 }} />}
active={card.view === 'saved'}
accent
onClick={async () => {
if (runStarting) return;
setRunStarting(true);
dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }));
try {
await dispatch(runWorkflowNow(workflow.id));
await dispatch(fetchRuns(workflow.id));
} finally {
// Hold the "Starting…" label briefly so the user sees the
// state change even on fast runs. Without this the button
// flickers and feels like nothing happened.
setTimeout(() => setRunStarting(false), 600);
}
}}
/>
<TabBtn
label="Edit"
icon={<EditIcon sx={{ fontSize: 16 }} />}
active={card.view === 'edit'}
onClick={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'edit', editFacet: card.editFacet || 'General' } }))}
/>
<TabBtn
label="History"
icon={<HistoryIcon sx={{ fontSize: 16 }} />}
active={card.view === 'history' || card.view === 'history_detail'}
onClick={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }))}
/>
{!workflow.schedule.enabled && (
<Box sx={{ ml: 'auto' }}>
<TabBtn
label="Schedule this task"
icon={<ScheduleIcon sx={{ fontSize: 16 }} />}
active={false}
onClick={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'edit', editFacet: 'Schedule' } }))}
/>
</Box>
)}
</Box>
)}
{/* ===== Body — view-specific subview =====
Crossfades between Run/Edit/History tabs so the swap doesn't
read as a "jump". Outer box is the scrollable viewport; the
animated child changes per `card.view`. */}
<Box sx={{ flex: 1, p: 2, overflowY: 'auto', minHeight: 0, position: 'relative' }}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={card.view}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -2 }}
transition={{ duration: 0.14, ease: 'easeOut' }}>
{card.view === 'preview' && (
<PreviewView
workflowId={workflowId}
steps={steps}
sourceSessionId={card.sourceSessionId || null}
initialDraft={card.draft || null}
onSaved={(wf) => {
// 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 }));
dispatch(openWorkflowCardAction({
workflowId: wf.id,
sourceSessionId: card.sourceSessionId,
view: 'saved',
draft: null,
}));
}}
/>
)}
{card.view === 'saved' && workflow && <SavedView workflow={workflow} steps={steps} />}
{card.view === 'edit' && workflow && (
<WorkflowEditViews
workflow={workflow}
facet={card.editFacet || 'General'}
onChangeFacet={(f) => dispatch(updateWorkflowCard({ workflowId, patch: { editFacet: f } }))}
/>
)}
{card.view === 'history' && workflow && (
<HistoryList
runs={runs || []}
onOpen={(run) => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history_detail', historyRunId: run.id } }))}
/>
)}
{card.view === 'history_detail' && workflow && (
<HistoryDetail
run={(runs || []).find((r) => r.id === card.historyRunId) || null}
onBack={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }))}
/>
)}
</motion.div>
</AnimatePresence>
</Box>
{/* ===== Resize handles ===== */}
{HANDLE_DEFS.map(({ dir, sx }) => (
<Box
key={dir}
className="resize-handle"
onPointerDown={handleResizeDown(dir)}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeUp}
sx={{
position: 'absolute',
cursor: CURSOR_MAP[dir],
opacity: 0,
zIndex: 25,
...sx,
}}
/>
))}
</Box>
);
};
function TabBtn({ label, icon, active, accent, onClick }: { label: string; icon: React.ReactNode; active: boolean; accent?: boolean; onClick: () => void }) {
const c = useClaudeTokens();
return (
<Box
onClick={onClick}
onPointerDown={(e) => e.stopPropagation()}
role="button"
data-no-drag
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
px: 1.1, py: 0.5,
fontSize: '0.82rem', fontWeight: 600,
color: active ? c.accent.primary : c.text.secondary,
bgcolor: active || accent ? c.accent.primary + '14' : 'transparent',
border: `1px solid ${active || accent ? c.accent.primary + '40' : c.border.subtle}`,
borderRadius: `${c.radius.md}px`,
cursor: 'pointer', userSelect: 'none',
'&:hover': { bgcolor: c.accent.primary + '10' },
}}>
{icon}
{label}
</Box>
);
}
export default React.memo(WorkflowCard);
@@ -0,0 +1,184 @@
import React, { useCallback, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch } from '@/shared/hooks';
import {
closeWorkflowCard,
createWorkflow,
type Workflow,
type WorkflowRun,
} from '@/shared/state/workflowsSlice';
import { removeWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
import { describePermissions, describeSchedule } from './scheduleUtils';
export function statusColor(s: string, c: ReturnType<typeof useClaudeTokens>): string {
if (s === 'success') return c.status.success;
if (s === 'failure') return c.status.error;
if (s === 'ran_late') return c.status.warning;
if (s === 'running') return c.accent.primary;
return c.text.muted;
}
export function statusBg(s: string, c: ReturnType<typeof useClaudeTokens>): string {
if (s === 'success') return c.status.successBg;
if (s === 'failure') return c.status.errorBg;
if (s === 'ran_late') return c.status.warningBg;
return c.bg.secondary;
}
export function labelForStatus(s: string): string {
if (s === 'success') return 'Success';
if (s === 'failure') return 'Failure';
if (s === 'ran_late') return 'Ran Late';
if (s === 'running') return 'Running';
if (s === 'skipped') return 'Skipped';
return s;
}
export function formatRunDate(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleString('en', { weekday: 'short', month: 'short', day: 'numeric' });
} catch { return iso; }
}
export function ActionBtn({ label, tone, disabled, onClick }: { label: string; tone: 'muted' | 'success'; disabled?: boolean; onClick: () => void }) {
const c = useClaudeTokens();
const isSuccess = tone === 'success';
return (
<Box
onClick={disabled ? undefined : onClick}
role="button"
sx={{
fontSize: '0.85rem', fontWeight: 600, px: 1.25, py: 0.55,
borderRadius: `${c.radius.md}px`,
cursor: disabled ? 'not-allowed' : 'pointer',
color: isSuccess ? c.status.success : c.text.secondary,
bgcolor: isSuccess ? c.status.successBg : c.bg.secondary,
border: `1px solid ${isSuccess ? c.status.success + '60' : c.border.subtle}`,
opacity: disabled ? 0.5 : 1,
'&:hover': { bgcolor: isSuccess ? c.status.success + '30' : c.bg.elevated },
}}>
{label}
</Box>
);
}
export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, onSaved }: {
workflowId: string;
steps: Workflow['steps'];
sourceSessionId: string | null;
initialDraft: Partial<Workflow> | null;
onSaved: (w: Workflow) => void;
}) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const [busy, setBusy] = useState(false);
const title = (initialDraft?.title as string) || 'Email summary request';
const description = (initialDraft?.description as string) || "This is an ai generated description of the workflow that gets auto generated after you click complete on the last step. It's used when we wrap workflows as tool calls for other agents to invoke";
const onSave = useCallback(async () => {
if (busy) return;
setBusy(true);
try {
const result = await dispatch(createWorkflow({
title,
description,
steps: steps.map((s) => ({ id: s.id, text: s.text })),
source_session_id: sourceSessionId,
use_synced_prompt: true,
} as Partial<Workflow>));
const wf = (result as unknown as { payload: Workflow }).payload;
if (wf?.id) onSaved(wf);
} finally {
setBusy(false);
}
}, [busy, dispatch, title, description, steps, sourceSessionId, onSaved]);
const onDiscard = useCallback(() => {
dispatch(closeWorkflowCard(workflowId));
dispatch(removeWorkflowCard(workflowId));
}, [dispatch, workflowId]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
<Box sx={{ flex: 1, fontSize: '0.88rem', color: c.text.secondary, lineHeight: 1.5 }}>{description}</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 0.5 }}>
{steps.map((s, idx) => (
<Box key={s.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}>
<Box sx={{ width: 24, height: 24, borderRadius: '50%', border: `1px solid ${c.border.medium}`, fontSize: '0.78rem', fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', color: c.text.secondary, flexShrink: 0, mt: 0.25 }}>{idx + 1}</Box>
<Box sx={{ flex: 1, fontSize: '0.92rem', color: c.text.primary, border: `1px solid ${idx === 0 ? c.border.medium : c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1.25, py: 0.75, bgcolor: c.bg.surface, lineHeight: 1.4 }}>{s.text}</Box>
</Box>
))}
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 0.75, mt: 1 }}>
<ActionBtn label="Discard" tone="muted" onClick={onDiscard} />
<ActionBtn label="Save" tone="success" onClick={onSave} disabled={busy} />
</Box>
</Box>
);
}
export function SavedView({ workflow, steps }: { workflow: Workflow; steps: Workflow['steps'] }) {
const c = useClaudeTokens();
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary }}><strong style={{ color: c.text.primary }}>Scheduled:</strong> {describeSchedule(workflow.schedule)}</Typography>
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary }}><strong style={{ color: c.text.primary }}>Permissions:</strong> {describePermissions(workflow)}</Typography>
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary, lineHeight: 1.5, mt: 0.5 }}>{workflow.description}</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 0.5 }}>
{steps.map((s, idx) => (
<Box key={s.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}>
<Box sx={{ width: 24, height: 24, borderRadius: '50%', border: `1px solid ${c.border.medium}`, fontSize: '0.78rem', fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', color: c.text.secondary, flexShrink: 0, mt: 0.25 }}>{idx + 1}</Box>
<Box sx={{ flex: 1, fontSize: '0.92rem', color: c.text.primary, px: 0.5, lineHeight: 1.45 }}>{s.text}</Box>
</Box>
))}
</Box>
</Box>
);
}
export function HistoryList({ runs, onOpen }: { runs: WorkflowRun[]; onOpen: (r: WorkflowRun) => void }) {
const c = useClaudeTokens();
if (!runs || runs.length === 0) {
return <Typography sx={{ fontSize: '0.88rem', color: c.text.muted, py: 1.5, textAlign: 'center' }}>No runs yet</Typography>;
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
{runs.map((r) => (
<Box
key={r.id}
onClick={() => onOpen(r)}
sx={{ display: 'flex', alignItems: 'center', gap: 1.25, py: 0.75, px: 0.5, cursor: 'pointer', borderRadius: 0.75, '&:hover': { bgcolor: c.bg.elevated } }}>
<Box sx={{ fontSize: '0.72rem', fontWeight: 700, color: statusColor(r.status, c), bgcolor: statusBg(r.status, c), px: 0.8, py: 0.3, borderRadius: 0.75, minWidth: 64, textAlign: 'center' }}>
{labelForStatus(r.status)}
</Box>
<Typography sx={{ fontSize: '0.88rem', color: c.text.primary }}>{formatRunDate(r.started_at)}</Typography>
<Box sx={{ ml: 'auto', fontSize: '0.78rem', color: c.text.muted }}>Open →</Box>
</Box>
))}
</Box>
);
}
export function HistoryDetail({ run, onBack }: { run: WorkflowRun | null; onBack: () => void }) {
const c = useClaudeTokens();
if (!run) return <Typography sx={{ fontSize: '0.88rem', color: c.text.muted }}>Run not found</Typography>;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box onClick={onBack} role="button" sx={{ fontSize: '0.82rem', color: c.text.muted, cursor: 'pointer', '&:hover': { color: c.accent.primary } }}>← back</Box>
<Box sx={{ fontSize: '0.72rem', fontWeight: 700, color: statusColor(run.status, c), bgcolor: statusBg(run.status, c), px: 0.8, py: 0.3, borderRadius: 0.75 }}>{labelForStatus(run.status)}</Box>
<Typography sx={{ fontSize: '0.88rem', color: c.text.primary, fontWeight: 600 }}>{formatRunDate(run.started_at)}</Typography>
</Box>
{run.error && (
<Typography sx={{ fontSize: '0.85rem', color: c.status.error, bgcolor: c.status.errorBg, p: 1, borderRadius: 0.75 }}>{run.error}</Typography>
)}
<Typography sx={{ fontSize: '0.85rem', color: c.text.secondary, lineHeight: 1.5 }}>Started {formatRunDate(run.started_at)}, finished {run.finished_at ? formatRunDate(run.finished_at) : 'in progress'}.</Typography>
{run.session_id && (
<Box sx={{ fontSize: '0.82rem', color: c.accent.primary, mt: 0.5 }}>Session: {run.session_id.slice(0, 8)}</Box>
)}
</Box>
);
}
@@ -0,0 +1,565 @@
import React, { useCallback, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import InputBase from '@mui/material/InputBase';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import Switch from '@mui/material/Switch';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch } from '@/shared/hooks';
import { updateWorkflow, type Workflow, type ScheduleConfig, type PermissionTier } from '@/shared/state/workflowsSlice';
import { WEEKDAY_LABEL, formatTime } from './scheduleUtils';
// JS-style weekday from a Date (Sun=0..Sat=6), matching ScheduleConfig.on_days.
function jsWeekday(d: Date): number { return d.getDay(); }
// Compute the next fire time from a ScheduleConfig — mirrors the backend
// math in scheduler.py:_next_fire_after so the preview matches what
// actually fires. Local-clock, like the backend.
function previewNextRun(sched: ScheduleConfig): Date | null {
if (!sched.enabled) return null;
const now = new Date();
let candidate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), sched.hour, sched.minute, 0, 0);
if (candidate <= now) candidate = new Date(candidate.getTime() + 86400000);
if (sched.repeat_unit === 'day') {
const step = Math.max(1, sched.repeat_every);
while (candidate <= now) candidate = new Date(candidate.getTime() + step * 86400000);
return candidate;
}
if (sched.repeat_unit === 'week') {
const allowed = sched.on_days.length ? sched.on_days : [jsWeekday(now)];
for (let i = 0; i < 14; i += 1) {
if (allowed.includes(jsWeekday(candidate)) && candidate > now) return candidate;
candidate = new Date(candidate.getTime() + 86400000);
}
return candidate;
}
if (sched.repeat_unit === 'month') {
const step = Math.max(1, sched.repeat_every);
let c = new Date(now.getFullYear(), now.getMonth(), Math.min(28, now.getDate()), sched.hour, sched.minute);
let guard = 0;
while (c <= now && guard < 60) {
c = new Date(c.getFullYear(), c.getMonth() + step, c.getDate(), sched.hour, sched.minute);
guard += 1;
}
return c;
}
return null;
}
function formatNextRun(d: Date): string {
const wd = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()];
const mo = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()];
return `${wd} ${mo} ${d.getDate()} at ${formatTime(d.getHours(), d.getMinutes())}`;
}
interface Props {
workflow: Workflow;
facet: 'General' | 'Actions' | 'Schedule';
onChangeFacet: (facet: 'General' | 'Actions' | 'Schedule') => void;
}
const BODY_FS = '0.88rem';
const LABEL_FS = '0.82rem';
const HINT_FS = '0.78rem';
const INPUT_FS = '0.88rem';
// Pre-save validation. Returns the first user-visible reason save should
// be blocked, or null when the draft is good to ship. Keeps the schedule
// from silently saving a "call tier" with no phone number — the previous
// failure mode where the schedule would fire and the call attempt would
// just no-op against an empty string.
function validateDraft(draft: Workflow): string | null {
for (const tier of (draft.permissions || [])) {
if (tier.kind === 'notify') continue;
const cleaned = (tier.phone || '').replace(/[^\d+]/g, '');
if (!cleaned) {
return tier.kind === 'text'
? 'Add a phone number for the text-me tier.'
: 'Add a phone number for the call-me tier.';
}
if (cleaned.replace(/^\+/, '').length < 7) {
return `Phone number looks too short (${tier.kind} tier).`;
}
}
return null;
}
export default function WorkflowEditViews({ workflow, facet, onChangeFacet }: Props) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const [draft, setDraft] = useState<Workflow>(workflow);
const [busy, setBusy] = useState(false);
// Save-feedback state: 'idle' | 'saved' | 'error'. `saved` flashes a
// checkmark + label on the Save button for 1.4s then auto-clears.
// `error` carries a string the user can read.
const [savedFlash, setSavedFlash] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(workflow), [draft, workflow]);
const onSave = useCallback(async () => {
if (busy || !dirty) return;
const reason = validateDraft(draft);
if (reason) {
setSaveError(reason);
return;
}
setSaveError(null);
setBusy(true);
try {
const result = await dispatch(updateWorkflow({ id: workflow.id, patch: draft }));
if (updateWorkflow.fulfilled.match(result)) {
setSavedFlash(true);
setTimeout(() => setSavedFlash(false), 1400);
} else {
setSaveError('Save failed. Please try again.');
}
} catch (e) {
setSaveError((e as Error)?.message || 'Save failed.');
} finally {
setBusy(false);
}
}, [busy, dirty, dispatch, workflow.id, draft]);
const onDiscard = useCallback(() => {
setDraft(workflow);
setSaveError(null);
}, [workflow]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, fontWeight: 500 }}>Currently Editing</Typography>
<Select
size="small"
value={facet}
onChange={(e) => onChangeFacet(e.target.value as Props['facet'])}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="General">General</MenuItem>
<MenuItem value="Actions">Actions</MenuItem>
<MenuItem value="Schedule">Schedule</MenuItem>
</Select>
<Box sx={{ flex: 1 }} />
<ActionBtn label="Discard" tone="muted" disabled={!dirty || busy} onClick={onDiscard} />
<ActionBtn
label={savedFlash ? '✓ Saved' : busy ? 'Saving…' : 'Save'}
tone="success"
disabled={!dirty || busy || savedFlash}
onClick={onSave}
/>
</Box>
{saveError && (
<Typography sx={{ fontSize: HINT_FS, color: c.status.error, bgcolor: c.status.errorBg, px: 1, py: 0.5, borderRadius: `${c.radius.md}px` }}>
{saveError}
</Typography>
)}
{facet === 'General' && <GeneralFacet draft={draft} setDraft={setDraft} />}
{facet === 'Actions' && <ActionsFacet draft={draft} setDraft={setDraft} />}
{facet === 'Schedule' && <ScheduleFacet draft={draft} setDraft={setDraft} />}
</Box>
);
}
function GeneralFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
const c = useClaudeTokens();
const [editingPrompt, setEditingPrompt] = useState(false);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
<FieldRow label="Title">
<InputBase
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
sx={{ flex: 1, fontSize: INPUT_FS, color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1, py: 0.5 }}
/>
</FieldRow>
<FieldRow label="Description" align="top">
<InputBase
multiline
minRows={2}
value={draft.description}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
sx={{ flex: 1, fontSize: INPUT_FS, color: c.text.secondary, lineHeight: 1.5, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1, py: 0.5 }}
/>
</FieldRow>
<FieldRow label="System prompt">
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ fontSize: LABEL_FS, color: c.accent.primary, cursor: 'pointer', fontWeight: 500 }} onClick={() => setEditingPrompt((v) => !v)}>
{editingPrompt ? 'Editing…' : 'Edit'}
</Box>
<Select
size="small"
value={draft.use_synced_prompt ? 'synced' : 'custom'}
onChange={(e) => setDraft({ ...draft, use_synced_prompt: e.target.value === 'synced' })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="synced">Synced to settings</MenuItem>
<MenuItem value="custom">Custom</MenuItem>
</Select>
</Box>
</FieldRow>
{editingPrompt && !draft.use_synced_prompt && (
<InputBase
multiline
minRows={4}
placeholder="Custom system prompt..."
value={draft.system_prompt || ''}
onChange={(e) => setDraft({ ...draft, system_prompt: e.target.value })}
sx={{ fontSize: INPUT_FS, color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, p: 1, lineHeight: 1.5 }}
/>
)}
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, mt: 0.5 }}>Workflow</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{draft.steps.map((s, idx) => (
<Box key={s.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}>
<Box sx={{ width: 24, height: 24, borderRadius: '50%', border: `1px solid ${c.border.medium}`, fontSize: HINT_FS, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', color: c.text.secondary, flexShrink: 0, mt: 0.4 }}>{idx + 1}</Box>
<InputBase
multiline
value={s.text}
onChange={(e) => {
const next = [...draft.steps];
next[idx] = { ...s, text: e.target.value };
setDraft({ ...draft, steps: next });
}}
sx={{ flex: 1, fontSize: INPUT_FS, color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1.25, py: 0.6, lineHeight: 1.4 }}
/>
</Box>
))}
</Box>
</Box>
);
}
function ActionsFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
const c = useClaudeTokens();
// Configure must ONLY appear when freeze is on (image #40 annotation).
// When "Don't freeze" is selected the entry vanishes entirely.
const [configuring, setConfiguring] = useState(false);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, color: c.text.secondary }}>
<Typography sx={{ fontSize: BODY_FS, color: c.text.secondary, lineHeight: 1.5 }}>
Do you want to prevent the agent from taking actions that weren&apos;t used in the original workflow?
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Select
size="small"
value={draft.actions.prevent_unused ? 'prevent' : 'allow'}
onChange={(e) => setDraft({ ...draft, actions: { ...draft.actions, prevent_unused: e.target.value === 'prevent' } })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="prevent">Prevent all unwanted actions</MenuItem>
<MenuItem value="allow">Allow all actions</MenuItem>
</Select>
</Box>
<Typography sx={{ fontSize: BODY_FS, color: c.text.secondary, lineHeight: 1.5, mt: 0.5 }}>
Do you want to freeze the actions available to the Agent so this flow always works even if you change your settings?
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Select
size="small"
value={draft.actions.freeze ? 'freeze' : 'dont'}
onChange={(e) => setDraft({ ...draft, actions: { ...draft.actions, freeze: e.target.value === 'freeze' } })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="freeze">Freeze actions</MenuItem>
<MenuItem value="dont">Don&apos;t freeze</MenuItem>
</Select>
</Box>
{draft.actions.freeze && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 0.5 }}>
<Box
onClick={() => setConfiguring((v) => !v)}
role="button"
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: LABEL_FS, color: configuring ? c.accent.primary : c.text.secondary, cursor: 'pointer', fontWeight: 500, '&:hover': { color: c.accent.primary } }}>
{configuring ? '⚙ Configuring…' : '⚙ Configure'}
</Box>
</Box>
)}
{draft.actions.freeze && configuring && (
<Box sx={{ mt: 0.5, display: 'flex', flexDirection: 'column', gap: 0.6, border: `1px solid ${c.accent.primary}40`, borderRadius: `${c.radius.lg}px`, p: 1.25 }}>
<Typography sx={{ fontSize: HINT_FS, fontWeight: 700, color: c.text.secondary, letterSpacing: '0.05em', mb: 0.25 }}>BUILT-IN ACTION SETS</Typography>
{(['Core Actions', 'Extended Actions', 'Apps', 'Browser'] as const).map((set) => {
const enabled = draft.actions.configured_sets.includes(set);
return (
<Box key={set} sx={{ display: 'flex', alignItems: 'center', gap: 1, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1, py: 0.6 }}>
<Typography sx={{ flex: 1, fontSize: BODY_FS, color: c.text.primary, fontWeight: 600 }}>{set}</Typography>
<Switch
size="small"
checked={enabled}
onChange={(e) => {
const next = e.target.checked
? [...draft.actions.configured_sets, set]
: draft.actions.configured_sets.filter((s) => s !== set);
setDraft({ ...draft, actions: { ...draft.actions, configured_sets: next } });
}}
/>
</Box>
);
})}
<Typography sx={{ fontSize: HINT_FS, fontWeight: 700, color: c.text.secondary, letterSpacing: '0.05em', mt: 0.75, mb: 0.25 }}>CUSTOM ACTION SETS</Typography>
{(['Notion', 'Google Workspace', 'YouTube', 'Reddit'] as const).map((set) => {
const enabled = draft.actions.configured_sets.includes(set);
return (
<Box key={set} sx={{ display: 'flex', alignItems: 'center', gap: 1, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 1, py: 0.6 }}>
<Typography sx={{ flex: 1, fontSize: BODY_FS, color: c.text.primary, fontWeight: 600 }}>{set}</Typography>
<Switch
size="small"
checked={enabled}
onChange={(e) => {
const next = e.target.checked
? [...draft.actions.configured_sets, set]
: draft.actions.configured_sets.filter((s) => s !== set);
setDraft({ ...draft, actions: { ...draft.actions, configured_sets: next } });
}}
/>
</Box>
);
})}
</Box>
)}
</Box>
);
}
function ScheduleFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
const c = useClaudeTokens();
const s = draft.schedule;
const setSched = useCallback((patch: Partial<ScheduleConfig>) => {
setDraft({ ...draft, schedule: { ...s, ...patch, enabled: true } });
}, [draft, s, setDraft]);
const addBackup = useCallback(() => {
const tiers = [...(draft.permissions || [])];
const lastKind = tiers.length ? tiers[tiers.length - 1].kind : 'notify';
// Tier escalation chain: notify → text → call. Cap at 3 tiers since
// the chain has no fourth medium and stacking duplicates makes no
// sense (matches Figma image #44 ceiling).
if (lastKind === 'notify') tiers.push({ kind: 'text', after_minutes: 5, phone: '' });
else if (lastKind === 'text') tiers.push({ kind: 'call', after_minutes: 60, phone: '' });
else return;
setDraft({ ...draft, permissions: tiers });
}, [draft, setDraft]);
const removeTier = useCallback((idx: number) => {
// Removing tier N drops all tiers after it too, so the chain stays
// contiguous (no "call" without "text" before it).
const tiers = (draft.permissions || []).slice(0, idx);
setDraft({ ...draft, permissions: tiers });
}, [draft, setDraft]);
const lastTierKind = (draft.permissions || []).length
? draft.permissions[draft.permissions.length - 1].kind
: 'notify';
const canAddBackup = lastTierKind !== 'call';
const setTier = useCallback((idx: number, patch: Partial<PermissionTier>) => {
const tiers = [...(draft.permissions || [])];
tiers[idx] = { ...tiers[idx], ...patch };
setDraft({ ...draft, permissions: tiers });
}, [draft, setDraft]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary }}>When should this workflow run?</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: BODY_FS, color: c.text.secondary }}>Repeat every</Typography>
<InputBase
type="number"
value={s.repeat_every}
onChange={(e) => setSched({ repeat_every: Math.max(1, Number(e.target.value) || 1) })}
sx={{ width: 48, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }}
/>
<Select
size="small"
value={s.repeat_unit}
onChange={(e) => setSched({ repeat_unit: e.target.value as ScheduleConfig['repeat_unit'] })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="day">day</MenuItem>
<MenuItem value="week">week</MenuItem>
<MenuItem value="month">month</MenuItem>
</Select>
</Box>
{s.repeat_unit === 'week' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, pl: 2, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>↳ on</Typography>
{WEEKDAY_LABEL.map((label, idx) => {
const active = s.on_days.includes(idx);
return (
<Box
key={idx}
onClick={() => setSched({ on_days: active ? s.on_days.filter((d) => d !== idx) : [...s.on_days, idx] })}
role="button"
sx={{ width: 26, height: 26, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: LABEL_FS, fontWeight: 700, cursor: 'pointer', color: active ? '#fff' : c.text.muted, bgcolor: active ? c.accent.primary : 'transparent', border: `1px solid ${active ? c.accent.primary : c.border.subtle}` }}>{label}</Box>
);
})}
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, pl: 2 }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>↳ at</Typography>
{/* 12-hour picker; we store 0..23 server-side but show 1..12 + AM/PM
so users can't accidentally schedule "3" thinking it's 3pm and
get a 3am run (the previous bare-number input made that easy). */}
<Select
size="small"
value={((s.hour + 11) % 12) + 1}
onChange={(e) => {
const h12 = Number(e.target.value);
const isPm = s.hour >= 12;
const next = (h12 % 12) + (isPm ? 12 : 0);
setSched({ hour: next });
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
{Array.from({ length: 12 }, (_, i) => i + 1).map((h) => (
<MenuItem key={h} value={h}>{h}</MenuItem>
))}
</Select>
<Typography sx={{ fontSize: INPUT_FS, color: c.text.muted }}>:</Typography>
<Select
size="small"
value={s.minute}
onChange={(e) => setSched({ minute: Number(e.target.value) })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
{[0, 15, 30, 45].map((m) => (
<MenuItem key={m} value={m}>{String(m).padStart(2, '0')}</MenuItem>
))}
</Select>
<Select
size="small"
value={s.hour < 12 ? 'AM' : 'PM'}
onChange={(e) => {
const wasPm = s.hour >= 12;
const willBePm = e.target.value === 'PM';
if (wasPm === willBePm) return;
setSched({ hour: willBePm ? s.hour + 12 : s.hour - 12 });
}}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="AM">AM</MenuItem>
<MenuItem value="PM">PM</MenuItem>
</Select>
</Box>
{(() => {
// "Next run" preview is the single line that turns "did my schedule
// actually take?" from a guess into an answer. Re-renders whenever
// the schedule fields change, so users get instant feedback.
const next = previewNextRun({ ...s, enabled: true });
return next ? (
<Typography sx={{ fontSize: HINT_FS, color: c.accent.primary, pl: 2, fontWeight: 500 }}>
Next run: {formatNextRun(next)}
</Typography>
) : null;
})()}
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, mt: 0.5 }}>How should the agent ask for your permission?</Typography>
{(draft.permissions || []).map((tier, idx) => (
<PermissionRow
key={idx}
idx={idx}
tier={tier}
prevKind={idx === 0 ? null : (draft.permissions[idx - 1].kind)}
onChange={(patch) => setTier(idx, patch)}
onRemove={idx === 0 ? undefined : () => removeTier(idx)}
/>
))}
{canAddBackup && (
<Box onClick={addBackup} role="button" sx={{ fontSize: LABEL_FS, color: c.text.muted, cursor: 'pointer', mt: 0.5, fontWeight: 500, '&:hover': { color: c.accent.primary } }}>+ add a backup</Box>
)}
</Box>
);
}
function PermissionRow({ idx, tier, onChange, onRemove }: {
idx: number;
tier: PermissionTier;
prevKind: PermissionTier['kind'] | null;
onChange: (p: Partial<PermissionTier>) => void;
onRemove?: () => void;
}) {
const c = useClaudeTokens();
if (idx === 0) {
return (
<Select
size="small"
value="notify"
sx={{ alignSelf: 'flex-start', fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
<MenuItem value="notify">Notify me in Open Swarm</MenuItem>
</Select>
);
}
const verb = tier.kind === 'text' ? 'Text me' : 'Call me';
const unitLabel = tier.kind === 'call' ? 'hour' : 'minutes';
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, pl: 2, position: 'relative' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>↳ and if I don&apos;t respond after</Typography>
<InputBase
type="number"
value={tier.after_minutes}
onChange={(e) => onChange({ after_minutes: Math.max(0, Number(e.target.value) || 0) })}
sx={{ width: 44, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }}
/>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>{unitLabel}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Select
size="small"
value={tier.kind}
onChange={(e) => onChange({ kind: e.target.value as PermissionTier['kind'] })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
{tier.kind !== 'call' && <MenuItem value="text">Text me</MenuItem>}
{tier.kind === 'call' && <MenuItem value="call">Call me</MenuItem>}
</Select>
<Typography sx={{ fontSize: HINT_FS, color: c.text.muted }}>at this number</Typography>
<InputBase
value={tier.phone || ''}
placeholder="+1 (000) 123 4567"
onChange={(e) => onChange({ phone: e.target.value })}
sx={{ flex: 1, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4, color: c.text.primary }}
/>
{onRemove && (
<Box
onClick={onRemove}
role="button"
sx={{ fontSize: HINT_FS, color: c.text.ghost, cursor: 'pointer', px: 0.5, '&:hover': { color: c.status.error } }}>
×
</Box>
)}
</Box>
</Box>
);
}
function FieldRow({ label, children, align }: { label: string; children: React.ReactNode; align?: 'top' | 'center' }) {
const c = useClaudeTokens();
return (
<Box sx={{ display: 'flex', alignItems: align === 'top' ? 'flex-start' : 'center', gap: 1 }}>
<Typography sx={{ width: 100, flexShrink: 0, fontSize: LABEL_FS, color: c.text.secondary, mt: align === 'top' ? 0.75 : 0, fontWeight: 500 }}>{label}:</Typography>
{children}
</Box>
);
}
function ActionBtn({ label, tone, disabled, onClick }: { label: string; tone: 'muted' | 'success'; disabled?: boolean; onClick: () => void }) {
const c = useClaudeTokens();
const isSuccess = tone === 'success';
return (
<Box
onClick={disabled ? undefined : onClick}
role="button"
sx={{
fontSize: LABEL_FS, fontWeight: 600, px: 1.25, py: 0.5,
borderRadius: `${c.radius.md}px`,
cursor: disabled ? 'not-allowed' : 'pointer',
color: isSuccess ? c.status.success : c.text.secondary,
bgcolor: isSuccess ? c.status.successBg : c.bg.secondary,
border: `1px solid ${isSuccess ? c.status.success + '60' : c.border.subtle}`,
opacity: disabled ? 0.5 : 1,
'&:hover': { bgcolor: isSuccess ? c.status.success + '30' : c.bg.elevated },
}}>
{label}
</Box>
);
}
@@ -0,0 +1,455 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import InputBase from '@mui/material/InputBase';
import CloseIcon from '@mui/icons-material/Close';
import AddIcon from '@mui/icons-material/Add';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import SearchIcon from '@mui/icons-material/Search';
import MenuIcon from '@mui/icons-material/Menu';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
addWorkflowCard,
closeWorkflowsHub,
setWorkflowsHubPosition,
setWorkflowsHubSize,
} from '@/shared/state/dashboardLayoutSlice';
import { openWorkflowCard } from '@/shared/state/workflowsSlice';
import ScheduleCalendar from './ScheduleCalendar';
import { WEEKDAY_LABEL, addDays, sameDay, startOfMonthGrid } from './scheduleUtils';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
const EDGE_THICKNESS = 6;
const CORNER_SIZE = 14;
const MIN_W = 720;
const MIN_H = 420;
const CURSOR_MAP: Record<ResizeDir, string> = {
n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize',
nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize',
};
const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
{ dir: 'n', sx: { top: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } },
{ dir: 's', sx: { bottom: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } },
{ dir: 'w', sx: { left: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } },
{ dir: 'e', sx: { right: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } },
{ dir: 'nw', sx: { top: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
{ dir: 'ne', sx: { top: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
{ dir: 'sw', sx: { bottom: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
{ dir: 'se', sx: { bottom: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
];
interface Props {
cardX: number;
cardY: number;
cardWidth: number;
cardHeight: number;
cardZOrder?: number;
zoom?: number;
panX?: number;
panY?: number;
}
type CalendarView = 'Week' | 'Month' | 'List';
const WorkflowsHubCard: React.FC<Props> = ({
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
zoom = 1, panX = 0, panY = 0,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const workflows = useAppSelector((s) => s.workflows.items);
const [view, setView] = useState<CalendarView>('Week');
const [viewOpen, setViewOpen] = useState(false);
const [refDate, setRefDate] = useState(new Date());
const [search, setSearch] = useState('');
const scheduled = useMemo(() => Object.values(workflows).filter((w) => w.schedule.enabled), [workflows]);
const unscheduled = useMemo(() => Object.values(workflows).filter((w) => !w.schedule.enabled), [workflows]);
const monthLabel = refDate.toLocaleString('en', { month: 'long', year: 'numeric' });
const onSelectWorkflow = useCallback((wid: string) => {
dispatch(addWorkflowCard({ workflowId: wid }));
dispatch(openWorkflowCard({ workflowId: wid, view: 'saved' }));
}, [dispatch]);
const onNew = useCallback(() => {
const tempId = `draft-${Date.now()}`;
dispatch(addWorkflowCard({ workflowId: tempId }));
dispatch(openWorkflowCard({
workflowId: tempId,
view: 'preview',
draft: { title: 'New workflow', description: 'Describe what this workflow should do.', steps: [{ id: 'step-1', text: '' }] },
}));
}, [dispatch]);
// ---- Card drag via header ----
const DRAG_THRESHOLD = 3;
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
const didDrag = useRef(false);
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const onHeaderPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('[data-no-drag], button, [role="button"], input, textarea, select')) return;
e.preventDefault();
e.stopPropagation();
dragState.current = {
startX: e.clientX, startY: e.clientY,
origX: cardX, origY: cardY,
startPanX: panRef.current.panX, startPanY: panRef.current.panY,
};
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, [cardX, cardY]);
const onHeaderPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const rawDx = e.clientX - dragState.current.startX;
const rawDy = e.clientY - dragState.current.startY;
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
didDrag.current = true;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
setLocalDragPos({
x: dragState.current.origX + rawDx / z - panDx,
y: dragState.current.origY + rawDy / z - panDy,
});
}, []);
const onHeaderPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
let finalX = dragState.current.origX + dx;
let finalY = dragState.current.origY + dy;
if (!e.shiftKey) {
finalX = Math.round(finalX / 24) * 24;
finalY = Math.round(finalY / 24) * 24;
}
dispatch(setWorkflowsHubPosition({ x: finalX, y: finalY }));
}
dragState.current = null;
didDrag.current = false;
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch]);
// ---- Resize ----
const resizeRef = useRef<{ dir: ResizeDir; sx0: number; sy0: number; ox: number; oy: number; ow: number; oh: number } | null>(null);
const [isResizing, setIsResizing] = useState(false);
const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const onResizeDown = useCallback((dir: ResizeDir) => (e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
resizeRef.current = { dir, sx0: e.clientX, sy0: e.clientY, ox: cardX, oy: cardY, ow: cardWidth, oh: cardHeight };
setIsResizing(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [cardX, cardY, cardWidth, cardHeight]);
const compute = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return null;
const { dir, sx0, sy0, ox, oy, ow, oh } = resizeRef.current;
const dx = (e.clientX - sx0) / zoom;
const dy = (e.clientY - sy0) / zoom;
let nx = ox, ny = oy, nw = ow, nh = oh;
if (dir.includes('e')) nw = ow + dx;
if (dir.includes('w')) { nw = ow - dx; nx = ox + dx; }
if (dir.includes('s')) nh = oh + dy;
if (dir.includes('n')) { nh = oh - dy; ny = oy + dy; }
if (nw < MIN_W) { if (dir.includes('w')) nx = ox + ow - MIN_W; nw = MIN_W; }
if (nh < MIN_H) { if (dir.includes('n')) ny = oy + oh - MIN_H; nh = MIN_H; }
return { x: nx, y: ny, w: nw, h: nh };
}, [zoom]);
const onResizeMove = useCallback((e: React.PointerEvent) => {
const r = compute(e);
if (r) setLocalResize(r);
}, [compute]);
const onResizeUp = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return;
const r = compute(e);
if (r) {
dispatch(setWorkflowsHubPosition({ x: r.x, y: r.y }));
dispatch(setWorkflowsHubSize({ width: r.w, height: r.h }));
}
resizeRef.current = null;
setLocalResize(null);
setIsResizing(false);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
}, [compute, dispatch]);
const dx = localResize?.x ?? localDragPos?.x ?? cardX;
const dy = localResize?.y ?? localDragPos?.y ?? cardY;
const dw = localResize?.w ?? cardWidth;
const dh = localResize?.h ?? cardHeight;
return (
<Box
data-select-type="workflows-hub-card"
sx={{
position: 'absolute',
contain: 'layout style',
willChange: 'transform',
left: dx,
top: dy,
width: dw,
height: dh,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
borderRadius: `${c.radius.lg}px`,
boxShadow: (isDragging || isResizing) ? c.shadow.lg : c.shadow.md,
display: 'flex',
flexDirection: 'column',
zIndex: (isDragging || isResizing) ? 999999 : cardZOrder,
transition: (isDragging || isResizing) ? 'none' : 'box-shadow 0.3s ease',
'&:hover .resize-handle': { opacity: 1 },
}}
>
{/* ===== Title strip (drag handle) ===== */}
<Box
onPointerDown={onHeaderPointerDown}
onPointerMove={onHeaderPointerMove}
onPointerUp={onHeaderPointerUp}
sx={{
display: 'flex', alignItems: 'center', gap: 0.6,
px: 1.5, py: 0.6,
borderBottom: `1px solid ${c.border.subtle}`,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none', userSelect: 'none',
flexShrink: 0,
}}
>
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 16, height: 16, color: c.accent.primary, fontSize: 14 }}>⚡</Box>
<Typography sx={{ flex: 1, fontWeight: 700, fontSize: '0.88rem', color: c.text.primary }}>Workflows</Typography>
<IconButton
size="small"
data-no-drag
onClick={(e) => { e.stopPropagation(); dispatch(closeWorkflowsHub()); }}
onPointerDown={(e) => e.stopPropagation()}
sx={{ p: 0.35, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}
>
<CloseIcon sx={{ fontSize: 15 }} />
</IconButton>
</Box>
{/* ===== Toolbar row (matches Figma image #8 header) ===== */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.65, px: 1.5, py: 0.7, borderBottom: `1px solid ${c.border.subtle}`, flexShrink: 0 }}>
<IconButton size="small" data-no-drag sx={{ p: 0.5, color: c.text.muted }}>
<MenuIcon sx={{ fontSize: 18 }} />
</IconButton>
<Box
onClick={onNew}
role="button"
data-no-drag
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
fontSize: '0.85rem', fontWeight: 600, color: c.text.primary,
bgcolor: c.bg.elevated, border: `1px solid ${c.border.subtle}`,
px: 1, py: 0.4, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
'&:hover': { borderColor: c.accent.primary, color: c.accent.primary },
}}
>
<AddIcon sx={{ fontSize: 14 }} />
New
</Box>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.75 }}>
<Box
onClick={() => setRefDate(new Date())}
role="button"
data-no-drag
sx={{
fontSize: '0.82rem', fontWeight: 500, color: c.text.secondary,
border: `1px solid ${c.border.subtle}`,
px: 1.1, py: 0.35, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
'&:hover': { color: c.text.primary, borderColor: c.border.medium },
}}>Today</Box>
<IconButton size="small" data-no-drag onClick={() => setRefDate(addDays(refDate, view === 'Month' ? -28 : -7))} sx={{ p: 0.3 }}><ChevronLeftIcon sx={{ fontSize: 18 }} /></IconButton>
<IconButton size="small" data-no-drag onClick={() => setRefDate(addDays(refDate, view === 'Month' ? 28 : 7))} sx={{ p: 0.3 }}><ChevronRightIcon sx={{ fontSize: 18 }} /></IconButton>
<Typography sx={{ fontSize: '0.92rem', fontWeight: 600, color: c.text.primary }}>{monthLabel}</Typography>
</Box>
<IconButton size="small" data-no-drag sx={{ p: 0.5, color: c.text.muted }}>
<SearchIcon sx={{ fontSize: 18 }} />
</IconButton>
<Box sx={{ position: 'relative' }}>
<Box
onClick={() => setViewOpen((v) => !v)}
role="button"
data-no-drag
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.25,
fontSize: '0.82rem', fontWeight: 500, color: c.text.secondary,
border: `1px solid ${c.border.subtle}`, px: 1, py: 0.35,
borderRadius: `${c.radius.md}px`, cursor: 'pointer',
'&:hover': { color: c.text.primary, borderColor: c.border.medium },
}}>
{view}
<KeyboardArrowDownIcon sx={{ fontSize: 16 }} />
</Box>
{viewOpen && (
<Box sx={{ position: 'absolute', top: '100%', right: 0, mt: 0.5, bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, boxShadow: c.shadow.md, zIndex: 5, minWidth: 110 }}>
{(['Week', 'Month', 'List'] as const).map((v) => (
<Box
key={v}
data-no-drag
onClick={() => { setView(v); setViewOpen(false); }}
sx={{ px: 1.25, py: 0.65, fontSize: '0.85rem', color: view === v ? c.accent.primary : c.text.primary, fontWeight: view === v ? 600 : 400, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}>
{v}
</Box>
))}
</Box>
)}
</Box>
</Box>
{/* ===== Body: sidebar + main calendar ===== */}
<Box sx={{ flex: 1, display: 'flex', minHeight: 0 }}>
{/* Sidebar */}
<Box sx={{ width: 240, flexShrink: 0, borderRight: `1px solid ${c.border.subtle}`, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ px: 1.5, pt: 1.25, pb: 0.75 }}>
<InputBase
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search workflows"
startAdornment={<SearchIcon sx={{ fontSize: 16, color: c.text.muted, mr: 0.75 }} />}
sx={{ fontSize: '0.82rem', color: c.text.primary, width: '100%', '& input::placeholder': { color: c.text.ghost, opacity: 1 } }}
/>
</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 />
<SidebarSection title="Un-scheduled workflows" items={unscheduled.filter((w) => match(w.title, search))} onPick={onSelectWorkflow} scheduled={false} />
</Box>
</Box>
{/* Main calendar area */}
<Box sx={{ flex: 1, minWidth: 0, overflow: 'auto', p: 1.5 }}>
<ScheduleCalendar view={view} density="roomy" onSelectWorkflow={onSelectWorkflow} refDate={refDate} />
</Box>
</Box>
{/* Resize handles */}
{HANDLE_DEFS.map(({ dir, sx }) => (
<Box
key={dir}
className="resize-handle"
onPointerDown={onResizeDown(dir)}
onPointerMove={onResizeMove}
onPointerUp={onResizeUp}
sx={{ position: 'absolute', cursor: CURSOR_MAP[dir], opacity: 0, zIndex: 25, ...sx }}
/>
))}
</Box>
);
};
function MiniMonth({ refDate, onPick }: { refDate: Date; onPick: (d: Date) => void }) {
const c = useClaudeTokens();
const start = startOfMonthGrid(refDate);
const cells = Array.from({ length: 35 }, (_, i) => addDays(start, i));
const today = new Date();
const label = refDate.toLocaleString('en', { month: 'long', year: 'numeric' });
return (
<Box sx={{ px: 1.5, pb: 1, borderBottom: `1px solid ${c.border.subtle}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', py: 0.5 }}>
<Typography sx={{ flex: 1, fontSize: '0.82rem', fontWeight: 700, color: c.text.primary }}>{label}</Typography>
<IconButton size="small" data-no-drag onClick={() => onPick(addMonths(refDate, -1))} sx={{ p: 0.15 }}><ChevronLeftIcon sx={{ fontSize: 14 }} /></IconButton>
<IconButton size="small" data-no-drag onClick={() => onPick(addMonths(refDate, 1))} sx={{ p: 0.15 }}><ChevronRightIcon sx={{ fontSize: 14 }} /></IconButton>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
{WEEKDAY_LABEL.map((l, i) => (
<Typography key={`${l}-${i}`} sx={{ textAlign: 'center', fontSize: '0.66rem', color: c.text.muted, fontWeight: 600, py: 0.2 }}>{l}</Typography>
))}
{cells.map((d) => {
const isToday = sameDay(d, today);
const inMonth = d.getMonth() === refDate.getMonth();
const selected = sameDay(d, refDate);
return (
<Box key={d.toISOString()} onClick={() => onPick(d)} data-no-drag sx={{ textAlign: 'center', py: 0.2, opacity: inMonth ? 1 : 0.4, cursor: 'pointer' }}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 22, height: 22, borderRadius: '50%', bgcolor: isToday ? c.accent.primary : selected ? c.accent.primary + '30' : 'transparent', color: isToday ? '#fff' : c.text.secondary, fontWeight: isToday ? 700 : 500, fontSize: '0.72rem' }}>{d.getDate()}</Box>
</Box>
);
})}
</Box>
</Box>
);
}
function SidebarSection({ title, items, onPick, scheduled }: {
title: string;
items: { id: string; title: string; schedule: { enabled: boolean } }[];
onPick: (id: string) => void;
scheduled: boolean;
}) {
const c = useClaudeTokens();
const [open, setOpen] = useState(true);
return (
<Box sx={{ mt: 1.5 }}>
<Box
onClick={() => setOpen((v) => !v)}
role="button"
data-no-drag
sx={{ display: 'flex', alignItems: 'center', mb: 0.5, cursor: 'pointer', '&:hover .section-chev': { color: c.text.primary } }}>
<Typography sx={{ flex: 1, fontSize: '0.78rem', fontWeight: 700, color: c.text.secondary }}>{title}</Typography>
<KeyboardArrowDownIcon className="section-chev" sx={{ fontSize: 14, color: c.text.muted, transform: open ? 'rotate(0deg)' : 'rotate(-90deg)', transition: 'transform 0.15s ease' }} />
</Box>
{open && items.length === 0 && (
<Typography sx={{ fontSize: '0.76rem', color: c.text.muted, fontStyle: 'italic', py: 0.5, pl: 0.5 }}>None yet</Typography>
)}
{open && items.map((w) => (
<Box
key={w.id}
onClick={() => onPick(w.id)}
data-no-drag
sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.4, pl: 0.5, color: c.text.primary, borderRadius: 0.5, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}>
{scheduled ? (
<Box sx={{ width: 11, height: 11, border: `1.5px solid ${c.accent.primary}`, bgcolor: c.accent.primary, borderRadius: 0.25, flexShrink: 0 }} />
) : (
<AddIcon sx={{ fontSize: 13, color: c.text.muted, flexShrink: 0 }} />
)}
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{w.title}</Typography>
</Box>
))}
</Box>
);
}
function match(title: string, query: string): boolean {
if (!query.trim()) return true;
return title.toLowerCase().includes(query.trim().toLowerCase());
}
function addMonths(d: Date, n: number): Date {
const x = new Date(d);
x.setMonth(x.getMonth() + n);
return x;
}
export default React.memo(WorkflowsHubCard);
@@ -0,0 +1,127 @@
import type { Workflow, ScheduleConfig } from '@/shared/state/workflowsSlice';
export const WEEKDAY_LABEL = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
export const WEEKDAY_LABEL_SHORT = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
export const WEEKDAY_FULL = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export function defaultSchedule(): ScheduleConfig {
return {
enabled: false,
repeat_every: 1,
repeat_unit: 'week',
on_days: [],
hour: 9,
minute: 0,
timezone: 'local',
on_missed: 'skip',
};
}
export function formatTime(hour: number, minute: number): string {
const h12 = ((hour + 11) % 12) + 1;
const suffix = hour < 12 ? 'am' : 'pm';
const mm = String(minute).padStart(2, '0');
return minute === 0 ? `${h12}${suffix}` : `${h12}:${mm}${suffix}`;
}
// Used in the roomy hub calendar: "10 AM", "12 PM", "1 PM"...
// Matches Figma image #8 styling for the left-column time labels.
export function formatHourLabel(hour: number): string {
const h12 = ((hour + 11) % 12) + 1;
const suffix = hour < 12 ? 'AM' : 'PM';
return `${h12} ${suffix}`;
}
export function describeSchedule(sched: ScheduleConfig): string {
if (!sched.enabled) return 'Not scheduled';
const time = formatTime(sched.hour, sched.minute);
if (sched.repeat_unit === 'day') {
return sched.repeat_every === 1 ? `Every day at ${time}` : `Every ${sched.repeat_every} days at ${time}`;
}
if (sched.repeat_unit === 'month') {
return sched.repeat_every === 1 ? `Every month at ${time}` : `Every ${sched.repeat_every} months at ${time}`;
}
const days = sched.on_days.length === 0 ? 'week' : sched.on_days
.slice()
.sort()
.map((d) => WEEKDAY_FULL[d])
.join(', ');
const cadence = sched.repeat_every === 1 ? `Every ${days}` : `Every ${sched.repeat_every} weeks on ${days}`;
return `${cadence} at ${time}`;
}
export function describePermissions(workflow: Workflow): string {
if (!workflow.permissions || workflow.permissions.length === 0) return 'Notify only';
const labels: string[] = [];
for (const p of workflow.permissions) {
if (p.kind === 'notify') labels.push('notify in app');
else if (p.kind === 'text') labels.push('text');
else if (p.kind === 'call') labels.push('call');
}
return `First ${labels.join(', then ')}`;
}
export function startOfWeek(date: Date): Date {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - d.getDay());
return d;
}
export function startOfMonthGrid(date: Date): Date {
const d = new Date(date.getFullYear(), date.getMonth(), 1);
d.setDate(d.getDate() - d.getDay());
return d;
}
export function sameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
export function addDays(date: Date, n: number): Date {
const d = new Date(date);
d.setDate(d.getDate() + n);
return d;
}
export function fireTimesWithin(workflow: Workflow, from: Date, to: Date, cap = 40): Date[] {
const sched = workflow.schedule;
if (!sched.enabled) return [];
const out: Date[] = [];
const cursor = new Date(from);
cursor.setHours(0, 0, 0, 0);
if (sched.repeat_unit === 'day') {
const step = Math.max(1, sched.repeat_every);
for (let i = 0; i < 366 && out.length < cap; i += step) {
const d = new Date(cursor);
d.setDate(d.getDate() + i);
d.setHours(sched.hour, sched.minute, 0, 0);
if (d >= from && d <= to) out.push(d);
if (d > to) break;
}
return out;
}
if (sched.repeat_unit === 'month') {
let d = new Date(from.getFullYear(), from.getMonth(), Math.min(28, from.getDate()), sched.hour, sched.minute);
let guard = 0;
while (d <= to && out.length < cap && guard < 60) {
if (d >= from) out.push(new Date(d));
d = new Date(d.getFullYear(), d.getMonth() + Math.max(1, sched.repeat_every), d.getDate(), sched.hour, sched.minute);
guard += 1;
}
return out;
}
const allowed = sched.on_days.length ? sched.on_days : [from.getDay()];
for (let i = 0; i < 60 && out.length < cap; i += 1) {
const day = new Date(cursor);
day.setDate(day.getDate() + i);
if (!allowed.includes(day.getDay())) continue;
day.setHours(sched.hour, sched.minute, 0, 0);
if (day >= from && day <= to) out.push(day);
if (day > to) break;
}
return out;
}
@@ -20,6 +20,10 @@ export const DEFAULT_VIEW_CARD_W = 1280;
export const DEFAULT_VIEW_CARD_H = 800;
export const DEFAULT_BROWSER_CARD_W = 1280;
export const DEFAULT_BROWSER_CARD_H = 800;
export const DEFAULT_WORKFLOW_CARD_W = 440;
export const DEFAULT_WORKFLOW_CARD_H = 520;
export const DEFAULT_WORKFLOWS_HUB_W = 1200;
export const DEFAULT_WORKFLOWS_HUB_H = 640;
export const EXPANDED_CARD_MIN_H = 620;
export const GRID_GAP = 24;
const GRID_ORIGIN = { x: 40, y: 100 };
@@ -66,6 +70,26 @@ export interface BrowserCardPosition {
spawned_by?: string | null;
}
export interface WorkflowCardPosition {
workflow_id: string;
x: number;
y: number;
width: number;
height: number;
zOrder: number;
source_session_id?: string | null;
}
// Singleton per dashboard. There's only one Workflows Hub card open at a
// time (the calendar + sidebar view), so it doesn't need an id keyspace.
export interface WorkflowsHubPosition {
x: number;
y: number;
width: number;
height: number;
zOrder: number;
}
export type NoteColor = 'yellow' | 'pink' | 'blue' | 'green' | 'purple' | 'gray';
export interface NotePosition {
@@ -86,6 +110,8 @@ export interface DashboardLayoutState {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
workflowCards: Record<string, WorkflowCardPosition>;
workflowsHub: WorkflowsHubPosition | null;
notes: Record<string, NotePosition>;
closedCardPositions: Record<string, CardPosition>;
glowingBrowserCards: Record<string, { sourceId: string; fading: boolean; label?: string }>;
@@ -100,12 +126,20 @@ export interface DashboardLayoutState {
// to center on the new card, then dispatches clearPendingFocusBrowserId.
pendingFocusBrowserId: string | null;
pendingFocusNoteId: string | null;
pendingFocusWorkflowId: string | null;
// Transient flag: when openWorkflowsHub creates/raises the singleton hub
// card, set to true so Dashboard.tsx can pan/zoom-to-fit on the new card.
// Without this, the hub spawns at an open grid cell which may be far
// from the user's current pan, and "click Expand" looks like a no-op.
pendingFocusWorkflowsHub: boolean;
}
const initialState: DashboardLayoutState = {
cards: {},
viewCards: {},
browserCards: {},
workflowCards: {},
workflowsHub: null,
notes: {},
closedCardPositions: {},
glowingBrowserCards: {},
@@ -116,12 +150,16 @@ const initialState: DashboardLayoutState = {
initialized: false,
pendingFocusBrowserId: null,
pendingFocusNoteId: null,
pendingFocusWorkflowId: null,
pendingFocusWorkflowsHub: false,
};
interface LayoutPayload {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
workflowCards: Record<string, WorkflowCardPosition>;
workflowsHub: WorkflowsHubPosition | null;
notes: Record<string, NotePosition>;
expandedSessionIds: string[];
}
@@ -154,6 +192,8 @@ export const fetchLayout = createAsyncThunk(
cards: (layout.cards ?? {}) as Record<string, CardPosition>,
viewCards: (layout.view_cards ?? {}) as Record<string, ViewCardPosition>,
browserCards: browserCards as Record<string, BrowserCardPosition>,
workflowCards: (layout.workflow_cards ?? {}) as Record<string, WorkflowCardPosition>,
workflowsHub: (layout.workflows_hub ?? null) as WorkflowsHubPosition | null,
notes: (layout.notes ?? {}) as Record<string, NotePosition>,
expandedSessionIds: (layout.expanded_session_ids ?? []) as string[],
} satisfies LayoutPayload;
@@ -175,6 +215,8 @@ export const saveLayout = createAsyncThunk(
cards: payload.cards,
view_cards: payload.viewCards,
browser_cards: payload.browserCards,
workflow_cards: payload.workflowCards,
workflows_hub: payload.workflowsHub,
notes: payload.notes,
expanded_session_ids: payload.expandedSessionIds,
},
@@ -211,6 +253,12 @@ function collectOccupiedRects(
for (const c of Object.values(state.browserCards)) {
rects.push({ x: c.x, y: c.y, w: c.width, h: c.height });
}
for (const w of Object.values(state.workflowCards)) {
rects.push({ x: w.x, y: w.y, w: w.width, h: w.height });
}
if (state.workflowsHub) {
rects.push({ x: state.workflowsHub.x, y: state.workflowsHub.y, w: state.workflowsHub.width, h: state.workflowsHub.height });
}
for (const n of Object.values(state.notes)) {
rects.push({ x: n.x, y: n.y, w: n.width, h: n.height });
}
@@ -371,7 +419,7 @@ const dashboardLayoutSlice = createSlice({
bringToFront(
state,
action: PayloadAction<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' }>,
action: PayloadAction<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' | 'workflow' | 'workflows-hub' }>,
) {
const { id, type } = action.payload;
const z = state.nextZOrder++;
@@ -384,6 +432,11 @@ const dashboardLayoutSlice = createSlice({
} else if (type === 'note') {
const note = state.notes[id];
if (note) note.zOrder = z;
} else if (type === 'workflow') {
const card = state.workflowCards[id];
if (card) card.zOrder = z;
} else if (type === 'workflows-hub') {
if (state.workflowsHub) state.workflowsHub.zOrder = z;
} else {
const card = state.browserCards[id];
if (card) card.zOrder = z;
@@ -439,13 +492,15 @@ const dashboardLayoutSlice = createSlice({
const agentCards = Object.values(state.cards);
const viewCards = Object.values(state.viewCards);
const bCards = Object.values(state.browserCards);
const total = agentCards.length + viewCards.length + bCards.length;
const wCards = Object.values(state.workflowCards);
const total = agentCards.length + viewCards.length + bCards.length + wCards.length;
if (total === 0) return;
const allItems = [
...agentCards.map((c) => ({ kind: 'agent' as const, id: c.session_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
...viewCards.map((c) => ({ kind: 'view' as const, id: c.output_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
...bCards.map((c) => ({ kind: 'browser' as const, id: c.browser_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
...wCards.map((c) => ({ kind: 'workflow' as const, id: c.workflow_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
];
allItems.sort((a, b) => a.y - b.y || a.x - b.x);
@@ -470,6 +525,9 @@ const dashboardLayoutSlice = createSlice({
} else if (item.kind === 'view') {
const card = state.viewCards[item.id];
if (card) { card.x = pos.x; card.y = pos.y; }
} else if (item.kind === 'workflow') {
const card = state.workflowCards[item.id];
if (card) { card.x = pos.x; card.y = pos.y; }
} else {
const card = state.browserCards[item.id];
if (card) { card.x = pos.x; card.y = pos.y; }
@@ -601,6 +659,129 @@ const dashboardLayoutSlice = createSlice({
delete state.browserCards[action.payload];
},
addWorkflowCard(
state,
action: PayloadAction<{
workflowId: string;
sourceSessionId?: string | null;
expandedSessionIds?: string[];
}>,
) {
const { workflowId, sourceSessionId, expandedSessionIds } = action.payload;
if (state.workflowCards[workflowId]) {
// Already on the canvas; just raise it and signal focus.
state.workflowCards[workflowId].zOrder = state.nextZOrder++;
state.pendingFocusWorkflowId = workflowId;
return;
}
const rects = collectOccupiedRects(state, expandedSessionIds);
let posX: number, posY: number;
const parentCard = sourceSessionId ? state.cards[sourceSessionId] : null;
if (parentCard) {
const anchorX = parentCard.x + parentCard.width + GRID_GAP * 6;
const anchorY = parentCard.y;
const pos = findOpenSpotNear(anchorX, anchorY, rects, DEFAULT_WORKFLOW_CARD_W, DEFAULT_WORKFLOW_CARD_H);
posX = pos.x;
posY = pos.y;
} else {
const pos = findOpenGridCell(rects, DEFAULT_WORKFLOW_CARD_W, DEFAULT_WORKFLOW_CARD_H);
posX = pos.x;
posY = pos.y;
}
state.workflowCards[workflowId] = {
workflow_id: workflowId,
x: posX,
y: posY,
width: DEFAULT_WORKFLOW_CARD_W,
height: DEFAULT_WORKFLOW_CARD_H,
zOrder: state.nextZOrder++,
source_session_id: sourceSessionId || null,
};
state.pendingFocusWorkflowId = workflowId;
},
setWorkflowCardPosition(
state,
action: PayloadAction<{ workflowId: string; x: number; y: number }>,
) {
const { workflowId, x, y } = action.payload;
const card = state.workflowCards[workflowId];
if (card) { card.x = x; card.y = y; }
},
setWorkflowCardSize(
state,
action: PayloadAction<{ workflowId: string; width: number; height: number }>,
) {
const { workflowId, width, height } = action.payload;
const card = state.workflowCards[workflowId];
if (card) {
card.width = Math.max(360, width);
card.height = Math.max(280, height);
}
},
removeWorkflowCard(state, action: PayloadAction<string>) {
delete state.workflowCards[action.payload];
},
// When a draft workflow is saved, the temporary `draft-...` id is
// replaced by the real workflow id from the server. Rekey the layout
// entry in-place so the card doesn't visibly hop position.
rekeyWorkflowCard(
state,
action: PayloadAction<{ oldId: string; newId: string }>,
) {
const { oldId, newId } = action.payload;
const card = state.workflowCards[oldId];
if (!card) return;
delete state.workflowCards[oldId];
state.workflowCards[newId] = { ...card, workflow_id: newId };
if (state.pendingFocusWorkflowId === oldId) state.pendingFocusWorkflowId = newId;
},
clearPendingFocusWorkflowId(state) {
state.pendingFocusWorkflowId = null;
},
openWorkflowsHub(state, action: PayloadAction<{ expandedSessionIds?: string[] } | undefined>) {
if (state.workflowsHub) {
state.workflowsHub.zOrder = state.nextZOrder++;
state.pendingFocusWorkflowsHub = true;
return;
}
const rects = collectOccupiedRects(state, action.payload?.expandedSessionIds);
const pos = findOpenGridCell(rects, DEFAULT_WORKFLOWS_HUB_W, DEFAULT_WORKFLOWS_HUB_H);
state.workflowsHub = {
x: pos.x,
y: pos.y,
width: DEFAULT_WORKFLOWS_HUB_W,
height: DEFAULT_WORKFLOWS_HUB_H,
zOrder: state.nextZOrder++,
};
state.pendingFocusWorkflowsHub = true;
},
clearPendingFocusWorkflowsHub(state) {
state.pendingFocusWorkflowsHub = false;
},
closeWorkflowsHub(state) {
state.workflowsHub = null;
},
setWorkflowsHubPosition(state, action: PayloadAction<{ x: number; y: number }>) {
if (!state.workflowsHub) return;
state.workflowsHub.x = action.payload.x;
state.workflowsHub.y = action.payload.y;
},
setWorkflowsHubSize(state, action: PayloadAction<{ width: number; height: number }>) {
if (!state.workflowsHub) return;
state.workflowsHub.width = Math.max(720, action.payload.width);
state.workflowsHub.height = Math.max(420, action.payload.height);
},
pasteBrowserCard(
state,
action: PayloadAction<{
@@ -749,7 +930,7 @@ const dashboardLayoutSlice = createSlice({
moveCards(
state,
action: PayloadAction<{
items: Array<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' }>;
items: Array<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' | 'workflow' }>;
dx: number;
dy: number;
}>,
@@ -774,6 +955,12 @@ const dashboardLayoutSlice = createSlice({
note.x += dx;
note.y += dy;
}
} else if (item.type === 'workflow') {
const card = state.workflowCards[item.id];
if (card) {
card.x += dx;
card.y += dy;
}
} else {
const card = state.browserCards[item.id];
if (card) {
@@ -901,6 +1088,8 @@ const dashboardLayoutSlice = createSlice({
state.cards = {};
state.viewCards = {};
state.browserCards = {};
state.workflowCards = {};
state.workflowsHub = null;
state.notes = {};
state.closedCardPositions = {};
state.glowingBrowserCards = {};
@@ -909,6 +1098,7 @@ const dashboardLayoutSlice = createSlice({
state.nextZOrder = 1;
state.initialized = false;
state.pendingFocusNoteId = null;
state.pendingFocusWorkflowId = null;
},
},
@@ -923,6 +1113,8 @@ const dashboardLayoutSlice = createSlice({
state.cards = action.payload.cards;
state.viewCards = action.payload.viewCards;
state.browserCards = action.payload.browserCards;
state.workflowCards = action.payload.workflowCards || {};
state.workflowsHub = action.payload.workflowsHub || null;
state.notes = action.payload.notes || {};
state.persistedExpandedSessionIds = action.payload.expandedSessionIds;
@@ -940,6 +1132,10 @@ const dashboardLayoutSlice = createSlice({
if (!c.zOrder) c.zOrder = 0;
if (c.zOrder > maxZ) maxZ = c.zOrder;
}
for (const w of Object.values(state.workflowCards)) {
if (!w.zOrder) w.zOrder = 0;
if (w.zOrder > maxZ) maxZ = w.zOrder;
}
for (const n of Object.values(state.notes)) {
if (!n.zOrder) n.zOrder = 0;
if (n.zOrder > maxZ) maxZ = n.zOrder;
@@ -1010,6 +1206,17 @@ export const {
fadeGlowingAgentCard,
clearGlowingAgentCard,
clearPendingFocusBrowserId,
addWorkflowCard,
setWorkflowCardPosition,
setWorkflowCardSize,
removeWorkflowCard,
rekeyWorkflowCard,
clearPendingFocusWorkflowId,
openWorkflowsHub,
closeWorkflowsHub,
setWorkflowsHubPosition,
setWorkflowsHubSize,
clearPendingFocusWorkflowsHub,
addNote,
setNotePosition,
setNoteSize,
+2
View File
@@ -15,6 +15,7 @@ import updateReducer from './updateSlice';
import modelsReducer from './modelsSlice';
import interactionReducer from './interactionSlice';
import subscriptionsReducer from './subscriptionsSlice';
import workflowsReducer from './workflowsSlice';
import onboardingProgressReducer from '@/app/components/Onboarding/OnboardingProgressSlice';
export const store = configureStore({
@@ -35,6 +36,7 @@ export const store = configureStore({
models: modelsReducer,
interaction: interactionReducer,
subscriptions: subscriptionsReducer,
workflows: workflowsReducer,
onboardingProgress: onboardingProgressReducer,
},
// Disable Redux Toolkit's dev-mode invariant middleware (serializable +
+210
View File
@@ -0,0 +1,210 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const API = `${API_BASE}/workflows`;
export type PermissionKind = 'notify' | 'text' | 'call';
export interface PermissionTier {
kind: PermissionKind;
after_minutes: number;
phone?: string | null;
}
export interface ScheduleConfig {
enabled: boolean;
repeat_every: number;
repeat_unit: 'day' | 'week' | 'month';
on_days: number[];
hour: number;
minute: number;
timezone: string;
on_missed: 'skip' | 'run_once' | 'run_all';
}
export interface ActionsConfig {
prevent_unused: boolean;
freeze: boolean;
configured_sets: string[];
}
export interface WorkflowStep {
id: string;
text: string;
}
export interface Workflow {
id: string;
title: string;
description: string;
icon: string;
system_prompt: string | null;
use_synced_prompt: boolean;
steps: WorkflowStep[];
actions: ActionsConfig;
schedule: ScheduleConfig;
permissions: PermissionTier[];
source_session_id?: string | null;
dashboard_id?: string | null;
model: string;
mode: string;
provider: string;
created_at: string;
updated_at: string;
last_run_at: string | null;
last_run_status: 'success' | 'failure' | 'ran_late' | 'running' | null;
last_run_id: string | null;
next_run_at: string | null;
}
export interface WorkflowRun {
id: string;
workflow_id: string;
status: 'running' | 'success' | 'failure' | 'ran_late' | 'skipped';
scheduled_for: string | null;
started_at: string;
finished_at: string | null;
session_id: string | null;
error: string | null;
cost_usd: number;
triggered_by: 'schedule' | 'manual' | 'retry';
}
// Position lives in dashboardLayoutSlice.workflowCards now. This entry
// only carries transient view state — which tab is open, draft contents
// for unsaved cards, the currently inspected history run, etc.
export interface OpenCard {
workflowId: string;
sourceSessionId?: string | null;
draft?: Partial<Workflow> | null;
view: 'preview' | 'saved' | 'edit' | 'history' | 'history_detail';
editFacet?: 'General' | 'Actions' | 'Schedule';
historyRunId?: string | null;
}
interface State {
items: Record<string, Workflow>;
runs: Record<string, WorkflowRun[]>;
openCards: Record<string, OpenCard>;
loaded: boolean;
loading: boolean;
}
const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false };
export const fetchWorkflows = createAsyncThunk(
'workflows/fetch',
async (dashboardId?: string) => {
const url = dashboardId ? `${API}/list?dashboard_id=${encodeURIComponent(dashboardId)}` : `${API}/list`;
const res = await fetch(url);
const data = await res.json();
return data.workflows as Workflow[];
},
{ condition: (_, { getState }) => !(getState() as { workflows: State }).workflows.loading },
);
export const createWorkflow = createAsyncThunk(
'workflows/create',
async (body: Partial<Workflow>) => {
const res = await fetch(`${API}/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`create failed ${res.status}`);
return (await res.json()) as Workflow;
},
);
export const updateWorkflow = createAsyncThunk(
'workflows/update',
async ({ id, patch }: { id: string; patch: Partial<Workflow> }) => {
const res = await fetch(`${API}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error(`update failed ${res.status}`);
return (await res.json()) as Workflow;
},
);
export const deleteWorkflow = createAsyncThunk('workflows/delete', async (id: string) => {
await fetch(`${API}/${id}`, { method: 'DELETE' });
return id;
});
export const runWorkflowNow = createAsyncThunk('workflows/run', async (id: string) => {
const res = await fetch(`${API}/${id}/run`, { method: 'POST' });
if (!res.ok) throw new Error(`run failed ${res.status}`);
const data = await res.json();
return { id, run_id: data.run_id as string };
});
export const fetchRuns = createAsyncThunk(
'workflows/runs',
async (id: string) => {
const res = await fetch(`${API}/${id}/runs?limit=50`);
const data = await res.json();
return { id, runs: data.runs as WorkflowRun[] };
},
);
const slice = createSlice({
name: 'workflows',
initialState,
reducers: {
openWorkflowCard(state, action: { payload: OpenCard }) {
state.openCards[action.payload.workflowId] = action.payload;
},
updateWorkflowCard(state, action: { payload: { workflowId: string; patch: Partial<OpenCard> } }) {
const existing = state.openCards[action.payload.workflowId];
if (existing) state.openCards[action.payload.workflowId] = { ...existing, ...action.payload.patch };
},
closeWorkflowCard(state, action: { payload: string }) {
delete state.openCards[action.payload];
},
rekeyOpenCard(state, action: { payload: { oldId: string; newId: string } }) {
const entry = state.openCards[action.payload.oldId];
if (!entry) return;
delete state.openCards[action.payload.oldId];
state.openCards[action.payload.newId] = { ...entry, workflowId: action.payload.newId };
},
upsertRun(state, action: { payload: WorkflowRun }) {
const r = action.payload;
const arr = state.runs[r.workflow_id] || [];
const idx = arr.findIndex((x) => x.id === r.id);
if (idx >= 0) arr[idx] = r; else arr.unshift(r);
state.runs[r.workflow_id] = arr.slice(0, 100);
const wf = state.items[r.workflow_id];
if (wf) {
wf.last_run_at = r.finished_at || r.started_at;
wf.last_run_status = r.status === 'skipped' ? wf.last_run_status : (r.status as Workflow['last_run_status']);
wf.last_run_id = r.id;
}
},
},
extraReducers: (builder) => {
builder
.addCase(fetchWorkflows.pending, (state) => { state.loading = true; })
.addCase(fetchWorkflows.fulfilled, (state, action) => {
state.loading = false;
state.loaded = true;
state.items = {};
for (const w of action.payload) state.items[w.id] = w;
})
.addCase(fetchWorkflows.rejected, (state) => { state.loading = false; state.loaded = true; })
.addCase(createWorkflow.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
.addCase(updateWorkflow.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
.addCase(deleteWorkflow.fulfilled, (state, action) => {
delete state.items[action.payload];
delete state.runs[action.payload];
})
.addCase(fetchRuns.fulfilled, (state, action) => {
state.runs[action.payload.id] = action.payload.runs;
});
},
});
export const { upsertRun, openWorkflowCard, updateWorkflowCard, closeWorkflowCard, rekeyOpenCard } = slice.actions;
export default slice.reducer;
@@ -24,6 +24,7 @@ import {
} from '../state/agentsSlice';
import { streamStart, streamDelta, streamEnd } from '../state/streamingSlice';
import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice';
import { upsertRun } from '../state/workflowsSlice';
import { getAuthToken } from '../config';
import { notifyAgentCompletion } from '../notifications';
@@ -687,6 +688,22 @@ class WebSocketManager {
}
break;
case 'workflow:run':
if (data.run) {
store.dispatch(upsertRun(data.run));
}
break;
case 'workflow:notify':
try {
notifyAgentCompletion({
sessionId: data.session_id || data.workflow_id,
sessionName: data.workflow_title || 'Workflow',
status: data.status === 'success' ? 'completed' : 'error',
});
} catch { /* notifications are best-effort */ }
break;
case 'dashboard:browser_card_added':
if (data.browser_card) {
store.dispatch(addBrowserCardFromBackend(data.browser_card));