mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-30 21:44:50 +02:00
[eric] workflows: restore scheduled-tasks on the workflow line (revert removal, keep windows fixes + 1.1.69)
This commit is contained in:
@@ -34,6 +34,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/editor/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/editor/ElementSelectionContext';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
@@ -173,6 +176,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);
|
||||
@@ -420,7 +424,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
padding: isExpanded ? '6px' : '5px',
|
||||
userSelect: 'none' as const,
|
||||
overflow: inputOpen || newAgentBounce || historyOpen ? 'visible' : 'hidden',
|
||||
// historyOpen: width owned by the inline history list; leave undefined so framer-motion measures intrinsic size.
|
||||
// historyOpen: width owned by SchedulePopover; leave undefined so framer-motion measures intrinsic size.
|
||||
width: viewPickerOpen ? 580 : historyOpen ? undefined : isExpanded ? 540 : undefined,
|
||||
}}
|
||||
>
|
||||
@@ -444,57 +448,34 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
/>
|
||||
</div>
|
||||
) : historyOpen ? (
|
||||
// Past-chat search list. Fixed-size bordered surface (matches the
|
||||
// toolbar popover footprint) with a search input + scrollable
|
||||
// results; clicking a row resumes that chat.
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', width: 620, maxWidth: 620, flexShrink: 0 }}>
|
||||
<Box sx={{
|
||||
width: '100%',
|
||||
height: 420,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, flexShrink: 0 }}>
|
||||
<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 } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
ref={historyListRef}
|
||||
onScroll={handleHistoryScroll}
|
||||
sx={{ flex: 1, overflowY: 'auto', borderTop: `1px solid ${c.border.subtle}` }}
|
||||
>
|
||||
{historySearch.results.length === 0 && !historySearch.loading && (
|
||||
<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>
|
||||
)}
|
||||
{historySearch.results.map((entry) => (
|
||||
<Box
|
||||
key={entry.id}
|
||||
onClick={() => handleHistorySelect(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>
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, flexShrink: 0, whiteSpace: 'nowrap' }}>
|
||||
{formatRelativeTime(entry.closed_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<div style={{ width: '100%' }}>
|
||||
<SchedulePopover
|
||||
mode={popoverMode}
|
||||
onModeChange={setPopoverMode}
|
||||
hideTopChrome
|
||||
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();
|
||||
}}
|
||||
onExpand={() => {
|
||||
// Singleton per dashboard, second Expand brings the existing card forward.
|
||||
dispatch(openWorkflowsHub({ expandedSessionIds: [] }));
|
||||
handleCloseHistory();
|
||||
}}
|
||||
historyScrollRef={historyListRef as React.RefObject<HTMLDivElement>}
|
||||
onHistoryScroll={handleHistoryScroll}
|
||||
/>
|
||||
</div>
|
||||
) : viewPickerOpen ? (
|
||||
<div style={{ width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1 }}>
|
||||
|
||||
@@ -12,6 +12,9 @@ import type {
|
||||
ViewCardPosition,
|
||||
BrowserCardPosition,
|
||||
NotePosition,
|
||||
WorkflowCardPosition,
|
||||
WorkflowsHubPosition,
|
||||
ConfigurePanelPosition,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { Output } from '@/shared/state/outputsSlice';
|
||||
import type { CardType, useDashboardSelection } from '../hooks/state/useDashboardSelection';
|
||||
@@ -37,6 +40,9 @@ interface DashboardCanvasProps {
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
notes: Record<string, NotePosition>;
|
||||
workflowCards: Record<string, WorkflowCardPosition>;
|
||||
workflowsHub: WorkflowsHubPosition | null;
|
||||
configurePanels: Record<string, ConfigurePanelPosition>;
|
||||
outputs: Record<string, Output>;
|
||||
glowingAgentCards: Record<string, GlowingAgentCard>;
|
||||
expandedSessionIds: string[];
|
||||
@@ -94,6 +100,9 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
viewCards,
|
||||
browserCards,
|
||||
notes,
|
||||
workflowCards,
|
||||
workflowsHub,
|
||||
configurePanels,
|
||||
outputs,
|
||||
glowingAgentCards,
|
||||
expandedSessionIds,
|
||||
@@ -211,7 +220,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 ? (
|
||||
{sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 && Object.keys(workflowCards).length === 0 && !workflowsHub ? (
|
||||
<DashboardEmptyState c={c} />
|
||||
) : (
|
||||
<div
|
||||
@@ -230,6 +239,9 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
viewCards={viewCards}
|
||||
browserCards={browserCards}
|
||||
notes={notes}
|
||||
workflowCards={workflowCards}
|
||||
workflowsHub={workflowsHub}
|
||||
configurePanels={configurePanels}
|
||||
outputs={outputs}
|
||||
glowingAgentCards={glowingAgentCards}
|
||||
expandedSessionIds={expandedSessionIds}
|
||||
|
||||
@@ -4,6 +4,9 @@ import AgentCard from '../cards/AgentCard';
|
||||
import DashboardViewCard from '../cards/DashboardViewCard';
|
||||
import BrowserCard from '../cards/BrowserCard';
|
||||
import NoteCard from '../cards/NoteCard';
|
||||
import WorkflowCard from '@/app/pages/Workflows/WorkflowCard';
|
||||
import WorkflowsHubCard from '@/app/pages/Workflows/WorkflowsHubCard';
|
||||
import ConfigurePanelCard from '@/app/pages/Workflows/ConfigurePanelCard';
|
||||
import {
|
||||
EXPANDED_CARD_MIN_H,
|
||||
DEFAULT_CARD_W,
|
||||
@@ -12,6 +15,9 @@ import {
|
||||
type ViewCardPosition,
|
||||
type BrowserCardPosition,
|
||||
type NotePosition,
|
||||
type WorkflowCardPosition,
|
||||
type WorkflowsHubPosition,
|
||||
type ConfigurePanelPosition,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { Output } from '@/shared/state/outputsSlice';
|
||||
import type { CardType, useDashboardSelection } from '../hooks/state/useDashboardSelection';
|
||||
@@ -26,6 +32,9 @@ interface DashboardCardLayerProps {
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
notes: Record<string, NotePosition>;
|
||||
workflowCards: Record<string, WorkflowCardPosition>;
|
||||
workflowsHub: WorkflowsHubPosition | null;
|
||||
configurePanels: Record<string, ConfigurePanelPosition>;
|
||||
outputs: Record<string, Output>;
|
||||
glowingAgentCards: Record<string, GlowingAgentCard>;
|
||||
expandedSessionIds: string[];
|
||||
@@ -59,6 +68,9 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
viewCards,
|
||||
browserCards,
|
||||
notes,
|
||||
workflowCards,
|
||||
workflowsHub,
|
||||
configurePanels,
|
||||
outputs,
|
||||
glowingAgentCards,
|
||||
expandedSessionIds,
|
||||
@@ -251,6 +263,48 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
|
||||
onBringToFront={onBringToFront}
|
||||
/>
|
||||
))}
|
||||
{workflowsHub && (
|
||||
<WorkflowsHubCard
|
||||
cardX={workflowsHub.x}
|
||||
cardY={workflowsHub.y}
|
||||
cardWidth={workflowsHub.width}
|
||||
cardHeight={workflowsHub.height}
|
||||
cardZOrder={workflowsHub.zOrder ?? 0}
|
||||
zoom={zoom}
|
||||
panX={panX}
|
||||
panY={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={zoom}
|
||||
panX={panX}
|
||||
panY={panY}
|
||||
isSelected={selection.isSelected(wc.workflow_id)}
|
||||
isHighlighted={highlightedCardId === wc.workflow_id}
|
||||
multiDragDelta={multiDragDelta}
|
||||
onCardSelect={onCardSelect}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onBringToFront={onBringToFront}
|
||||
/>
|
||||
))}
|
||||
{Object.values(configurePanels).map((p) => (
|
||||
<ConfigurePanelCard
|
||||
key={`configure-${p.workflow_id}`}
|
||||
panel={p}
|
||||
zOrder={1}
|
||||
/>
|
||||
))}
|
||||
{/* Marquee selection rectangle */}
|
||||
{selection.marquee && (
|
||||
<div
|
||||
|
||||
@@ -34,8 +34,32 @@ import { useDashboardActive } from '@/shared/hooks/useDashboardActive';
|
||||
import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough';
|
||||
import { useStreamingMessage } from '@/shared/state/streamingSlice';
|
||||
import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canvasInteractionState';
|
||||
import { openWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { addWorkflowCard, setWorkflowCardPosition, setWorkflowCardSize } from '@/shared/state/dashboardLayoutSlice';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesomeOutlined';
|
||||
import { getAgentWorkTime, fmtSeconds } from '@/shared/agentWorkTime';
|
||||
|
||||
/** Extract up to 3 substantive user-prompt steps to seed a workflow. */
|
||||
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;
|
||||
}
|
||||
|
||||
const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ service, size = 16 }) => {
|
||||
if (service === 'gmail') {
|
||||
return (
|
||||
@@ -221,6 +245,25 @@ const AgentCard: React.FC<Props> = ({
|
||||
const hasApiKey = !!useAppSelector((s) => s.settings.data.anthropic_api_key);
|
||||
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
|
||||
const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds);
|
||||
// Hide the "Convert to workflow" button when this chat is already
|
||||
// entangled with a workflow (Image #44 note). Two cases:
|
||||
// (a) The session is one of a workflow's runner sessions, OR
|
||||
// (b) The session is the source the workflow was originally derived
|
||||
// from. Either way a fresh convert would just clone the workflow,
|
||||
// which is confusing identity collapse.
|
||||
const workflowRunsMap = useAppSelector((s) => s.workflows.runs);
|
||||
const workflowItems = useAppSelector((s) => s.workflows.items);
|
||||
const isWorkflowRunnerSession = useMemo(() => {
|
||||
for (const arr of Object.values(workflowRunsMap || {})) {
|
||||
for (const r of arr || []) {
|
||||
if (r.session_id === session.id) return true;
|
||||
}
|
||||
}
|
||||
for (const wf of Object.values(workflowItems || {})) {
|
||||
if (wf.source_session_id === session.id) return true;
|
||||
}
|
||||
return false;
|
||||
}, [workflowRunsMap, workflowItems, session.id]);
|
||||
// Curated picker label with a tidy fallback for unknowns.
|
||||
const friendlyModelLabel = useMemo(() => {
|
||||
const value = session.model;
|
||||
@@ -855,6 +898,67 @@ 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 && !isWorkflowRunnerSession && (
|
||||
<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: '',
|
||||
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}`;
|
||||
// The OG chat card BECOMES the workflow card: capture
|
||||
// its position + size, remove the chat card, and drop
|
||||
// the workflow card in the same physical slot. The
|
||||
// chat session itself stays accessible via History.
|
||||
// Per Image #61 / #62: no tether arrow, no second
|
||||
// card alongside.
|
||||
// Capture this card's current position/size, drop the
|
||||
// workflow card in the same slot, then remove the
|
||||
// source chat card.
|
||||
dispatch(addWorkflowCard({
|
||||
workflowId: tempId,
|
||||
sourceSessionId: null,
|
||||
expandedSessionIds,
|
||||
}));
|
||||
dispatch(setWorkflowCardPosition({ workflowId: tempId, x: cardX, y: cardY }));
|
||||
dispatch(setWorkflowCardSize({ workflowId: tempId, width: cardWidth, height: cardHeight }));
|
||||
dispatch(removeCard(session.id));
|
||||
dispatch(openWorkflowCard({
|
||||
workflowId: tempId,
|
||||
sourceSessionId: null,
|
||||
view: 'preview',
|
||||
draft,
|
||||
}));
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
color: '#fff',
|
||||
bgcolor: c.accent.primary,
|
||||
border: `1px solid ${c.accent.primary}`,
|
||||
fontSize: '0.78rem', fontWeight: 700,
|
||||
px: 1.1, py: 0.5,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeIcon sx={{ fontSize: 14 }} />
|
||||
Convert to workflow
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={isDraft ? 'Remove' : 'Close chat'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
|
||||
@@ -2,6 +2,8 @@ import type {
|
||||
CardPosition,
|
||||
ViewCardPosition,
|
||||
BrowserCardPosition,
|
||||
WorkflowCardPosition,
|
||||
WorkflowsHubPosition,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
|
||||
export interface ContentBounds {
|
||||
@@ -17,11 +19,15 @@ export function computeContentBounds(
|
||||
cards: Record<string, CardPosition>,
|
||||
viewCards: Record<string, ViewCardPosition>,
|
||||
browserCards: Record<string, BrowserCardPosition>,
|
||||
workflowCards: Record<string, WorkflowCardPosition> = {},
|
||||
workflowsHub: WorkflowsHubPosition | null = null,
|
||||
): ContentBounds | undefined {
|
||||
const allRects = [
|
||||
...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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, type RefObject } from 'react';
|
||||
import type { CardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { EXPANDED_CARD_MIN_H } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardPosition, BrowserCardPosition, WorkflowCardPosition, ConfigurePanelPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { Workflow, OpenCard } from '@/shared/state/workflowsSlice';
|
||||
import { EXPANDED_CARD_MIN_H, GRID_GAP } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { AgentSession } from '@/shared/state/agentsSlice';
|
||||
|
||||
const ELBOW_RADIUS = 16;
|
||||
@@ -60,6 +61,10 @@ interface UseTethersArgs {
|
||||
glowingBrowserCards: Record<string, GlowingBrowserCard>;
|
||||
cards: Record<string, CardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
workflowCards: Record<string, WorkflowCardPosition>;
|
||||
workflowItems: Record<string, Workflow>;
|
||||
workflowOpenCards: Record<string, OpenCard>;
|
||||
configurePanels: Record<string, ConfigurePanelPosition>;
|
||||
expandedSessionIds: string[];
|
||||
liveDragInfo: LiveDragInfo | null;
|
||||
measuredHeightsRef: RefObject<Record<string, number>>;
|
||||
@@ -72,6 +77,10 @@ export function useTethers({
|
||||
glowingBrowserCards,
|
||||
cards,
|
||||
browserCards,
|
||||
workflowCards,
|
||||
workflowItems,
|
||||
workflowOpenCards,
|
||||
configurePanels,
|
||||
expandedSessionIds,
|
||||
liveDragInfo,
|
||||
measuredHeightsRef,
|
||||
@@ -226,9 +235,199 @@ export function useTethers({
|
||||
|
||||
const browserTethers = Array.from(glowTethers.values()).filter(Boolean) as Tether[];
|
||||
|
||||
return [...agentTethers, ...browserTethers];
|
||||
// Workflow tethers reuse the browser-tether anchor/elbow math; skip deleted workflows to avoid dangling arrows.
|
||||
const workflowTethers: Tether[] = [];
|
||||
for (const wc of Object.values(workflowCards)) {
|
||||
const sourceId = wc.source_session_id;
|
||||
if (!sourceId) continue;
|
||||
const src = cards[sourceId];
|
||||
if (!src) continue;
|
||||
// Layout entry can outlive its workflow when deleted from the hub.
|
||||
const hasReal = wc.workflow_id in workflowItems;
|
||||
const hasDraft = wc.workflow_id in workflowOpenCards;
|
||||
if (!hasReal && !hasDraft) continue;
|
||||
// "Make workflow" is a draft-time affordance; once saved (openCard leaves 'preview') the link retires.
|
||||
const openCard = workflowOpenCards[wc.workflow_id];
|
||||
if (openCard && openCard.view !== 'preview') 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,
|
||||
});
|
||||
}
|
||||
|
||||
// Sidecar tethers: workflow card to its sibling agent session (View Agent / Watch Live / Test Agent).
|
||||
for (const wc of Object.values(workflowCards)) {
|
||||
const openCard = workflowOpenCards[wc.workflow_id];
|
||||
if (!openCard?.sidecarSessionId || !openCard.sidecarKind) continue;
|
||||
const sidecarId = openCard.sidecarSessionId;
|
||||
const sidecar = cards[sidecarId];
|
||||
if (!sidecar) continue;
|
||||
let srcX = wc.x, srcY = wc.y;
|
||||
let dstX = sidecar.x, dstY = sidecar.y;
|
||||
if (liveDragInfo) {
|
||||
if (liveDragInfo.cardId === wc.workflow_id) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; }
|
||||
if (liveDragInfo.cardId === sidecarId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; }
|
||||
}
|
||||
const dstMeasured = measuredHeightsRef.current![sidecarId];
|
||||
const dstH = dstMeasured ?? (expandedSessionIds.includes(sidecarId)
|
||||
? Math.max(EXPANDED_CARD_MIN_H, sidecar.height)
|
||||
: sidecar.height);
|
||||
const srcCx = srcX + wc.width / 2;
|
||||
const dstCx = dstX + sidecar.width / 2;
|
||||
const srcAnchors: Anchor[] = [
|
||||
{ x: srcX + wc.width, y: srcY + wc.height * 0.54, side: 'right' },
|
||||
{ x: srcX, y: srcY + wc.height * 0.54, side: 'left' },
|
||||
{ x: srcCx, y: srcY, side: 'top' },
|
||||
{ x: srcCx, y: srcY + wc.height, side: 'bottom' },
|
||||
];
|
||||
const dstAnchors: Anchor[] = [
|
||||
{ x: dstX, y: dstY + dstH * 0.54, side: 'left' },
|
||||
{ x: dstX + sidecar.width, y: dstY + dstH * 0.54, side: 'right' },
|
||||
{ x: dstCx, y: dstY, side: 'top' },
|
||||
{ x: dstCx, y: dstY + dstH, 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 pathD = elbowPath(x1, y1, x2, y2);
|
||||
const midX = x1 + (x2 - x1) / 2;
|
||||
const midY = y1 + (y2 - y1) / 2;
|
||||
const sidecarLabel = openCard.sidecarKind === 'testing' ? 'Testing' : 'Watching';
|
||||
workflowTethers.push({
|
||||
key: `sidecar-${wc.workflow_id}`,
|
||||
path: pathD,
|
||||
labelX: midX,
|
||||
labelY: midY,
|
||||
label: sidecarLabel,
|
||||
fading: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Configure-panel tethers: anchor each open configure panel to its workflow card.
|
||||
const configureTethers: Tether[] = [];
|
||||
for (const p of Object.values(configurePanels)) {
|
||||
const wc = workflowCards[p.workflow_id];
|
||||
if (!wc) continue;
|
||||
let srcX = wc.x, srcY = wc.y;
|
||||
let dstX = p.x, dstY = p.y;
|
||||
if (liveDragInfo) {
|
||||
if (liveDragInfo.cardId === p.workflow_id) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; }
|
||||
}
|
||||
const srcCx = srcX + wc.width / 2;
|
||||
const dstCx = dstX + p.width / 2;
|
||||
const srcAnchors: Anchor[] = [
|
||||
{ x: srcX + wc.width, y: srcY + wc.height * 0.5, side: 'right' },
|
||||
{ x: srcX, y: srcY + wc.height * 0.5, side: 'left' },
|
||||
{ x: srcCx, y: srcY, side: 'top' },
|
||||
{ x: srcCx, y: srcY + wc.height, side: 'bottom' },
|
||||
];
|
||||
const dstAnchors: Anchor[] = [
|
||||
{ x: dstX, y: dstY + p.height * 0.5, side: 'left' },
|
||||
{ x: dstX + p.width, y: dstY + p.height * 0.5, side: 'right' },
|
||||
{ x: dstCx, y: dstY, side: 'top' },
|
||||
{ x: dstCx, y: dstY + p.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 pathD = elbowPath(x1, y1, x2, y2);
|
||||
const midX = x1 + (x2 - x1) / 2;
|
||||
const midY = y1 + (y2 - y1) / 2;
|
||||
configureTethers.push({
|
||||
key: `configure-${p.workflow_id}`,
|
||||
path: pathD,
|
||||
labelX: midX,
|
||||
labelY: midY,
|
||||
label: 'Configure',
|
||||
fading: false,
|
||||
});
|
||||
}
|
||||
|
||||
return [...agentTethers, ...browserTethers, ...workflowTethers, ...configureTethers];
|
||||
// measuredHeightsTick re-runs the memo once ResizeObserver reports a new
|
||||
// height after a collapse (the ref read is invisible to the dep checker).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]);
|
||||
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, configurePanels, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ export function getCardRect(id: string, type: CardType):
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { report } from '@/shared/serviceClient';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { expandSession } from '@/shared/state/agentsSlice';
|
||||
import { bringToFront } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardType } from '../state/useDashboardSelection';
|
||||
import type { CanvasActions } from './useCanvasControls';
|
||||
|
||||
@@ -13,6 +13,7 @@ interface UseArrowNavArgs {
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
workflowCards: Record<string, WorkflowCardPosition>;
|
||||
zoom: number;
|
||||
isActive: boolean;
|
||||
focusedCardId: string | null;
|
||||
@@ -25,6 +26,7 @@ export function useArrowNav({
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
workflowCards,
|
||||
zoom,
|
||||
isActive,
|
||||
focusedCardId,
|
||||
@@ -48,6 +50,9 @@ export function useArrowNav({
|
||||
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;
|
||||
@@ -80,7 +85,7 @@ export function useArrowNav({
|
||||
}
|
||||
|
||||
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(() => {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEffect, type Dispatch, type SetStateAction } from 'react';
|
||||
import { report } from '@/shared/serviceClient';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { closeSession, toggleExpandSession } from '@/shared/state/agentsSlice';
|
||||
import { removeViewCard, removeBrowserCard, removeNote } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { removeViewCard, removeBrowserCard, removeNote, removeWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { closeWorkflowCard } from '@/shared/state/workflowsSlice';
|
||||
import type { useDashboardSelection } from '../state/useDashboardSelection';
|
||||
|
||||
type Selection = ReturnType<typeof useDashboardSelection>;
|
||||
@@ -79,6 +80,9 @@ export function useDashboardShortcuts({
|
||||
dispatch(removeBrowserCard(id));
|
||||
} else if (type === 'note') {
|
||||
dispatch(removeNote(id));
|
||||
} else if (type === 'workflow') {
|
||||
dispatch(removeWorkflowCard(id));
|
||||
dispatch(closeWorkflowCard(id));
|
||||
}
|
||||
}
|
||||
selection.deselectAll();
|
||||
|
||||
@@ -15,9 +15,12 @@ import {
|
||||
resetLayout,
|
||||
removeViewCard,
|
||||
clearPendingFocusBrowserId,
|
||||
clearPendingFocusWorkflowId,
|
||||
clearPendingFocusWorkflowsHub,
|
||||
type ViewCardPosition,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { fetchOutputs, type Output } from '@/shared/state/outputsSlice';
|
||||
import { fetchWorkflows } from '@/shared/state/workflowsSlice';
|
||||
import { dashboardWs } from '@/shared/ws/WebSocketManager';
|
||||
import { initBrowserCommandHandler } from '@/shared/browserCommandHandler';
|
||||
import { clearPendingBrowserUrl, clearPendingFocusAgentId } from '@/shared/state/tempStateSlice';
|
||||
@@ -59,6 +62,8 @@ export function useDashboardLifecycle({
|
||||
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);
|
||||
|
||||
// Track dashboard engagement time
|
||||
useEffect(() => {
|
||||
@@ -93,11 +98,13 @@ export function useDashboardLifecycle({
|
||||
? (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);
|
||||
|
||||
@@ -214,6 +221,44 @@ export function useDashboardLifecycle({
|
||||
}, 200);
|
||||
}, [isActive, pendingFocusBrowserId, layoutInitialized, dispatch, canvasActions, handleHighlightCard]);
|
||||
|
||||
// Same pan/highlight choreography for newly-spawned workflow cards.
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
if (!pendingFocusWorkflowId || !layoutInitialized) return;
|
||||
const workflowId = pendingFocusWorkflowId;
|
||||
dispatch(clearPendingFocusWorkflowId());
|
||||
setTimeout(() => {
|
||||
const card = store.getState().dashboardLayout.workflowCards[workflowId];
|
||||
if (card) {
|
||||
canvasActions.fitToCards(
|
||||
[{ x: card.x, y: card.y, width: card.width, height: card.height }],
|
||||
1.15,
|
||||
true,
|
||||
);
|
||||
handleHighlightCard(workflowId);
|
||||
}
|
||||
}, 200);
|
||||
}, [isActive, pendingFocusWorkflowId, layoutInitialized, dispatch, canvasActions, handleHighlightCard]);
|
||||
|
||||
// Pan/zoom to Workflows Hub on Expand; chained rAFs ensure fit runs after the hub div lands at its new coords.
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
if (!pendingFocusWorkflowsHub || !layoutInitialized) return;
|
||||
dispatch(clearPendingFocusWorkflowsHub());
|
||||
const fit = () => {
|
||||
const hub = store.getState().dashboardLayout.workflowsHub;
|
||||
if (!hub) return;
|
||||
canvasActions.fitToCards(
|
||||
[{ x: hub.x, y: hub.y, width: hub.width, height: hub.height }],
|
||||
1.1,
|
||||
true,
|
||||
);
|
||||
};
|
||||
requestAnimationFrame(() => requestAnimationFrame(fit));
|
||||
const fallback = setTimeout(fit, 300);
|
||||
return () => clearTimeout(fallback);
|
||||
}, [isActive, pendingFocusWorkflowsHub, layoutInitialized, dispatch, canvasActions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutInitialized || restoredExpandedRef.current) return;
|
||||
restoredExpandedRef.current = true;
|
||||
|
||||
@@ -30,6 +30,8 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
const isElementSelectMode = elementSelectionCtx?.selectMode ?? false;
|
||||
const {
|
||||
dashboardName, sessions, expandedSessionIds, cards, viewCards, browserCards,
|
||||
workflowCards, workflowItems, workflowOpenCards, configurePanels, workflowsHub,
|
||||
pendingFocusWorkflowId, pendingFocusWorkflowsHub,
|
||||
notes, pendingFocusNoteId, layoutInitialized, persistedExpandedSessionIds,
|
||||
zoomSensitivity, newAgentShortcut, browserHomepage, expandNewChats,
|
||||
autoRevealSubAgents, outputs, outputsLoaded, glowingAgentCards, glowingBrowserCards,
|
||||
@@ -40,8 +42,8 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
const sessionList = useMemo(() => Object.values(sessions), [sessions]);
|
||||
|
||||
const contentBounds = useMemo(
|
||||
() => computeContentBounds(cards, viewCards, browserCards),
|
||||
[cards, viewCards, browserCards],
|
||||
() => computeContentBounds(cards, viewCards, browserCards, workflowCards, workflowsHub),
|
||||
[cards, viewCards, browserCards, workflowCards, workflowsHub],
|
||||
);
|
||||
|
||||
const canvas = useCanvasControls(zoomSensitivity, contentBounds, isActive);
|
||||
@@ -51,6 +53,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
viewCards,
|
||||
browserCards,
|
||||
notes,
|
||||
workflowCards,
|
||||
);
|
||||
const {
|
||||
toolbarRef, toolbarOpen, setToolbarOpen, searchPaletteOpen, setSearchPaletteOpen,
|
||||
@@ -138,6 +141,9 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
workflowCards,
|
||||
configurePanels,
|
||||
workflowsHub,
|
||||
notes,
|
||||
expandedSessionIds,
|
||||
captureNow,
|
||||
@@ -168,6 +174,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
workflowCards,
|
||||
zoom: canvas.zoom,
|
||||
isActive,
|
||||
focusedCardId,
|
||||
@@ -231,6 +238,10 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
glowingBrowserCards,
|
||||
cards,
|
||||
browserCards,
|
||||
workflowCards,
|
||||
workflowItems,
|
||||
workflowOpenCards,
|
||||
configurePanels,
|
||||
expandedSessionIds,
|
||||
liveDragInfo,
|
||||
measuredHeightsRef,
|
||||
@@ -241,6 +252,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
return {
|
||||
c, dashboardId, dashboardName, canvas, selection, sessions, sessionList,
|
||||
cards, viewCards, browserCards, notes, outputs, glowingAgentCards,
|
||||
workflowCards, workflowsHub, configurePanels,
|
||||
expandedSessionIds, tethers, highlightedCardId, autoFocusSessionId,
|
||||
focusedCardId, pendingFocusNoteId, multiDragDelta, shakeDirection,
|
||||
neighborDirections, toolbarOpen, searchPaletteOpen, newAgentBounce,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardType } from '@/shared/state/dashboardLayoutSlice';
|
||||
@@ -43,6 +43,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);
|
||||
@@ -77,8 +78,9 @@ export function useDashboardSelection(
|
||||
for (const vc of Object.values(viewCards)) next.set(vc.output_id, 'view');
|
||||
for (const bc of Object.values(browserCards)) next.set(bc.browser_id, 'browser');
|
||||
for (const n of Object.values(notes)) next.set(n.note_id, 'note');
|
||||
for (const wc of Object.values(workflowCards)) next.set(wc.workflow_id, 'workflow');
|
||||
setSelectedIds(next);
|
||||
}, [cards, viewCards, browserCards, notes]);
|
||||
}, [cards, viewCards, browserCards, notes, workflowCards]);
|
||||
|
||||
const selectCard = useCallback(
|
||||
(id: string, type: CardType, shiftKey: boolean) => {
|
||||
@@ -161,6 +163,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);
|
||||
@@ -176,7 +191,7 @@ export function useDashboardSelection(
|
||||
|
||||
return intersecting;
|
||||
},
|
||||
[cards, viewCards, browserCards, notes],
|
||||
[cards, viewCards, browserCards, notes, workflowCards],
|
||||
);
|
||||
|
||||
const handleCanvasMouseDown = useCallback(
|
||||
|
||||
@@ -11,6 +11,13 @@ export function useDashboardSelectors(dashboardId: string) {
|
||||
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 configurePanels = useAppSelector((state) => state.dashboardLayout.configurePanels);
|
||||
const workflowsHub = useAppSelector((state) => state.dashboardLayout.workflowsHub);
|
||||
const pendingFocusWorkflowId = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowId);
|
||||
const pendingFocusWorkflowsHub = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowsHub);
|
||||
const workflowItems = useAppSelector((state) => state.workflows.items);
|
||||
const workflowOpenCards = useAppSelector((state) => state.workflows.openCards);
|
||||
const notes = useAppSelector((state) => state.dashboardLayout.notes);
|
||||
const pendingFocusNoteId = useAppSelector((state) => state.dashboardLayout.pendingFocusNoteId);
|
||||
const layoutInitialized = useAppSelector((state) => state.dashboardLayout.initialized);
|
||||
@@ -32,6 +39,13 @@ export function useDashboardSelectors(dashboardId: string) {
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
workflowCards,
|
||||
workflowItems,
|
||||
workflowOpenCards,
|
||||
configurePanels,
|
||||
workflowsHub,
|
||||
pendingFocusWorkflowId,
|
||||
pendingFocusWorkflowsHub,
|
||||
notes,
|
||||
pendingFocusNoteId,
|
||||
layoutInitialized,
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
type ViewCardPosition,
|
||||
type BrowserCardPosition,
|
||||
type NotePosition,
|
||||
type WorkflowCardPosition,
|
||||
type ConfigurePanelPosition,
|
||||
type WorkflowsHubPosition,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
|
||||
interface UseLayoutSaveArgs {
|
||||
@@ -15,6 +18,9 @@ interface UseLayoutSaveArgs {
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
workflowCards: Record<string, WorkflowCardPosition>;
|
||||
configurePanels: Record<string, ConfigurePanelPosition>;
|
||||
workflowsHub: WorkflowsHubPosition | null;
|
||||
notes: Record<string, NotePosition>;
|
||||
expandedSessionIds: string[];
|
||||
captureNow: () => void;
|
||||
@@ -31,6 +37,9 @@ export function useLayoutSave({
|
||||
cards,
|
||||
viewCards,
|
||||
browserCards,
|
||||
workflowCards,
|
||||
configurePanels,
|
||||
workflowsHub,
|
||||
notes,
|
||||
expandedSessionIds,
|
||||
captureNow,
|
||||
@@ -47,7 +56,7 @@ export function useLayoutSave({
|
||||
skipInitialSave.current = false;
|
||||
return;
|
||||
}
|
||||
const payload = { dashboardId, cards, viewCards, browserCards, notes, expandedSessionIds };
|
||||
const payload = { dashboardId, cards, viewCards, browserCards, workflowCards, configurePanels, workflowsHub, notes, expandedSessionIds };
|
||||
pendingSaveRef.current = payload;
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
@@ -56,7 +65,7 @@ export function useLayoutSave({
|
||||
saveTimerRef.current = null;
|
||||
captureNow();
|
||||
}, 500);
|
||||
}, [isActive, cards, viewCards, browserCards, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]);
|
||||
}, [isActive, cards, viewCards, browserCards, workflowCards, configurePanels, workflowsHub, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Select from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { openConfigurePanel, closeConfigurePanel } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { BODY_FS, LABEL_FS } from './workflowEditCommon';
|
||||
|
||||
export default function ActionsFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
// Configure pops the Action Library out as a separate dashboard card
|
||||
// tethered to this workflow (image #120). Lives in
|
||||
// dashboardLayout.configurePanels keyed by workflow id; user can drag,
|
||||
// resize, and X-close from there.
|
||||
const configuring = useAppSelector((s) => Boolean(s.dashboardLayout.configurePanels[draft.id]));
|
||||
const toggleConfigure = () => {
|
||||
if (configuring) dispatch(closeConfigurePanel(draft.id));
|
||||
else dispatch(openConfigurePanel({ workflowId: draft.id }));
|
||||
};
|
||||
// If the user flips Freeze off while the popout is open, close it so
|
||||
// the orphaned card doesn't keep listening to a workflow that no
|
||||
// longer wants a frozen action set.
|
||||
React.useEffect(() => {
|
||||
if (!draft.actions.freeze && configuring) {
|
||||
dispatch(closeConfigurePanel(draft.id));
|
||||
}
|
||||
}, [draft.actions.freeze, draft.id, configuring, dispatch]);
|
||||
|
||||
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'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't freeze</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
{/* Configure only makes sense when actions are frozen: the user
|
||||
is explicitly picking a curated subset. With "Don't freeze",
|
||||
the agent inherits global settings, so there's nothing to
|
||||
configure here. Auto-close the panel on un-freeze so a stale
|
||||
popout doesn't outlive the toggle. */}
|
||||
{draft.actions.freeze && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 0.5 }}>
|
||||
<Box
|
||||
onClick={toggleConfigure}
|
||||
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>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import {
|
||||
closeConfigurePanel,
|
||||
setConfigurePanelPosition,
|
||||
setConfigurePanelSize,
|
||||
type ConfigurePanelPosition,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import Tools from '@/app/pages/Tools/Tools';
|
||||
|
||||
const MIN_W = 420;
|
||||
const MIN_H = 320;
|
||||
const EDGE = 6;
|
||||
|
||||
export default function ConfigurePanelCard({ panel, zOrder }: { panel: ConfigurePanelPosition; zOrder: number }) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
||||
const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
|
||||
const [localPos, setLocalPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [localSize, setLocalSize] = useState<{ w: number; h: number } | null>(null);
|
||||
|
||||
const onDragStart = useCallback((e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: panel.x, origY: panel.y };
|
||||
setLocalPos({ x: panel.x, y: panel.y });
|
||||
}, [panel.x, panel.y]);
|
||||
|
||||
const onDragMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const dx = e.clientX - dragRef.current.startX;
|
||||
const dy = e.clientY - dragRef.current.startY;
|
||||
const nx = dragRef.current.origX + dx;
|
||||
const ny = dragRef.current.origY + dy;
|
||||
setLocalPos({ x: nx, y: ny });
|
||||
// Push the live position into Redux so the dashboard tether stays
|
||||
// glued to the panel during the drag instead of lagging until pointer
|
||||
// up. setLocalPos is kept for sub-frame smoothness, but Redux is the
|
||||
// tether's source of truth.
|
||||
dispatch(setConfigurePanelPosition({ workflowId: panel.workflow_id, x: nx, y: ny }));
|
||||
}, [dispatch, panel.workflow_id]);
|
||||
|
||||
const onDragEnd = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
dragRef.current = null;
|
||||
setLocalPos(null);
|
||||
}, []);
|
||||
|
||||
const onResizeStart = useCallback((e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: panel.width, origH: panel.height };
|
||||
setLocalSize({ w: panel.width, h: panel.height });
|
||||
}, [panel.width, panel.height]);
|
||||
|
||||
const onResizeMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
const dw = e.clientX - resizeRef.current.startX;
|
||||
const dh = e.clientY - resizeRef.current.startY;
|
||||
setLocalSize({
|
||||
w: Math.max(MIN_W, resizeRef.current.origW + dw),
|
||||
h: Math.max(MIN_H, resizeRef.current.origH + dh),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onResizeEnd = useCallback((e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
if (localSize) {
|
||||
dispatch(setConfigurePanelSize({ workflowId: panel.workflow_id, width: localSize.w, height: localSize.h }));
|
||||
}
|
||||
resizeRef.current = null;
|
||||
setLocalSize(null);
|
||||
}, [dispatch, localSize, panel.workflow_id]);
|
||||
|
||||
const displayX = localPos?.x ?? panel.x;
|
||||
const displayY = localPos?.y ?? panel.y;
|
||||
const displayW = localSize?.w ?? panel.width;
|
||||
const displayH = localSize?.h ?? panel.height;
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-select-type="configure-panel"
|
||||
data-select-id={panel.workflow_id}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: displayX,
|
||||
top: displayY,
|
||||
width: displayW,
|
||||
height: displayH,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.accent.primary}80`,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
boxShadow: c.shadow.lg,
|
||||
zIndex: zOrder,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Drag handle + close X strip across the top. Stays slim so the
|
||||
full Action Library underneath gets the vertical space. */}
|
||||
<Box
|
||||
onPointerDown={onDragStart}
|
||||
onPointerMove={onDragMove}
|
||||
onPointerUp={onDragEnd}
|
||||
onPointerCancel={onDragEnd}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 0.5,
|
||||
px: 1, py: 0.5,
|
||||
borderBottom: `1px solid ${c.border.subtle}`,
|
||||
bgcolor: c.bg.surface,
|
||||
cursor: 'grab',
|
||||
'&:active': { cursor: 'grabbing' },
|
||||
flexShrink: 0,
|
||||
userSelect: 'none',
|
||||
}}>
|
||||
<DragIndicatorIcon sx={{ fontSize: 14, color: c.text.muted }} />
|
||||
<Box sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.text.secondary, flex: 1 }}>Action Library</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => dispatch(closeConfigurePanel(panel.workflow_id))}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ p: 0.25, color: c.text.muted, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}>
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{/* Body: the real Action Library, exact same component as /actions. */}
|
||||
<Box sx={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||
<Tools />
|
||||
</Box>
|
||||
{/* SE resize handle. */}
|
||||
<Box
|
||||
onPointerDown={onResizeStart}
|
||||
onPointerMove={onResizeMove}
|
||||
onPointerUp={onResizeEnd}
|
||||
onPointerCancel={onResizeEnd}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 0, bottom: 0,
|
||||
width: 14, height: 14,
|
||||
cursor: 'nwse-resize',
|
||||
opacity: 0.6,
|
||||
'&:hover': { opacity: 1 },
|
||||
// Diagonal stripes for the universal "drag-resize" hint.
|
||||
background: `linear-gradient(135deg, transparent 50%, ${c.border.medium} 50%, ${c.border.medium} 60%, transparent 60%, transparent 75%, ${c.border.medium} 75%, ${c.border.medium} 85%, transparent 85%)`,
|
||||
borderBottomRightRadius: `${EDGE}px`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// Image #38, #48: Edit Agent embedded in the workflow card.
|
||||
// Creates a real, sticky-per-workflow agent session via /workflows/{id}/
|
||||
// edit-agent-session and embeds AgentChat so tool calls render as their
|
||||
// normal cards (MCP Activation, Gmail Query, etc.). Header keeps the
|
||||
// subtitle on the left and Settings + Discard + Save on the right. In
|
||||
// fix mode (Image #48) the very first message in the session is a
|
||||
// failure-context prompt, and a red prefix card renders above the chat
|
||||
// so the user sees Why we're here at a glance.
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import DeleteOutlineRounded from '@mui/icons-material/DeleteOutlineRounded';
|
||||
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
||||
import BuildRounded from '@mui/icons-material/BuildRounded';
|
||||
import TuneRounded from '@mui/icons-material/TuneRounded';
|
||||
import KeyboardArrowDownRounded from '@mui/icons-material/KeyboardArrowDownRounded';
|
||||
import ScienceOutlined from '@mui/icons-material/ScienceOutlined';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { clearFixSeed, setCardSidecar, updateWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { DEFAULT_CARD_W, DEFAULT_CARD_H, placeCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
|
||||
import { fetchSession } from '@/shared/state/agentsSlice';
|
||||
import StepList from './StepList';
|
||||
import { API_BASE, getAuthToken } from '@/shared/config';
|
||||
import AgentChat from '@/app/pages/AgentChat/AgentChat';
|
||||
|
||||
interface Props {
|
||||
workflow: Workflow;
|
||||
steps: Workflow['steps'];
|
||||
isFixMode?: boolean;
|
||||
}
|
||||
|
||||
function InlineSubtitle({ workflow }: { workflow: Workflow }) {
|
||||
const c = useClaudeTokens();
|
||||
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
|
||||
const runs = useAppSelector((s) => s.workflows.runs[workflow.id]);
|
||||
const modelLabel = React.useMemo(() => {
|
||||
if (!workflow?.model) return '';
|
||||
for (const list of Object.values(modelsByProvider || {})) {
|
||||
for (const m of (list as Array<{ value: string; label?: string }>) || []) {
|
||||
if (m.value === workflow.model) return m.label || workflow.model;
|
||||
}
|
||||
}
|
||||
return workflow.model;
|
||||
}, [workflow?.model, modelsByProvider]);
|
||||
const duration = React.useMemo(() => {
|
||||
if (!runs || runs.length === 0) return '';
|
||||
const last = runs.find((r) => r.finished_at);
|
||||
if (!last || !last.finished_at) return '';
|
||||
const ms = new Date(last.finished_at).getTime() - new Date(last.started_at).getTime();
|
||||
if (ms <= 0) return '';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
||||
return `${Math.floor(ms / 60_000)}m`;
|
||||
}, [runs]);
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1.25, fontSize: '0.82rem', color: c.text.muted, minWidth: 0, overflow: 'hidden' }}>
|
||||
{modelLabel && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{modelLabel}</Box>}
|
||||
{workflow.mode && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{workflow.mode}</Box>}
|
||||
{duration && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{duration}</Box>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EditAgentView({ workflow, steps, isFixMode = false }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const card = useAppSelector((s) => s.workflows.openCards[workflow.id]);
|
||||
const wfCardPos = useAppSelector((s) => s.dashboardLayout.workflowCards[workflow.id]);
|
||||
const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds);
|
||||
const fixSeed = card?.fixSeed || null;
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [showSaveBeforeTest, setShowSaveBeforeTest] = useState(false);
|
||||
const [fixPrefixExpanded, setFixPrefixExpanded] = useState(false);
|
||||
const [editSessionId, setEditSessionId] = useState<string | null>(workflow.edit_agent_session_id || null);
|
||||
const [seedSent, setSeedSent] = useState(false);
|
||||
// Clear the fix seed after the view unmounts so re-entering edit_agent
|
||||
// (without going through Fix-with-Agent) doesn't re-show the prefix.
|
||||
useEffect(() => () => { dispatch(clearFixSeed(workflow.id)); }, [dispatch, workflow.id]);
|
||||
|
||||
// Spawn (or reattach to) the sticky Edit Agent session on mount.
|
||||
useEffect(() => {
|
||||
if (editSessionId) return;
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflow.id)}/edit-agent-session`, {
|
||||
method: 'POST',
|
||||
headers: tok ? { Authorization: `Bearer ${tok}` } : {},
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const sid = data?.session_id as string | undefined;
|
||||
if (!sid || !alive) return;
|
||||
try { await dispatch(fetchSession(sid)).unwrap(); } catch { /* may not be hydrated yet */ }
|
||||
if (alive) setEditSessionId(sid);
|
||||
} catch { /* best-effort */ }
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [editSessionId, workflow.id, dispatch]);
|
||||
|
||||
// First-turn seed: post the hidden opener so the agent's first reply
|
||||
// is the friendly "How would you like to modify the workflow..." prompt
|
||||
// (or, in fix mode, an analysis of the failure context).
|
||||
const editSession = useAppSelector((s) => editSessionId ? s.agents.sessions[editSessionId] : undefined);
|
||||
useEffect(() => {
|
||||
if (!editSessionId || !editSession || seedSent) return;
|
||||
const msgs = editSession.messages || [];
|
||||
if (msgs.length > 0) {
|
||||
setSeedSent(true);
|
||||
return;
|
||||
}
|
||||
const seed = isFixMode && fixSeed
|
||||
? `The most recent run failed on Step ${fixSeed.stepIdx + 1} (${fixSeed.stepLabel}). Error: ${fixSeed.error}\n\nWalk me through what likely went wrong and propose a concrete prompt change for that step.`
|
||||
: 'Greet me briefly, then ask: "How would you like to modify the workflow (e.g. filter out spam emails before summarizing)?"';
|
||||
setSeedSent(true);
|
||||
(async () => {
|
||||
try {
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
await fetch(`${API_BASE}/agents/sessions/${encodeURIComponent(editSessionId)}/message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) },
|
||||
body: JSON.stringify({ prompt: seed, hidden: true }),
|
||||
});
|
||||
} catch { /* best-effort */ }
|
||||
})();
|
||||
}, [editSessionId, editSession, seedSent, isFixMode, fixSeed]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } }));
|
||||
}, [dispatch, workflow.id]);
|
||||
|
||||
const onTest = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflow.id)}/test-run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) },
|
||||
body: JSON.stringify({ steps: steps.map((s) => ({ id: s.id, text: s.text, label: s.label || null })) }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const sessionId = data?.session_id as string | undefined;
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const { store } = await import('@/shared/state/store');
|
||||
if (!store.getState().agents.sessions[sessionId]) {
|
||||
try { await dispatch(fetchSession(sessionId)).unwrap(); } catch { /* not fatal */ }
|
||||
}
|
||||
if (!store.getState().dashboardLayout.cards[sessionId] && wfCardPos) {
|
||||
dispatch(placeCard({
|
||||
sessionId,
|
||||
x: wfCardPos.x + wfCardPos.width + 60,
|
||||
y: wfCardPos.y,
|
||||
width: DEFAULT_CARD_W,
|
||||
height: DEFAULT_CARD_H,
|
||||
expandedSessionIds,
|
||||
}));
|
||||
}
|
||||
dispatch(setPendingFocusAgentId(sessionId));
|
||||
} catch { /* best-effort */ }
|
||||
dispatch(setCardSidecar({ workflowId: workflow.id, sessionId, kind: 'testing' }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, workflow.id, steps, dispatch, wfCardPos, expandedSessionIds]);
|
||||
|
||||
const onTestClick = useCallback(() => {
|
||||
// No local draft to warn about anymore (the Edit Agent's tool will
|
||||
// mutate workflow.steps directly when wired). Skip the modal for now.
|
||||
void onTest();
|
||||
}, [onTest]);
|
||||
void showSaveBeforeTest; void setShowSaveBeforeTest;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<InlineSubtitle workflow={workflow} />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Tooltip title="Permissions, actions, cost cap">
|
||||
<Box
|
||||
onClick={() => dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'edit', editFacet: 'Actions' } }))}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 28, height: 28, borderRadius: 999,
|
||||
color: c.text.secondary, cursor: 'pointer',
|
||||
'&:hover': { color: c.text.primary, bgcolor: c.bg.elevated },
|
||||
}}>
|
||||
<TuneRounded sx={{ fontSize: 16 }} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Tooltip title="Spawn a Test Agent that runs the latest workflow next to this card with a Testing arrow chip.">
|
||||
<Box
|
||||
onClick={onTestClick}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.3,
|
||||
fontSize: '0.78rem', fontWeight: 700,
|
||||
color: c.accent.primary, bgcolor: 'transparent',
|
||||
px: 1, py: 0.4, borderRadius: 999,
|
||||
border: `1px solid ${c.accent.primary}55`,
|
||||
cursor: busy ? 'not-allowed' : 'pointer',
|
||||
opacity: busy ? 0.5 : 1,
|
||||
'&:hover': { bgcolor: c.accent.primary + '14' },
|
||||
}}>
|
||||
<ScienceOutlined sx={{ fontSize: 14 }} />
|
||||
Test
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<HeaderBtn
|
||||
label="Discard"
|
||||
icon={<DeleteOutlineRounded sx={{ fontSize: 16 }} />}
|
||||
onClick={onClose}
|
||||
tone="muted"
|
||||
/>
|
||||
<HeaderBtn
|
||||
label="Save"
|
||||
icon={<SaveOutlinedIcon sx={{ fontSize: 16 }} />}
|
||||
onClick={onClose}
|
||||
tone="filled"
|
||||
/>
|
||||
</Box>
|
||||
<StepList steps={steps} />
|
||||
{isFixMode && fixSeed && <FixPrefixCard seed={fixSeed} expanded={fixPrefixExpanded} onToggle={() => setFixPrefixExpanded((x) => !x)} />}
|
||||
{/* Embedded real Edit Agent chat. AgentChat owns the composer +
|
||||
message list + tool-call card rendering, matching Image #48
|
||||
(MCP Activation, Gmail Query, etc.). embedded=true tells it to
|
||||
skip its own dashboard chrome since we own the surrounding card. */}
|
||||
<Box sx={{ flex: 1, minHeight: 280, display: 'flex', flexDirection: 'column', mx: -1, mb: -1 }}>
|
||||
{editSessionId ? (
|
||||
<AgentChat sessionId={editSessionId} embedded autoFocus />
|
||||
) : (
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: c.text.muted, fontSize: '0.85rem' }}>
|
||||
Starting the Edit Agent...
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Dialog open={false} onClose={() => {}} maxWidth="sm" fullWidth>
|
||||
<Box />
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function FixPrefixCard({ seed, expanded, onToggle }: { seed: { stepIdx: number; stepLabel: string; error: string }; expanded: boolean; onToggle: () => void }) {
|
||||
const c = useClaudeTokens();
|
||||
const PREVIEW_MAX = 110;
|
||||
const needsExpand = (seed.error || '').length > PREVIEW_MAX;
|
||||
const shown = !needsExpand || expanded
|
||||
? seed.error
|
||||
: (seed.error || '').slice(0, PREVIEW_MAX).trimEnd() + '...';
|
||||
return (
|
||||
<Box
|
||||
onClick={needsExpand ? onToggle : undefined}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 1.25,
|
||||
p: 1.25, borderRadius: `${c.radius.lg}px`,
|
||||
bgcolor: c.status.errorBg,
|
||||
border: `1px solid ${c.status.error}30`,
|
||||
cursor: needsExpand ? 'pointer' : 'default',
|
||||
'&:hover': needsExpand ? { bgcolor: c.status.error + '14' } : {},
|
||||
}}>
|
||||
<Box sx={{
|
||||
width: 32, height: 32, borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: c.status.error + '22', color: c.status.error,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<BuildRounded sx={{ fontSize: 16 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography sx={{ flex: 1, fontSize: '0.92rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.3 }}>
|
||||
Fixing Step {seed.stepIdx + 1}: {seed.stepLabel}
|
||||
</Typography>
|
||||
{needsExpand && (
|
||||
<KeyboardArrowDownRounded sx={{
|
||||
fontSize: 18,
|
||||
color: c.text.muted,
|
||||
transform: expanded ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.18s ease',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, mt: 0.25, lineHeight: 1.45, whiteSpace: 'pre-wrap' }}>
|
||||
{shown}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderBtn({ label, icon, onClick, tone, disabled }: { label: string; icon: React.ReactNode; onClick: () => void; tone: 'muted' | 'filled'; disabled?: boolean }) {
|
||||
const c = useClaudeTokens();
|
||||
const filled = tone === 'filled';
|
||||
return (
|
||||
<Box
|
||||
onClick={disabled ? undefined : onClick}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
fontSize: '0.82rem', fontWeight: 700,
|
||||
px: 1.1, py: 0.45, borderRadius: 999,
|
||||
color: filled ? '#fff' : c.text.secondary,
|
||||
bgcolor: filled ? c.text.primary : 'transparent',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
'&:hover': filled ? { filter: 'brightness(1.05)' } : { color: c.text.primary, bgcolor: c.bg.elevated },
|
||||
}}>
|
||||
{icon}
|
||||
{label}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import React 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 EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { fetchSession, resumeSession } from '@/shared/state/agentsSlice';
|
||||
import {
|
||||
DEFAULT_CARD_H,
|
||||
DEFAULT_CARD_W,
|
||||
placeCard,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
|
||||
import { store } from '@/shared/state/store';
|
||||
import type { Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { FieldRow, BODY_FS, LABEL_FS, HINT_FS, INPUT_FS } from './workflowEditCommon';
|
||||
|
||||
export default function GeneralFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const sourceSessionId = draft.source_session_id || null;
|
||||
// Open the source chat: fetch if missing, fall through to resume if
|
||||
// it was closed, place a card if there isn't one. That's it. No pan
|
||||
// animation, no focus pin, no dashboard_id patching, no auto-clear
|
||||
// timers. Match the way any other chat opens on the canvas; let the
|
||||
// user scroll to it.
|
||||
const openSourceChat = React.useCallback(async () => {
|
||||
if (!sourceSessionId) return;
|
||||
const sid = sourceSessionId;
|
||||
if (!store.getState().agents.sessions[sid]) {
|
||||
try {
|
||||
await dispatch(fetchSession(sid)).unwrap();
|
||||
} catch {
|
||||
try {
|
||||
await dispatch(resumeSession({ sessionId: sid })).unwrap();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!store.getState().dashboardLayout.cards[sid]) {
|
||||
dispatch(placeCard({
|
||||
sessionId: sid,
|
||||
x: 400, y: 200,
|
||||
width: DEFAULT_CARD_W,
|
||||
height: DEFAULT_CARD_H,
|
||||
}));
|
||||
}
|
||||
// Pan the canvas to the chat card so the user can see it. Safe to
|
||||
// do here because the active element is the Edit button, not a
|
||||
// textarea: handleCardSelect's input-aware blur guard prevents the
|
||||
// focus animation from killing typing focus in a separate flow.
|
||||
dispatch(setPendingFocusAgentId(sid));
|
||||
}, [sourceSessionId, dispatch]);
|
||||
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">
|
||||
<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>
|
||||
</FieldRow>
|
||||
{!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 }}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 0.5 }}>
|
||||
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary, flex: 1 }}>Workflow</Typography>
|
||||
{sourceSessionId && (
|
||||
<Box
|
||||
role="button"
|
||||
onClick={openSourceChat}
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
fontSize: LABEL_FS, fontWeight: 600,
|
||||
color: c.text.muted, cursor: 'pointer',
|
||||
'&:hover': { color: c.accent.primary },
|
||||
}}>
|
||||
<EditOutlinedIcon sx={{ fontSize: 14 }} />
|
||||
Edit
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import type { Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { runWorkflowNow, deleteWorkflow, updateWorkflow, openWorkflowCard } from '@/shared/state/workflowsSlice';
|
||||
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { WEEKDAY_FULL, 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 dispatch = useAppDispatch();
|
||||
const workflows = useAppSelector((s) => Object.values(s.workflows.items));
|
||||
// Right-click menu: pinned position + the workflow whose pill was
|
||||
// clicked. Same anchor pattern as MUI's menu examples.
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; workflow: Workflow } | null>(null);
|
||||
const closeMenu = () => setCtxMenu(null);
|
||||
const onRunNow = () => {
|
||||
if (!ctxMenu) return;
|
||||
dispatch(runWorkflowNow(ctxMenu.workflow.id));
|
||||
closeMenu();
|
||||
};
|
||||
const onPauseToggle = () => {
|
||||
if (!ctxMenu) return;
|
||||
const wf = ctxMenu.workflow;
|
||||
dispatch(updateWorkflow({
|
||||
id: wf.id,
|
||||
patch: { schedule: { ...wf.schedule, enabled: !wf.schedule.enabled } as any },
|
||||
ifMatch: wf.updated_at || null,
|
||||
}));
|
||||
closeMenu();
|
||||
};
|
||||
const onEdit = () => {
|
||||
if (!ctxMenu) return;
|
||||
dispatch(addWorkflowCard({ workflowId: ctxMenu.workflow.id }));
|
||||
// Right-click "Edit" on a calendar entry opens the new Edit Agent
|
||||
// chat view, matching the post-revamp design (Image #38).
|
||||
dispatch(openWorkflowCard({ workflowId: ctxMenu.workflow.id, view: 'edit_agent' }));
|
||||
closeMenu();
|
||||
};
|
||||
const onDelete = () => {
|
||||
if (!ctxMenu) return;
|
||||
const ok = window.confirm(`Delete "${ctxMenu.workflow.title}"? Scheduled runs will stop.`);
|
||||
if (!ok) { closeMenu(); return; }
|
||||
dispatch(deleteWorkflow(ctxMenu.workflow.id));
|
||||
closeMenu();
|
||||
};
|
||||
const ctxMenuEl = (
|
||||
<Menu
|
||||
open={Boolean(ctxMenu)}
|
||||
onClose={closeMenu}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={ctxMenu ? { top: ctxMenu.y, left: ctxMenu.x } : undefined}>
|
||||
<MenuItem onClick={onRunNow}>Run now</MenuItem>
|
||||
<MenuItem onClick={onPauseToggle}>{ctxMenu?.workflow.schedule.enabled ? 'Pause schedule' : 'Resume schedule'}</MenuItem>
|
||||
<MenuItem onClick={onEdit}>Edit…</MenuItem>
|
||||
<MenuItem onClick={onDelete} sx={{ color: c.status.error }}>Delete</MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
// 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 ? 32 : 44;
|
||||
const ROW_LABEL = compact ? '0.7rem' : '0.74rem';
|
||||
const DAY_NUM = compact ? '0.95rem' : '1.15rem';
|
||||
const DAY_LABEL = compact ? '0.66rem' : '0.72rem';
|
||||
const EVENT_FS = compact ? '0.7rem' : '0.78rem';
|
||||
|
||||
if (view === 'Week') {
|
||||
const start = startOfWeek(today);
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
||||
const HOURS = HOURS_24;
|
||||
// Prefer the short zone name ("PDT", "EST", "JST") so the label
|
||||
// reads in plain English instead of "GMT-7". formatToParts is wide-
|
||||
// supported; if it ever fails we degrade silently rather than show
|
||||
// a confusing fallback.
|
||||
const TZ_LABEL = (() => {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('en', { timeZoneName: 'short' }).formatToParts(new Date());
|
||||
return parts.find((p) => p.type === 'timeZoneName')?.value || '';
|
||||
} catch { return ''; }
|
||||
})();
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', color: c.text.secondary }}>
|
||||
{/* Day headers: muted weekday caps; today's date gets the filled circle */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', gap: 0, position: 'sticky', top: 0, bgcolor: c.bg.surface, zIndex: 2, pb: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'flex-end', pr: 1, pb: 0.5 }}>
|
||||
{!compact && (
|
||||
<Typography sx={{ fontSize: '0.62rem', 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: c.text.muted, fontWeight: 600, letterSpacing: '0.08em', lineHeight: 1.3, textTransform: 'uppercase' }}>
|
||||
{WEEKDAY_LABEL_SHORT[d.getDay()]}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: compact ? 30 : 38, height: compact ? 30 : 38, borderRadius: '50%', bgcolor: isToday ? c.accent.primary : 'transparent', color: isToday ? '#fff' : c.text.primary, fontWeight: isToday ? 700 : 500, 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, hourIdx) => (
|
||||
<React.Fragment key={hour}>
|
||||
{/* Hour label sits inside its row (top-aligned) rather than
|
||||
straddling the line above it; that way the first row
|
||||
doesn't clip "12 AM" and the labels never drift when the
|
||||
body scrolls. Apple Calendar does the same. */}
|
||||
<Box sx={{
|
||||
height: SLOT_H, fontSize: ROW_LABEL,
|
||||
color: c.text.ghost, fontWeight: 500,
|
||||
textAlign: 'right', pr: 1, pt: 0.25,
|
||||
borderTop: hourIdx === 0 ? 'none' : `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);
|
||||
const targetWeekday = d.getDay();
|
||||
return (
|
||||
<Box
|
||||
key={`${d.toISOString()}-${hour}`}
|
||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const wid = e.dataTransfer.getData('application/x-workflow-id');
|
||||
if (!wid) return;
|
||||
const wf = workflows.find((w) => w.id === wid);
|
||||
if (!wf) return;
|
||||
// Build the patched schedule: new hour, and for
|
||||
// weekly schedules swap on_days to just the target
|
||||
// weekday. Daily/monthly only get the new hour.
|
||||
const sched = { ...wf.schedule, hour } as typeof wf.schedule;
|
||||
if (sched.repeat_unit === 'week') sched.on_days = [targetWeekday];
|
||||
dispatch(updateWorkflow({
|
||||
id: wf.id,
|
||||
patch: { schedule: sched as any },
|
||||
ifMatch: wf.updated_at || null,
|
||||
}));
|
||||
}}
|
||||
sx={{ height: SLOT_H, borderLeft: `1px solid ${c.border.subtle}`, borderTop: hourIdx === 0 ? 'none' : `1px solid ${c.border.subtle}`, position: 'relative' }}>
|
||||
<EventStack
|
||||
events={evs}
|
||||
onSelectWorkflow={onSelectWorkflow}
|
||||
eventFontSize={EVENT_FS}
|
||||
onContextWorkflow={(wf, ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: wf }); }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
{ctxMenuEl}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === 'Month') {
|
||||
const start = startOfMonthGrid(today);
|
||||
const cells = Array.from({ length: 35 }, (_, i) => addDays(start, i));
|
||||
const accent = c.accent.primary;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* Sticky weekday header so it stays visible even when the
|
||||
calendar body scrolls. Slightly bigger + tinted bg so it
|
||||
reads cleanly in both light and dark themes. */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', position: 'sticky', top: 0, bgcolor: c.bg.surface, zIndex: 2, borderBottom: `1px solid ${c.border.subtle}`, py: 0.6 }}>
|
||||
{WEEKDAY_LABEL_SHORT.map((l, i) => (
|
||||
<Typography key={`${l}-${i}`} sx={{ textAlign: 'center', fontSize: '0.74rem', color: c.text.secondary, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase' }}>{l}</Typography>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 0, borderLeft: `1px solid ${c.border.subtle}` }}>
|
||||
{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 ? 70 : 96, borderRight: `1px solid ${c.border.subtle}`, borderBottom: `1px solid ${c.border.subtle}`, p: 0.5, position: 'relative', overflow: 'hidden', bgcolor: inMonth ? 'transparent' : c.bg.elevated }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
|
||||
{/* Out-of-month dates still need to be legible (Apple
|
||||
Calendar shows them in a muted shade, not invisible).
|
||||
Color tweak instead of opacity so dark themes stay
|
||||
readable. */}
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', minWidth: 22, height: 22, borderRadius: '50%', bgcolor: isToday ? accent : 'transparent', color: isToday ? '#fff' : inMonth ? c.text.primary : c.text.ghost, fontWeight: isToday ? 700 : 500, fontSize: '0.82rem', px: 0.5 }}>{d.getDate()}</Box>
|
||||
</Box>
|
||||
{evs.slice(0, compact ? 3 : 4).map((e, idx) => (
|
||||
<Box
|
||||
key={`${e.workflow.id}-${idx}`}
|
||||
onClick={() => onSelectWorkflow?.(e.workflow.id)}
|
||||
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
|
||||
sx={{ mt: 0.3, display: 'flex', alignItems: 'center', gap: 0.5, fontSize: EVENT_FS, color: c.text.primary, cursor: 'pointer', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', '&:hover': { color: accent } }}>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: accent, flexShrink: 0 }} />
|
||||
<span style={{ color: c.text.muted, flexShrink: 0 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, fontWeight: 500 }}>{e.workflow.title}</span>
|
||||
</Box>
|
||||
))}
|
||||
{evs.length > (compact ? 3 : 4) && (
|
||||
<Typography sx={{ fontSize: EVENT_FS, color: c.text.muted, mt: 0.3, pl: 1.4 }}>+{evs.length - (compact ? 3 : 4)} more</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{ctxMenuEl}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Apple-Calendar-style list: big day number + weekday on the left, a
|
||||
// vertical colored bar separating it from events on the right. Today
|
||||
// renders even with no events (shows a "No events today" placeholder)
|
||||
// so the list doesn't feel empty for new users.
|
||||
const upcoming: { date: Date; events: { workflow: Workflow; date: Date }[]; isToday: boolean }[] = [];
|
||||
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) || [];
|
||||
const isToday = sameDay(day, today);
|
||||
if (arr.length || isToday) upcoming.push({ date: day, events: arr, isToday });
|
||||
}
|
||||
const accent = c.accent.primary;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.lg}px`, overflow: 'hidden', bgcolor: c.bg.surface }}>
|
||||
{upcoming.length === 0 && (
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.muted, textAlign: 'center', py: 3 }}>No scheduled workflows</Typography>
|
||||
)}
|
||||
{upcoming.map(({ date, events, isToday }, rowIdx) => (
|
||||
<Box
|
||||
key={date.toISOString()}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'stretch',
|
||||
borderTop: rowIdx === 0 ? 'none' : `1px dashed ${c.border.subtle}`,
|
||||
minHeight: 64,
|
||||
}}>
|
||||
<Box sx={{ width: 96, flexShrink: 0, display: 'flex', alignItems: 'center', gap: 0.75, pl: 2, pr: 1.25 }}>
|
||||
<Typography sx={{ fontSize: '1.55rem', fontWeight: 600, color: isToday ? accent : c.text.primary, lineHeight: 1, letterSpacing: '-0.01em' }}>
|
||||
{date.getDate()}
|
||||
</Typography>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: isToday ? accent : c.text.secondary, fontWeight: 500, lineHeight: 1.2 }}>
|
||||
{date.toLocaleString('en', { month: 'short' })}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.2 }}>{WEEKDAY_FULL[date.getDay()]}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', py: 1, pr: 2 }}>
|
||||
{events.length === 0 && (
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.ghost }}>No events today</Typography>
|
||||
)}
|
||||
{events.map((e, idx) => (
|
||||
<Tooltip key={`${e.workflow.id}-${idx}`} title={<EventTooltipBody event={e} />} placement="right" arrow>
|
||||
<Box
|
||||
onClick={() => onSelectWorkflow?.(e.workflow.id)}
|
||||
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1.25,
|
||||
py: 0.4,
|
||||
fontSize: '0.88rem', color: c.text.secondary, cursor: 'pointer',
|
||||
'&:hover .ev-title': { color: accent },
|
||||
}}>
|
||||
<Box sx={{ width: 3, alignSelf: 'stretch', minHeight: 22, bgcolor: accent, borderRadius: 1, flexShrink: 0 }} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography className="ev-title" sx={{ fontSize: '0.9rem', fontWeight: 500, color: c.text.primary, lineHeight: 1.3 }}>{e.workflow.title}</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.3 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
{ctxMenuEl}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Apple Calendar style event chip: 3px colored left-bar + faintly-tinted
|
||||
// background + readable text. One chip per cell with a "+N" badge for
|
||||
// overflow; clicking it opens a popover listing all events that hour.
|
||||
function EventStack({ events, onSelectWorkflow, eventFontSize, onContextWorkflow }: {
|
||||
events: { workflow: Workflow; date: Date }[];
|
||||
onSelectWorkflow?: (id: string) => void;
|
||||
eventFontSize: string;
|
||||
onContextWorkflow?: (workflow: Workflow, e: React.MouseEvent) => void;
|
||||
}) {
|
||||
const c = useClaudeTokens();
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
if (events.length === 0) return null;
|
||||
const first = events[0];
|
||||
const rest = events.slice(1);
|
||||
const accent = c.accent.primary;
|
||||
|
||||
// Time string is part of the chip so a glance tells you both what and
|
||||
// when, matching Apple's "Title, 1pm" pattern. Chip is slim (height ~22)
|
||||
// not slot-stretching, since OpenSwarm events fire at a single instant.
|
||||
const timeLabel = formatTime(first.date.getHours(), first.date.getMinutes());
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={<EventTooltipBody event={first} />} placement="top" arrow>
|
||||
<Box
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('application/x-workflow-id', first.workflow.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={() => onSelectWorkflow?.(first.workflow.id)}
|
||||
onContextMenu={(e) => onContextWorkflow?.(first.workflow, e)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 2, right: rest.length > 0 ? 24 : 2, top: 2,
|
||||
height: 22,
|
||||
bgcolor: accent + '14',
|
||||
color: c.text.primary,
|
||||
borderLeft: `3px solid ${accent}`,
|
||||
borderRadius: '4px',
|
||||
px: 0.65, py: 0,
|
||||
fontSize: eventFontSize, fontWeight: 500,
|
||||
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 0.5,
|
||||
'&:hover': { bgcolor: accent + '22' },
|
||||
}}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{first.workflow.title}</span>
|
||||
<span style={{ color: 'inherit', opacity: 0.7, flexShrink: 0 }}>{timeLabel}</span>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
{rest.length > 0 && (
|
||||
<Box
|
||||
onClick={(e) => setAnchor(e.currentTarget)}
|
||||
role="button"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 2, top: 2,
|
||||
height: 22,
|
||||
minWidth: 20, px: 0.4,
|
||||
bgcolor: accent + '22',
|
||||
color: accent,
|
||||
borderRadius: '4px',
|
||||
fontSize: eventFontSize, fontWeight: 700,
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
'&:hover': { bgcolor: accent + '33' },
|
||||
}}>
|
||||
+{rest.length}
|
||||
</Box>
|
||||
)}
|
||||
<Popover
|
||||
open={Boolean(anchor)}
|
||||
anchorEl={anchor}
|
||||
onClose={() => setAnchor(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}>
|
||||
<Box sx={{ minWidth: 220, p: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.5 }}>
|
||||
{events.length} runs at this hour
|
||||
</Typography>
|
||||
{events.map((e, idx) => (
|
||||
<Box
|
||||
key={`${e.workflow.id}-${idx}`}
|
||||
onClick={() => { setAnchor(null); onSelectWorkflow?.(e.workflow.id); }}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 0.5, py: 0.5, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: c.accent.primary }} />
|
||||
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, fontWeight: 600 }}>{e.workflow.title}</Typography>
|
||||
<Typography sx={{ fontSize: '0.74rem', color: c.text.muted }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EventTooltipBody({ event }: { event: { workflow: Workflow; date: Date } }) {
|
||||
const wf = event.workflow;
|
||||
const status = wf.last_run_status;
|
||||
const cost = wf.cost_estimate?.last_run_usd;
|
||||
const monthly = wf.cost_estimate?.monthly_usd;
|
||||
return (
|
||||
<Box sx={{ fontSize: '0.72rem', lineHeight: 1.5 }}>
|
||||
<div style={{ fontWeight: 700 }}>{wf.title}</div>
|
||||
<div>{`Fires at ${formatTime(event.date.getHours(), event.date.getMinutes())}`}</div>
|
||||
{status && <div>{`Last run: ${status}`}</div>}
|
||||
{typeof cost === 'number' && cost > 0 && <div>{`Last run cost: $${cost.toFixed(4)}`}</div>}
|
||||
{typeof monthly === 'number' && monthly > 0 && <div>{`Est. monthly: $${monthly.toFixed(2)}`}</div>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
import React, { useCallback, useEffect, 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 Tooltip from '@mui/material/Tooltip';
|
||||
import RepeatIcon from '@mui/icons-material/RepeatRounded';
|
||||
import HourglassEmptyIcon from '@mui/icons-material/HourglassEmptyRounded';
|
||||
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||
import BedtimeIcon from '@mui/icons-material/BedtimeOutlined';
|
||||
import NotificationsIcon from '@mui/icons-material/NotificationsNoneRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { fetchCloudSmsStatus, type Workflow, type ScheduleConfig, type PermissionTier } from '@/shared/state/workflowsSlice';
|
||||
import { WEEKDAY_LABEL, formatTime } from './scheduleUtils';
|
||||
import { nextTierAfter } from './permissionsUtils';
|
||||
import { BODY_FS, LABEL_FS, HINT_FS, INPUT_FS } from './workflowEditCommon';
|
||||
|
||||
function jsWeekday(d: Date): number { return d.getDay(); }
|
||||
|
||||
// Turn an IANA zone string into something a non-dev can parse. "local"
|
||||
// (legacy) or the host's own zone collapse to "your time"; otherwise
|
||||
// show "Pacific Time" / "Eastern Time" / etc. when we can resolve a
|
||||
// short name via Intl, falling back to the raw IANA name if not.
|
||||
function friendlyTzLabel(tz: string): string {
|
||||
if (!tz || tz === 'local') return 'your time';
|
||||
try {
|
||||
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (tz === host) {
|
||||
const parts = new Intl.DateTimeFormat('en', { timeZone: tz, timeZoneName: 'long' }).formatToParts(new Date());
|
||||
const name = parts.find((p) => p.type === 'timeZoneName')?.value || '';
|
||||
return name ? `your time (${name.replace(' Standard Time', '').replace(' Daylight Time', '')})` : 'your time';
|
||||
}
|
||||
const parts = new Intl.DateTimeFormat('en', { timeZone: tz, timeZoneName: 'long' }).formatToParts(new Date());
|
||||
const name = parts.find((p) => p.type === 'timeZoneName')?.value || '';
|
||||
return name || tz;
|
||||
} catch {
|
||||
return tz;
|
||||
}
|
||||
}
|
||||
|
||||
function lastDayOfMonthFE(year: number, monthZeroBased: number): number {
|
||||
return new Date(year, monthZeroBased + 1, 0).getDate();
|
||||
}
|
||||
|
||||
// Compute the next fire time from a ScheduleConfig. Mirrors the backend
|
||||
// math in scheduler.py:_next_fire_after using browser-local time so the
|
||||
// preview lines up with what the user will actually see on their system
|
||||
// clock. Honors ends_at + max_runs so the "Next run" line doesn't lie
|
||||
// after the schedule has expired.
|
||||
function previewNextRun(sched: ScheduleConfig): Date | null {
|
||||
if (!sched.enabled) return null;
|
||||
const now = new Date();
|
||||
if (sched.ends_at) {
|
||||
const ends = new Date(sched.ends_at);
|
||||
if (!Number.isNaN(ends.getTime()) && ends.getTime() <= now.getTime()) return null;
|
||||
}
|
||||
if (sched.max_runs != null && sched.runs_count >= sched.max_runs) return null;
|
||||
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);
|
||||
const startDay = now.getDate();
|
||||
let year = now.getFullYear();
|
||||
let month = now.getMonth();
|
||||
let guard = 0;
|
||||
while (guard < 60) {
|
||||
const day = Math.min(startDay, lastDayOfMonthFE(year, month));
|
||||
const c = new Date(year, month, day, sched.hour, sched.minute, 0, 0);
|
||||
if (c > now) return c;
|
||||
month += step;
|
||||
year += Math.floor(month / 12);
|
||||
month = ((month % 12) + 12) % 12;
|
||||
guard += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
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())}`;
|
||||
}
|
||||
|
||||
type EndKind = 'forever' | 'on_date' | 'after_n';
|
||||
|
||||
function endKindFromSched(s: ScheduleConfig): EndKind {
|
||||
if (s.ends_at) return 'on_date';
|
||||
if (s.max_runs != null) return 'after_n';
|
||||
return 'forever';
|
||||
}
|
||||
|
||||
interface AppOpenInfo {
|
||||
alwaysOn: boolean; // tray + login both configured
|
||||
loginAtLaunch: boolean;
|
||||
trayEnabled: boolean;
|
||||
}
|
||||
|
||||
function useAppOpenInfo(): { info: AppOpenInfo; fix: () => Promise<void> } {
|
||||
const [info, setInfo] = useState<AppOpenInfo>({ alwaysOn: false, loginAtLaunch: false, trayEnabled: false });
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const w: any = (window as any).openswarm;
|
||||
if (!w?.getAppOpenInfo) return;
|
||||
w.getAppOpenInfo().then((res: AppOpenInfo) => { if (alive) setInfo(res); }).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
const fix = useCallback(async () => {
|
||||
const w: any = (window as any).openswarm;
|
||||
if (!w?.setLoginItem || !w?.enableTray) return;
|
||||
await w.setLoginItem(true);
|
||||
await w.enableTray(true);
|
||||
if (w.getAppOpenInfo) {
|
||||
const next = await w.getAppOpenInfo();
|
||||
setInfo(next);
|
||||
}
|
||||
}, []);
|
||||
return { info, fix };
|
||||
}
|
||||
|
||||
export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; setDraft: (w: Workflow) => void }) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const s = draft.schedule;
|
||||
const cloudSms = useAppSelector((st) => (st as any).workflows?.cloudSmsEnabled);
|
||||
|
||||
useEffect(() => { dispatch(fetchCloudSmsStatus()); }, [dispatch]);
|
||||
|
||||
// No silent enable-on-edit. The master Switch is now the single source
|
||||
// of truth for whether this schedule is armed.
|
||||
const setSched = useCallback((patch: Partial<ScheduleConfig>) => {
|
||||
setDraft({ ...draft, schedule: { ...s, ...patch } });
|
||||
}, [draft, s, setDraft]);
|
||||
|
||||
const addBackup = useCallback(() => {
|
||||
const tiers = [...(draft.permissions || [])];
|
||||
const next = nextTierAfter(tiers);
|
||||
if (!next) return;
|
||||
tiers.push(next);
|
||||
setDraft({ ...draft, permissions: tiers });
|
||||
}, [draft, setDraft]);
|
||||
|
||||
const removeTier = useCallback((idx: number) => {
|
||||
// Drop the removed tier AND all following tiers 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 setTier = useCallback((idx: number, patch: Partial<PermissionTier>) => {
|
||||
const tiers = [...(draft.permissions || [])];
|
||||
tiers[idx] = { ...tiers[idx], ...patch };
|
||||
setDraft({ ...draft, permissions: tiers });
|
||||
}, [draft, setDraft]);
|
||||
|
||||
const canAddBackup = ((draft.permissions || [])[ (draft.permissions || []).length - 1 ]?.kind || 'notify') !== 'call';
|
||||
const endKind = endKindFromSched(s);
|
||||
const nextPreview = useMemo(() => previewNextRun(s), [s]);
|
||||
const { info: appOpen, fix: fixAppOpen } = useAppOpenInfo();
|
||||
|
||||
const setEndKind = (k: EndKind) => {
|
||||
if (k === 'forever') setSched({ ends_at: null, max_runs: null });
|
||||
else if (k === 'on_date') setSched({ ends_at: new Date(Date.now() + 7 * 86400000).toISOString(), max_runs: null });
|
||||
else setSched({ ends_at: null, max_runs: 10 });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Master on/off. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Switch size="small" checked={s.enabled} onChange={(e) => setSched({ enabled: e.target.checked })} />
|
||||
<Typography sx={{ fontSize: BODY_FS, fontWeight: 700, color: c.text.primary }}>
|
||||
{s.enabled ? 'Schedule is on' : 'Schedule is off'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{s.enabled && (
|
||||
<AppOpenStatusBadge info={appOpen} hour={s.hour} minute={s.minute} onFix={fixAppOpen} />
|
||||
)}
|
||||
|
||||
{/* Section: When should this workflow run? */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography sx={{ fontSize: BODY_FS, fontWeight: 600, color: c.text.primary }}>
|
||||
When should this workflow run?
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>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: 56, 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.75, pl: 12, flexWrap: 'wrap' }}>
|
||||
<Typography sx={{ fontSize: LABEL_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: 28, height: 28, 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, flexWrap: 'wrap' }}>
|
||||
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>At</Typography>
|
||||
<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>
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.text.ghost, ml: 0.5 }}>{friendlyTzLabel(s.timezone)}</Typography>
|
||||
</Box>
|
||||
{nextPreview && s.enabled && (
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.accent.primary, pl: 12, fontWeight: 500 }}>
|
||||
Next run: {formatNextRun(nextPreview)}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mt: 0.5 }}>
|
||||
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>Runs</Typography>
|
||||
<Select
|
||||
size="small"
|
||||
value={endKind}
|
||||
onChange={(e) => setEndKind(e.target.value as EndKind)}
|
||||
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
|
||||
<MenuItem value="forever">Until I turn it off</MenuItem>
|
||||
<MenuItem value="on_date">Until a date</MenuItem>
|
||||
<MenuItem value="after_n">After a number of runs</MenuItem>
|
||||
</Select>
|
||||
{endKind === 'on_date' && (
|
||||
<InputBase
|
||||
type="date"
|
||||
value={s.ends_at ? s.ends_at.slice(0, 10) : ''}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setSched({ ends_at: v ? new Date(v + 'T23:59:59').toISOString() : null });
|
||||
}}
|
||||
sx={{ fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }}
|
||||
/>
|
||||
)}
|
||||
{endKind === 'after_n' && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<InputBase
|
||||
type="number"
|
||||
value={s.max_runs ?? 10}
|
||||
onChange={(e) => setSched({ max_runs: Math.max(1, Number(e.target.value) || 1) })}
|
||||
sx={{ width: 56, 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 }}>runs ({s.runs_count} so far)</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{(() => {
|
||||
if (endKind === 'on_date' && s.ends_at) {
|
||||
const ends = new Date(s.ends_at).getTime();
|
||||
if (!Number.isNaN(ends) && ends <= Date.now()) {
|
||||
return (
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, pl: 12 }}>
|
||||
This date is in the past. The schedule will turn itself off.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
}
|
||||
if (endKind === 'after_n' && s.max_runs != null && s.runs_count >= s.max_runs) {
|
||||
return (
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, pl: 12 }}>
|
||||
This workflow has already run {s.runs_count}× (limit {s.max_runs}). Raise the number or reset the counter to re-arm.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>If missed</Typography>
|
||||
<Select
|
||||
size="small"
|
||||
value={s.on_missed === 'run_all' ? 'run_once' : s.on_missed}
|
||||
onChange={(e) => setSched({ on_missed: e.target.value as ScheduleConfig['on_missed'] })}
|
||||
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
|
||||
<MenuItem value="skip">Skip the missed run</MenuItem>
|
||||
<MenuItem value="run_once">Run once after I wake the app</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Section: What can the agent do? */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography sx={{ fontSize: BODY_FS, fontWeight: 600, color: c.text.primary }}>
|
||||
What can the agent do?
|
||||
</Typography>
|
||||
<Select
|
||||
size="small"
|
||||
value={draft.actions.freeze ? 'scoped' : 'full'}
|
||||
onChange={(e) => {
|
||||
const scoped = e.target.value === 'scoped';
|
||||
if (!scoped) {
|
||||
const ok = window.confirm('Full access lets this scheduled run do anything an agent normally can: run commands, edit files, browse the web, send messages. Continue?');
|
||||
if (!ok) return;
|
||||
}
|
||||
setDraft({ ...draft, actions: { ...draft.actions, freeze: scoped } });
|
||||
}}
|
||||
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}>
|
||||
<MenuItem value="scoped">Only what the original chat used (recommended)</MenuItem>
|
||||
<MenuItem value="full">Anything an agent can do (run commands, edit files, browse)</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
{/* Section: How should the agent ask for your permission? */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography sx={{ fontSize: BODY_FS, fontWeight: 600, color: c.text.primary }}>
|
||||
How should the agent ask for your permission?
|
||||
</Typography>
|
||||
{(draft.permissions || []).map((tier, idx) => (
|
||||
<PermissionRow
|
||||
key={idx}
|
||||
idx={idx}
|
||||
tier={tier}
|
||||
cloudSmsEnabled={Boolean(cloudSms)}
|
||||
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 } }}>+ Escalate if I don't respond</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function AppOpenStatusBadge({ info, hour, minute, onFix }: { info: AppOpenInfo; hour: number; minute: number; onFix: () => void }) {
|
||||
const c = useClaudeTokens();
|
||||
const good = info.alwaysOn;
|
||||
const fmt = formatTime(hour, minute);
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1, pl: 0.25,
|
||||
bgcolor: good ? c.status.successBg : (c.status.warningBg || c.bg.elevated),
|
||||
border: `1px solid ${good ? c.status.success + '60' : (c.status.warning || c.text.muted) + '60'}`,
|
||||
borderRadius: `${c.radius.md}px`, px: 1, py: 0.5,
|
||||
}}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: good ? c.status.success : (c.status.warning || c.text.muted) }} />
|
||||
<Typography sx={{ flex: 1, fontSize: HINT_FS, color: c.text.primary }}>
|
||||
{good ? 'Will run even if you close OpenSwarm.' : `OpenSwarm must be open at ${fmt} for this to run.`}
|
||||
</Typography>
|
||||
{!good && (
|
||||
<Tooltip title="One click: start OpenSwarm automatically when you log in, and keep a small icon in your menubar so it stays running when you close the window. You can undo both later in Settings.">
|
||||
<Box onClick={onFix} role="button" sx={{ fontSize: HINT_FS, color: c.accent.primary, cursor: 'pointer', fontWeight: 700, whiteSpace: 'nowrap' }}>Always-on</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionRow({ idx, tier, cloudSmsEnabled, onChange, onRemove }: {
|
||||
idx: number;
|
||||
tier: PermissionTier;
|
||||
cloudSmsEnabled: boolean;
|
||||
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 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 }}>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</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>
|
||||
{!cloudSmsEnabled && (
|
||||
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, fontStyle: 'italic' }}>
|
||||
Coming soon. Until cloud SMS ships, this tier falls back to an in-app notify with a "fallback" badge.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
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 IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import BookmarkIcon from '@mui/icons-material/BookmarkBorderRounded';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import CalendarMonthIcon from '@mui/icons-material/CalendarMonthRounded';
|
||||
import OpenInFullIcon from '@mui/icons-material/OpenInFullRounded';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
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';
|
||||
import { addDays, startOfWeek } from './scheduleUtils';
|
||||
|
||||
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;
|
||||
/** When true, hides the internal Search/Schedule chips + redundant "+ New"
|
||||
* pill. The new DashboardToolbar pills above the popover replace them. */
|
||||
hideTopChrome?: boolean;
|
||||
}
|
||||
|
||||
export default function SchedulePopover({
|
||||
mode, onModeChange, historyResults, historyLoading, historyQuery, onHistoryQueryChange,
|
||||
onHistorySelect, onNewChat, onWorkflowSelect, onExpand, historyScrollRef, onHistoryScroll,
|
||||
hideTopChrome = false,
|
||||
}: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const [calendarView, setCalendarView] = useState<'Week' | 'Month' | 'List'>('Week');
|
||||
const [refDate, setRefDate] = useState<Date>(() => new Date());
|
||||
const workflows = useAppSelector((s) => s.workflows.items);
|
||||
|
||||
const periodLabel = useMemo(() => {
|
||||
if (calendarView === 'Month') {
|
||||
return refDate.toLocaleString('en', { month: 'long', year: 'numeric' });
|
||||
}
|
||||
if (calendarView === 'Week') {
|
||||
const start = startOfWeek(refDate);
|
||||
const end = addDays(start, 6);
|
||||
const sameMonth = start.getMonth() === end.getMonth();
|
||||
const startStr = start.toLocaleString('en', { month: 'short', day: 'numeric' });
|
||||
const endStr = sameMonth
|
||||
? String(end.getDate())
|
||||
: end.toLocaleString('en', { month: 'short', day: 'numeric' });
|
||||
return `${startStr} – ${endStr}, ${end.getFullYear()}`;
|
||||
}
|
||||
return refDate.toLocaleString('en', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||
}, [refDate, calendarView]);
|
||||
|
||||
const onPrev = useCallback(() => {
|
||||
setRefDate((d) => addDays(d, calendarView === 'Month' ? -28 : calendarView === 'Week' ? -7 : -1));
|
||||
}, [calendarView]);
|
||||
const onNext = useCallback(() => {
|
||||
setRefDate((d) => addDays(d, calendarView === 'Month' ? 28 : calendarView === 'Week' ? 7 : 1));
|
||||
}, [calendarView]);
|
||||
|
||||
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. Hidden when the parent toolbar supplies its
|
||||
own pill row (Image #32 / #54); kept around so the legacy callers
|
||||
that surface Schedule mode still have a way in. */}
|
||||
{!hideTopChrome && (
|
||||
<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 } }}
|
||||
/>
|
||||
{!hideTopChrome && (
|
||||
<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 hasWorkflow = Boolean(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>
|
||||
{/* Only annotate chats that became saved workflows.
|
||||
A small workflow glyph reads as a tag, where the
|
||||
old single-letter chip read as a random initial. */}
|
||||
{hasWorkflow && (
|
||||
<Tooltip title="This chat is saved as a workflow">
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 18, height: 18, borderRadius: '4px', color: c.text.muted }}>
|
||||
<BookmarkIcon sx={{ fontSize: 13 }} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
<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>
|
||||
{/* Period nav: Today pill, prev/next chevrons, range label.
|
||||
Apple Calendar pattern. Keeps the popover usable without
|
||||
forcing a full Expand for date browsing. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 1.5, pb: 0.75, flexShrink: 0 }}>
|
||||
<Box
|
||||
onClick={() => setRefDate(new Date())}
|
||||
role="button"
|
||||
sx={{
|
||||
fontSize: '0.78rem', fontWeight: 600, color: c.text.secondary,
|
||||
border: `1px solid ${c.border.subtle}`, px: 0.95, py: 0.3,
|
||||
borderRadius: `${c.radius.md}px`, cursor: 'pointer',
|
||||
'&:hover': { color: c.text.primary, borderColor: c.border.medium },
|
||||
}}>Today</Box>
|
||||
<IconButton size="small" onClick={onPrev} sx={{ p: 0.3, color: c.text.muted, '&:hover': { color: c.text.primary } }}><ChevronLeftIcon sx={{ fontSize: 17 }} /></IconButton>
|
||||
<IconButton size="small" onClick={onNext} sx={{ p: 0.3, color: c.text.muted, '&:hover': { color: c.text.primary } }}><ChevronRightIcon sx={{ fontSize: 17 }} /></IconButton>
|
||||
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.primary, ml: 0.25 }}>{periodLabel}</Typography>
|
||||
</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} refDate={refDate} />
|
||||
</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,240 @@
|
||||
// Minimum-steps-to-value entry point: from any open chat, hit "Schedule"
|
||||
// in the header, pick one of four presets, and we materialize a workflow
|
||||
// seeded with source_session_id (so it inherits the chat's tool surface
|
||||
// + steps via the existing /workflows/create path). "Custom..." opens a
|
||||
// LOCAL draft card instead of immediately POSTing /workflows/create, so
|
||||
// users who change their mind don't leave behind an orphan workflow.
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { createWorkflow, openWorkflowCard, type ScheduleConfig, type Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { defaultSchedule } from './scheduleUtils';
|
||||
|
||||
type Preset = {
|
||||
label: string;
|
||||
hint: string;
|
||||
build: () => Partial<ScheduleConfig>;
|
||||
};
|
||||
|
||||
const PRESETS: Preset[] = [
|
||||
{ label: 'Every day at 9am', hint: 'Daily standup, morning report', build: () => ({ enabled: true, repeat_unit: 'day', repeat_every: 1, hour: 9, minute: 0 }) },
|
||||
{ label: 'Weekdays at 9am', hint: 'Mon to Fri', build: () => ({ enabled: true, repeat_unit: 'week', repeat_every: 1, on_days: [1, 2, 3, 4, 5], hour: 9, minute: 0 }) },
|
||||
{ label: 'Every Monday at 9am', hint: 'Weekly check-in', build: () => ({ enabled: true, repeat_unit: 'week', repeat_every: 1, on_days: [1], hour: 9, minute: 0 }) },
|
||||
{ label: 'Every month on the 1st', hint: 'Monthly summary, billing report', build: () => ({ enabled: true, repeat_unit: 'month', repeat_every: 1, hour: 9, minute: 0 }) },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
anchorEl: HTMLElement | null;
|
||||
onClose: () => void;
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
// Hook so the caller can show "Workflow created" feedback inline.
|
||||
onCreated?: (workflowId: string) => void;
|
||||
// Auto-suggest path: when the caller detected time-words and wants to
|
||||
// pre-fill the popover with that exact schedule, the first preset
|
||||
// shown becomes "Use suggestion: <label>" and is set as the default.
|
||||
prefillSchedule?: ScheduleConfig | null;
|
||||
prefillLabel?: string | null;
|
||||
}
|
||||
|
||||
export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sessionName, onCreated, prefillSchedule, prefillLabel }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [title, setTitle] = useState<string>(sessionName || 'Untitled');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const workflows = useAppSelector((s) => s.workflows.items);
|
||||
// Workflow cards only render inside the Dashboard canvas. When this
|
||||
// popover is opened from somewhere else (Apps editor, etc.), Custom...
|
||||
// would silently drop the user on a non-canvas page with no visible
|
||||
// editor — see this session's chat history. Look up the session's
|
||||
// dashboard so we can navigate there before opening the draft.
|
||||
const sessionDashboardId = useAppSelector(
|
||||
(s) => sessionId ? s.agents.sessions[sessionId]?.dashboard_id : null,
|
||||
);
|
||||
|
||||
// Dup-detect: a chat session can only sanely have one schedule attached.
|
||||
// If we find one already, offer "Open existing" instead of silently
|
||||
// creating a duplicate that fires twice.
|
||||
const existing = useMemo<Workflow | null>(() => {
|
||||
if (!sessionId) return null;
|
||||
for (const w of Object.values(workflows)) {
|
||||
if (w.source_session_id === sessionId) return w;
|
||||
}
|
||||
return null;
|
||||
}, [workflows, sessionId]);
|
||||
|
||||
const submit = useCallback(async (preset: Preset) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const schedule: ScheduleConfig = { ...defaultSchedule(), ...preset.build() };
|
||||
const result = await dispatch(createWorkflow({
|
||||
title,
|
||||
source_session_id: sessionId,
|
||||
schedule,
|
||||
} as Partial<Workflow>));
|
||||
if (createWorkflow.fulfilled.match(result)) {
|
||||
const wf = result.payload as Workflow;
|
||||
dispatch(addWorkflowCard({ workflowId: wf.id, sourceSessionId: sessionId }));
|
||||
dispatch(openWorkflowCard({ workflowId: wf.id, view: 'saved' }));
|
||||
onCreated?.(wf.id);
|
||||
onClose();
|
||||
} else {
|
||||
setError('Create failed. Try again.');
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message || 'Create failed.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, dispatch, sessionId, title, onClose, onCreated]);
|
||||
|
||||
const openCustom = useCallback(() => {
|
||||
// Open a local draft. NO backend create yet — the workflow only
|
||||
// exists on disk once the user clicks Save in the editor. Closing
|
||||
// the draft card from here leaves nothing behind (the "orphan"
|
||||
// bug from the previous create-then-edit flow).
|
||||
const tempId = `draft-${sessionId}-${Date.now()}`;
|
||||
dispatch(addWorkflowCard({ workflowId: tempId, sourceSessionId: sessionId }));
|
||||
dispatch(openWorkflowCard({
|
||||
workflowId: tempId,
|
||||
sourceSessionId: sessionId,
|
||||
view: 'preview',
|
||||
draft: {
|
||||
title,
|
||||
description: 'Scheduled from chat. Edit anytime.',
|
||||
steps: [{ id: 'step-1', text: '' }],
|
||||
schedule: { ...defaultSchedule() },
|
||||
} as Partial<Workflow>,
|
||||
}));
|
||||
// If the user opened this popover from somewhere other than the
|
||||
// dashboard canvas (e.g. the Apps editor), the draft card we just
|
||||
// created is invisible because <WorkflowCard /> is only rendered on
|
||||
// /dashboard/<id>. Navigate there so the user lands on the editable
|
||||
// card. No-op when already on a dashboard route.
|
||||
if (sessionDashboardId && !location.pathname.startsWith('/dashboard/')) {
|
||||
navigate(`/dashboard/${sessionDashboardId}`);
|
||||
}
|
||||
onClose();
|
||||
}, [dispatch, sessionId, title, onClose, sessionDashboardId, navigate, location.pathname]);
|
||||
|
||||
const openExisting = useCallback(() => {
|
||||
if (!existing) return;
|
||||
dispatch(addWorkflowCard({ workflowId: existing.id, sourceSessionId: sessionId }));
|
||||
dispatch(openWorkflowCard({ workflowId: existing.id, view: 'saved' }));
|
||||
onClose();
|
||||
}, [dispatch, existing, sessionId, onClose]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={onClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
slotProps={{ paper: { sx: { width: 320, p: 1.25 } } }}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.75 }}>
|
||||
SCHEDULE THIS CHAT
|
||||
</Typography>
|
||||
{existing && (
|
||||
<Box sx={{
|
||||
display: 'flex', flexDirection: 'column', gap: 0.4,
|
||||
px: 1, py: 0.75, mb: 0.75,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: c.status.warningBg || c.bg.elevated,
|
||||
border: `1px solid ${(c.status.warning || c.text.muted) + '60'}`,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.text.primary }}>
|
||||
This chat is already scheduled.
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>
|
||||
"{existing.title}" was made from this conversation. Adding another would fire twice.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, mt: 0.5 }}>
|
||||
<Box onClick={openExisting} role="button" sx={{
|
||||
fontSize: '0.74rem', fontWeight: 600, color: c.accent.primary,
|
||||
cursor: 'pointer', px: 0.75, py: 0.3, borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: c.accent.primary + '14', border: `1px solid ${c.accent.primary}40`,
|
||||
'&:hover': { bgcolor: c.accent.primary + '22' },
|
||||
}}>Open existing →</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary }}>Name:</Typography>
|
||||
<InputBase
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
sx={{ flex: 1, fontSize: '0.85rem', color: c.text.primary, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.3 }}
|
||||
/>
|
||||
</Box>
|
||||
{prefillSchedule && prefillLabel && (
|
||||
<Box
|
||||
role="button"
|
||||
onClick={() => submit({
|
||||
label: prefillLabel,
|
||||
hint: 'Detected from your conversation',
|
||||
build: () => prefillSchedule as Partial<ScheduleConfig>,
|
||||
})}
|
||||
sx={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'flex-start',
|
||||
px: 1, py: 0.7, borderRadius: `${c.radius.md}px`,
|
||||
mb: 0.5,
|
||||
border: `1px solid ${c.accent.primary}55`,
|
||||
bgcolor: c.accent.primary + '14',
|
||||
cursor: busy ? 'wait' : 'pointer',
|
||||
opacity: busy ? 0.5 : 1,
|
||||
'&:hover': { bgcolor: c.accent.primary + '22' },
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.accent.primary, letterSpacing: '0.04em' }}>SUGGESTED</Typography>
|
||||
<Typography sx={{ fontSize: '0.86rem', fontWeight: 600, color: c.text.primary }}>{prefillLabel}</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>Detected from your last reply</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{PRESETS.map((p) => (
|
||||
<Box
|
||||
key={p.label}
|
||||
role="button"
|
||||
onClick={() => submit(p)}
|
||||
sx={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'flex-start',
|
||||
px: 1, py: 0.6, borderRadius: `${c.radius.md}px`,
|
||||
cursor: busy ? 'wait' : 'pointer',
|
||||
opacity: busy ? 0.5 : 1,
|
||||
'&:hover': { bgcolor: c.bg.elevated },
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '0.86rem', fontWeight: 600, color: c.text.primary }}>{p.label}</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>{p.hint}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
<Box
|
||||
role="button"
|
||||
onClick={openCustom}
|
||||
sx={{
|
||||
mt: 0.5, borderTop: `1px solid ${c.border.subtle}`,
|
||||
px: 1, py: 0.7, borderRadius: `${c.radius.md}px`,
|
||||
cursor: busy ? 'wait' : 'pointer',
|
||||
opacity: busy ? 0.5 : 1,
|
||||
'&:hover': { bgcolor: c.bg.elevated },
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.accent.primary }}>Custom…</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>Open the editor without saving yet</Typography>
|
||||
</Box>
|
||||
{error && (
|
||||
<Typography sx={{ mt: 0.5, fontSize: '0.74rem', color: c.status.error }}>{error}</Typography>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// Image #49: natural-language schedule composer.
|
||||
// Header morphs into `[trash] Cancel task scheduling`. Body shows a soft
|
||||
// frame around the read-only step list, an agent reply bubble asking for
|
||||
// the cadence, and a chat-style composer at the bottom. On submit we
|
||||
// hit /workflows/{id}/parse-schedule (aux LLM), surface the parsed
|
||||
// ScheduleConfig in a confirmation modal (the "always ask permission"
|
||||
// stand-in for the schedule_workflow tool call), and PATCH on confirm.
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DeleteOutlineRounded from '@mui/icons-material/DeleteOutlineRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { updateWorkflow, updateWorkflowCard, type ScheduleConfig, type Workflow } from '@/shared/state/workflowsSlice';
|
||||
import StepList from './StepList';
|
||||
import { API_BASE, getAuthToken } from '@/shared/config';
|
||||
import { useAppSelector as _useAppSelector } from '@/shared/hooks';
|
||||
import ChatInput from '@/app/pages/AgentChat/ChatInput';
|
||||
|
||||
interface Props {
|
||||
workflow: Workflow;
|
||||
steps: Workflow['steps'];
|
||||
}
|
||||
|
||||
function InlineSubtitle({ workflow }: { workflow: Workflow }) {
|
||||
const c = useClaudeTokens();
|
||||
const modelsByProvider = _useAppSelector((s) => s.models.byProvider);
|
||||
const runs = _useAppSelector((s) => s.workflows.runs[workflow.id]);
|
||||
const modelLabel = React.useMemo(() => {
|
||||
if (!workflow?.model) return '';
|
||||
for (const list of Object.values(modelsByProvider || {})) {
|
||||
for (const m of (list as Array<{ value: string; label?: string }>) || []) {
|
||||
if (m.value === workflow.model) return m.label || workflow.model;
|
||||
}
|
||||
}
|
||||
return workflow.model;
|
||||
}, [workflow?.model, modelsByProvider]);
|
||||
const duration = React.useMemo(() => {
|
||||
if (!runs || runs.length === 0) return '';
|
||||
const last = runs.find((r) => r.finished_at);
|
||||
if (!last || !last.finished_at) return '';
|
||||
const ms = new Date(last.finished_at).getTime() - new Date(last.started_at).getTime();
|
||||
if (ms <= 0) return '';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
||||
return `${Math.floor(ms / 60_000)}m`;
|
||||
}, [runs]);
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1.25, fontSize: '0.82rem', color: c.text.muted, minWidth: 0, overflow: 'hidden' }}>
|
||||
{modelLabel && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{modelLabel}</Box>}
|
||||
{workflow.mode && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{workflow.mode}</Box>}
|
||||
{duration && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{duration}</Box>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SchedulingView({ workflow, steps }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState<ScheduleConfig | null>(null);
|
||||
|
||||
// The composer behaves like any normal chat: it defaults to the user's
|
||||
// configured default model/mode (e.g. their subscription model), not the
|
||||
// workflow's stored run model, and its pickers actually work.
|
||||
const defaultModel = _useAppSelector((s) => s.settings.data.default_model);
|
||||
const defaultMode = _useAppSelector((s) => s.settings.data.default_mode);
|
||||
const settingsLoaded = _useAppSelector((s) => s.settings.loaded);
|
||||
const [chatModel, setChatModel] = useState(defaultModel || 'sonnet');
|
||||
const [chatMode, setChatMode] = useState(defaultMode || 'agent');
|
||||
const settingsApplied = useRef(false);
|
||||
useEffect(() => {
|
||||
if (settingsLoaded && !settingsApplied.current) {
|
||||
setChatModel(defaultModel || 'sonnet');
|
||||
setChatMode(defaultMode || 'agent');
|
||||
settingsApplied.current = true;
|
||||
}
|
||||
}, [settingsLoaded, defaultModel, defaultMode]);
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } }));
|
||||
}, [dispatch, workflow.id]);
|
||||
|
||||
const onSubmit = useCallback(async (text: string) => {
|
||||
const cleaned = (text || '').trim();
|
||||
if (!cleaned || busy) return undefined;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflow.id)}/parse-schedule`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) },
|
||||
body: JSON.stringify({ text: cleaned }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError(`Couldn't parse that. Try "every Wednesday at 1pm" or "Mondays at 3pm".`);
|
||||
return undefined;
|
||||
}
|
||||
const data = await res.json();
|
||||
const cfg = data?.schedule as ScheduleConfig | undefined;
|
||||
if (!cfg) {
|
||||
setError(`Couldn't read a schedule out of that. Try being more specific.`);
|
||||
return undefined;
|
||||
}
|
||||
setPending(cfg);
|
||||
return cfg;
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message || 'Network error.');
|
||||
return undefined;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, workflow.id]);
|
||||
|
||||
const onConfirm = useCallback(async () => {
|
||||
if (!pending) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await dispatch(updateWorkflow({
|
||||
id: workflow.id,
|
||||
patch: { schedule: { ...pending, enabled: true } as Workflow['schedule'] },
|
||||
ifMatch: workflow.updated_at || null,
|
||||
}));
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setPending(null);
|
||||
}
|
||||
}, [pending, dispatch, workflow.id, workflow.updated_at]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
{/* Inline header replacement. Image #49: subtitle on LEFT, Cancel
|
||||
on RIGHT. Cancel matches the subtitle's weight/size/color so the
|
||||
row reads as peers, not a heavy CTA; it just reddens on hover. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<InlineSubtitle workflow={workflow} />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box
|
||||
onClick={onCancel}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
fontSize: '0.82rem', fontWeight: 500,
|
||||
color: c.text.muted, cursor: 'pointer',
|
||||
'&:hover': { color: c.status.error },
|
||||
}}>
|
||||
<DeleteOutlineRounded sx={{ fontSize: 15 }} />
|
||||
Cancel task scheduling
|
||||
</Box>
|
||||
</Box>
|
||||
<StepList steps={steps} />
|
||||
<Typography sx={{ fontSize: '0.92rem', color: c.text.secondary, lineHeight: 1.45, mt: 0.5 }}>
|
||||
When should this workflow run (e.g. every Wednesday at 1pm)
|
||||
</Typography>
|
||||
{error && (
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.status.error }}>{error}</Typography>
|
||||
)}
|
||||
{/* Spacer pushes the composer to the bottom of the card so the view
|
||||
reads like a normal chat (prompt up top, input docked below). */}
|
||||
<Box sx={{ flex: 1, minHeight: 40 }} />
|
||||
{/* Real ChatInput (same one the toolbar / agent chat use) so the
|
||||
composer matches Image #54 / #64 exactly: live model picker,
|
||||
mode picker, thinking level, paperclip + mic, the works. We
|
||||
ignore everything except the message text on send and route it
|
||||
through /parse-schedule. sessionId is a stable per-workflow id
|
||||
so ChatInput's draft persistence survives view re-mounts. */}
|
||||
<Box sx={{ mx: -0.5 }}>
|
||||
<ChatInput
|
||||
onSend={(msg) => { void onSubmit(msg); }}
|
||||
mode={chatMode}
|
||||
onModeChange={setChatMode}
|
||||
model={chatModel}
|
||||
onModelChange={setChatModel}
|
||||
embedded
|
||||
autoFocus
|
||||
sessionId={`schedule-${workflow.id}`}
|
||||
disabled={busy}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Dialog open={!!pending} onClose={() => setPending(null)}>
|
||||
<Box sx={{ p: 2.5, minWidth: 360, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Typography sx={{ fontSize: '1rem', fontWeight: 700, color: c.text.primary }}>
|
||||
Schedule this workflow?
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.9rem', color: c.text.secondary, lineHeight: 1.5 }}>
|
||||
The agent wants to set <b>{workflow.title}</b> to run <b>{pending && describe(pending)}</b>. You can change or cancel this anytime.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 0.5 }}>
|
||||
<Box
|
||||
onClick={() => setPending(null)}
|
||||
role="button"
|
||||
sx={{ fontSize: '0.86rem', color: c.text.secondary, px: 1, py: 0.6, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>
|
||||
Cancel
|
||||
</Box>
|
||||
<Box
|
||||
onClick={onConfirm}
|
||||
role="button"
|
||||
sx={{
|
||||
fontSize: '0.86rem', fontWeight: 700,
|
||||
color: '#fff', bgcolor: c.accent.primary,
|
||||
px: 1.4, py: 0.55, borderRadius: 999, cursor: 'pointer',
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
{busy ? 'Applying…' : 'Schedule it'}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function describe(s: ScheduleConfig): string {
|
||||
const h12 = ((s.hour + 11) % 12) + 1;
|
||||
const ampm = s.hour < 12 ? 'am' : 'pm';
|
||||
const time = s.minute === 0 ? `${h12}${ampm}` : `${h12}:${String(s.minute).padStart(2, '0')}${ampm}`;
|
||||
if (s.repeat_unit === 'day') return s.repeat_every === 1 ? `every day at ${time}` : `every ${s.repeat_every} days at ${time}`;
|
||||
if (s.repeat_unit === 'month') return s.repeat_every === 1 ? `every month at ${time}` : `every ${s.repeat_every} months at ${time}`;
|
||||
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
if (s.on_days.length === 5 && [1,2,3,4,5].every((d) => s.on_days.includes(d))) return `weekdays at ${time}`;
|
||||
if (s.on_days.length === 2 && [0,6].every((d) => s.on_days.includes(d))) return `weekends at ${time}`;
|
||||
if (s.on_days.length === 1) return `${labels[s.on_days[0]]}s at ${time}`;
|
||||
return `weekly at ${time}`;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// Vertical step list, the one shared building block across every
|
||||
// workflow card subview. Supports three orthogonal modes that compose:
|
||||
//
|
||||
// editable onChangeStep is set -> each row is a TextareaAutosize
|
||||
// (PreviewView only).
|
||||
// expandable expandable=true -> chevron next to each title;
|
||||
// click reveals the raw prompt body.
|
||||
// live stepStatuses is set -> per-step circle becomes done/active/
|
||||
// failed; Running view also surfaces
|
||||
// activeStepSubtitle + duration.
|
||||
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import TextareaAutosize from '@mui/material/TextareaAutosize';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import KeyboardArrowDownRounded from '@mui/icons-material/KeyboardArrowDownRounded';
|
||||
import CheckRounded from '@mui/icons-material/CheckRounded';
|
||||
import CloseRounded from '@mui/icons-material/CloseRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import type { Workflow, WorkflowRun } from '@/shared/state/workflowsSlice';
|
||||
|
||||
export type StepStatus = 'pending' | 'active' | 'done' | 'failed';
|
||||
|
||||
interface Props {
|
||||
workflow?: Workflow | null;
|
||||
steps: Workflow['steps'];
|
||||
runs?: WorkflowRun[];
|
||||
activeRunId?: string | null;
|
||||
framed?: boolean;
|
||||
// Edit mode
|
||||
onChangeStep?: (idx: number, text: string) => void;
|
||||
// Expand mode
|
||||
expandable?: boolean;
|
||||
expandedIds?: string[];
|
||||
onToggleExpand?: (id: string) => void;
|
||||
// Live mode
|
||||
stepStatuses?: StepStatus[];
|
||||
activeStepSubtitle?: string | null;
|
||||
activeStepDuration?: string | null;
|
||||
// Cap visible rows; render "... N more" beneath when truncated.
|
||||
maxVisible?: number;
|
||||
}
|
||||
|
||||
const CIRCLE_SIZE = 24;
|
||||
const CONNECTOR_X = CIRCLE_SIZE / 2;
|
||||
|
||||
export default function StepList(props: Props) {
|
||||
const {
|
||||
steps, framed, onChangeStep,
|
||||
expandable, expandedIds, onToggleExpand,
|
||||
stepStatuses, activeStepSubtitle, activeStepDuration,
|
||||
maxVisible = 4,
|
||||
} = props;
|
||||
const c = useClaudeTokens();
|
||||
if (!steps || steps.length === 0) return null;
|
||||
|
||||
const visible = steps.slice(0, maxVisible);
|
||||
const hiddenCount = Math.max(0, steps.length - visible.length);
|
||||
const expanded = new Set(expandedIds || []);
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative', pl: 0, mt: 0.25 }}>
|
||||
{visible.length > 1 && (
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: CONNECTOR_X - 0.5,
|
||||
top: CIRCLE_SIZE * 0.5,
|
||||
bottom: CIRCLE_SIZE * 0.5,
|
||||
// '1px' not 1: MUI sx treats width:1 as 100%, which rendered the
|
||||
// connector as a full-width grey band behind the steps.
|
||||
width: '1px',
|
||||
bgcolor: c.border.medium,
|
||||
opacity: 0.65,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.85 }}>
|
||||
{visible.map((s, idx) => {
|
||||
const status: StepStatus = stepStatuses?.[idx] ?? 'pending';
|
||||
const isActive = status === 'active';
|
||||
const isDone = status === 'done';
|
||||
const isFailed = status === 'failed';
|
||||
const isExpanded = expanded.has(s.id);
|
||||
const label = (s.label || '').trim() || firstWords(s.text, 6);
|
||||
const rawBody = (s.text || '').trim();
|
||||
const hasExpandableBody = expandable && rawBody && rawBody !== label;
|
||||
|
||||
return (
|
||||
<Box key={s.id} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Box
|
||||
onClick={hasExpandableBody && !isActive ? () => onToggleExpand?.(s.id) : undefined}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 1.25,
|
||||
position: 'relative',
|
||||
cursor: hasExpandableBody && !isActive ? 'pointer' : 'default',
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
px: (isActive || (isExpanded && hasExpandableBody)) ? 0.5 : 0,
|
||||
py: (isActive || (isExpanded && hasExpandableBody)) ? 0.5 : 0,
|
||||
mx: (isActive || (isExpanded && hasExpandableBody)) ? -0.5 : 0,
|
||||
bgcolor: (isActive || (isExpanded && hasExpandableBody)) ? c.bg.elevated : 'transparent',
|
||||
transition: 'background 0.18s ease',
|
||||
'&:hover': hasExpandableBody && !isActive ? { bgcolor: c.bg.elevated } : {},
|
||||
}}>
|
||||
<StepDisc
|
||||
index={idx}
|
||||
status={status}
|
||||
framed={!!framed}
|
||||
c={c}
|
||||
/>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
{onChangeStep ? (
|
||||
<TextareaAutosize
|
||||
value={s.text}
|
||||
onChange={(e) => onChangeStep(idx, e.target.value)}
|
||||
minRows={1}
|
||||
style={{
|
||||
width: '100%',
|
||||
resize: 'none',
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '0.92rem',
|
||||
color: c.text.primary,
|
||||
border: framed ? `1px solid ${c.border.medium}` : '1px solid transparent',
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
background: framed ? c.bg.surface : 'transparent',
|
||||
padding: '6px 10px',
|
||||
lineHeight: 1.45,
|
||||
outline: 'none',
|
||||
overflow: 'hidden',
|
||||
transition: 'border-color 0.12s ease, background 0.12s ease',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minHeight: CIRCLE_SIZE }}>
|
||||
<Typography sx={{
|
||||
fontSize: '0.92rem',
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
color: c.text.primary,
|
||||
lineHeight: 1.45,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{label}
|
||||
</Typography>
|
||||
{isActive && activeStepDuration && (
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mr: hasExpandableBody ? 0 : 0.5, flexShrink: 0 }}>
|
||||
{activeStepDuration}
|
||||
</Typography>
|
||||
)}
|
||||
{hasExpandableBody && !isActive && (
|
||||
<KeyboardArrowDownRounded sx={{
|
||||
fontSize: 18,
|
||||
color: c.text.muted,
|
||||
transform: isExpanded ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.18s ease',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{/* Active step: tool call subtitle. Sits under the title
|
||||
with a small leading glyph so the user can read it as
|
||||
"what the agent is doing right now". */}
|
||||
{isActive && activeStepSubtitle && (
|
||||
<Typography sx={{
|
||||
fontSize: '0.82rem',
|
||||
color: c.text.secondary,
|
||||
mt: 0.4,
|
||||
display: 'flex', alignItems: 'center', gap: 0.5,
|
||||
}}>
|
||||
<Box component="span" sx={{ display: 'inline-flex', fontSize: 13 }}>{'▢'}</Box>
|
||||
{activeStepSubtitle}
|
||||
</Typography>
|
||||
)}
|
||||
{/* Expanded body: the raw prompt that lives under the
|
||||
LLM label. Soft elevated panel so it reads as a
|
||||
drill-down, not a separate step. */}
|
||||
{hasExpandableBody && isExpanded && (
|
||||
<Box sx={{
|
||||
mt: 0.6,
|
||||
p: 1,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: c.bg.elevated,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, lineHeight: 1.5, whiteSpace: 'pre-wrap' }}>
|
||||
{rawBody}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{isFailed && undefined}
|
||||
{isDone && undefined}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{hiddenCount > 0 && (
|
||||
<Typography sx={{
|
||||
fontSize: '0.86rem',
|
||||
color: c.text.secondary,
|
||||
mt: 0.6,
|
||||
ml: 0,
|
||||
}}>
|
||||
... {hiddenCount} more
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function StepDisc({ index, status, framed, c }: { index: number; status: StepStatus; framed: boolean; c: ReturnType<typeof useClaudeTokens> }) {
|
||||
if (status === 'done') {
|
||||
return (
|
||||
<Box sx={{
|
||||
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
|
||||
bgcolor: c.text.muted + '55',
|
||||
color: '#fff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0, position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
<CheckRounded sx={{ fontSize: 15 }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<Box sx={{
|
||||
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
|
||||
bgcolor: c.status.error,
|
||||
color: '#fff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0, position: 'relative', zIndex: 1,
|
||||
boxShadow: `0 0 0 3px ${c.status.error}22`,
|
||||
}}>
|
||||
<CloseRounded sx={{ fontSize: 15 }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (status === 'active') {
|
||||
return (
|
||||
<Box sx={{
|
||||
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
|
||||
border: `2px solid ${c.accent.primary}`,
|
||||
bgcolor: c.bg.surface,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0, position: 'relative', zIndex: 1,
|
||||
animation: 'workflow-step-spin 1.4s linear infinite',
|
||||
'@keyframes workflow-step-spin': {
|
||||
'0%': { boxShadow: `0 0 0 0 ${c.accent.primary}55` },
|
||||
'50%': { boxShadow: `0 0 0 4px ${c.accent.primary}00` },
|
||||
'100%': { boxShadow: `0 0 0 0 ${c.accent.primary}55` },
|
||||
},
|
||||
}}>
|
||||
<Box sx={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
border: `1.5px solid ${c.accent.primary}`,
|
||||
borderTopColor: 'transparent',
|
||||
animation: 'workflow-step-dot 0.9s linear infinite',
|
||||
'@keyframes workflow-step-dot': {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'100%': { transform: 'rotate(360deg)' },
|
||||
},
|
||||
}} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
// pending
|
||||
void framed;
|
||||
void index;
|
||||
return (
|
||||
<Box sx={{
|
||||
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
bgcolor: c.bg.surface,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0, position: 'relative', zIndex: 1,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
function firstWords(s: string, n: number): string {
|
||||
const words = (s || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length <= n) return words.join(' ');
|
||||
return words.slice(0, n).join(' ') + '...';
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { motion } from 'framer-motion';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import HistoryIcon from '@mui/icons-material/HistoryRounded';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrowRounded';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import {
|
||||
closeWorkflowCard,
|
||||
fetchRuns,
|
||||
openWorkflowCard as openWorkflowCardAction,
|
||||
rekeyOpenCard,
|
||||
runWorkflowNow,
|
||||
updateWorkflow,
|
||||
updateWorkflowCard,
|
||||
type Workflow,
|
||||
} from '@/shared/state/workflowsSlice';
|
||||
import {
|
||||
DEFAULT_CARD_H,
|
||||
DEFAULT_CARD_W,
|
||||
placeCard,
|
||||
rekeyWorkflowCard,
|
||||
removeWorkflowCard,
|
||||
setWorkflowCardPosition,
|
||||
setWorkflowCardSize,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
|
||||
import { fetchSession } from '@/shared/state/agentsSlice';
|
||||
import WorkflowEditViews from './WorkflowEditViews';
|
||||
import { HistoryDetail, HistoryList, PreviewView, SavedView } from './WorkflowCardSubviews';
|
||||
import { CompletedView, FailedView, RunningView } from './WorkflowCardLiveViews';
|
||||
import SchedulingView from './SchedulingView';
|
||||
import EditAgentView from './EditAgentView';
|
||||
import StopRounded from '@mui/icons-material/StopRounded';
|
||||
import PauseRounded from '@mui/icons-material/PauseRounded';
|
||||
import { StatusDot, RunSparkline, LastFiredHint, isStaleSinceLastRun } from './workflowVisuals';
|
||||
import { store } from '@/shared/state/store';
|
||||
import { getAgentWorkTime } from '@/shared/agentWorkTime';
|
||||
|
||||
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]);
|
||||
const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds);
|
||||
|
||||
// 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);
|
||||
const [runToast, setRunToast] = useState<string | null>(null);
|
||||
const [editDirty, setEditDirty] = useState(false);
|
||||
// First-success celebration: one tiny burst the first time the
|
||||
// workflow ever reaches success. We track the celebration in localStorage
|
||||
// keyed by workflow id so we don't repeat it across reloads.
|
||||
const [celebrate, setCelebrate] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!workflow || !runs || runs.length === 0) return;
|
||||
const successes = runs.filter((r) => r.status === 'success');
|
||||
if (successes.length !== 1) return;
|
||||
const key = `openswarm:first-success:${workflow.id}`;
|
||||
if (typeof localStorage !== 'undefined' && localStorage.getItem(key)) return;
|
||||
setCelebrate(true);
|
||||
try { localStorage.setItem(key, '1'); } catch { /* private mode etc. */ }
|
||||
const t = window.setTimeout(() => setCelebrate(false), 2200);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [workflow?.id, runs]);
|
||||
|
||||
// Lazy-load runs whenever a view that needs them is open. Saved view
|
||||
// uses runs for the live-fill connector + step duration estimates;
|
||||
// History views obviously need them too.
|
||||
useEffect(() => {
|
||||
if (!card) return;
|
||||
const needsRuns =
|
||||
card.view === 'saved' ||
|
||||
card.view === 'history' ||
|
||||
card.view === 'history_detail' ||
|
||||
card.view === 'running' ||
|
||||
card.view === 'completed' ||
|
||||
card.view === 'failed';
|
||||
if (needsRuns && workflow && !runs) {
|
||||
dispatch(fetchRuns(workflow.id));
|
||||
}
|
||||
}, [card?.view, workflow?.id, runs, dispatch]);
|
||||
|
||||
// Layout state (workflowCards in dashboardLayoutSlice) persists across
|
||||
// app restarts; workflows.openCards in workflowsSlice does NOT — it's a
|
||||
// transient view-state cache. On relaunch the user sees the workflow
|
||||
// card position restored AND the source-chat tether redrawn, but the
|
||||
// card body itself doesn't render because openCards is empty. Auto-
|
||||
// create a Saved-view openCard once we know the workflow really exists
|
||||
// server-side. Without this, the user sees only the orange tether arrow
|
||||
// pointing at nothing.
|
||||
useEffect(() => {
|
||||
if (!workflow || card) return;
|
||||
dispatch(openWorkflowCardAction({
|
||||
workflowId: workflow.id,
|
||||
sourceSessionId: workflow.source_session_id || null,
|
||||
view: 'saved',
|
||||
draft: null,
|
||||
}));
|
||||
}, [workflow?.id, card, dispatch]);
|
||||
|
||||
// Keep wheel-scroll inside the card body instead of letting it bubble
|
||||
// up to the dashboard pan/zoom listener. Without this, scrolling the
|
||||
// schedule/history list shifts the canvas underneath the card. Mirrors
|
||||
// the chat-panel wheel guard in AgentChat.tsx. Ctrl/meta + wheel is
|
||||
// intentionally allowed through so canvas zoom still works when the
|
||||
// cursor is over a workflow card.
|
||||
const bodyScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const el = bodyScrollRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) return;
|
||||
const atTop = el.scrollTop <= 0;
|
||||
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
|
||||
const scrollingDown = e.deltaY > 0;
|
||||
const scrollingUp = e.deltaY < 0;
|
||||
if ((scrollingUp && atTop) || (scrollingDown && atBottom)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
e.stopPropagation();
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
// X just hides the card. Schedule keeps firing in the background; the
|
||||
// user can re-open from the Workflows hub. A confirm dialog here was
|
||||
// more friction than value (users clicked through it without reading).
|
||||
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;
|
||||
// Chat views embed a full AgentChat that needs a fixed scroll viewport;
|
||||
// every other view should size to its content so nothing is cut off and
|
||||
// there's no dead space below short content. While the user is actively
|
||||
// resizing, honor the dragged height.
|
||||
// Chat views host a composer + conversation, so they keep a bounded height
|
||||
// (composer docked at the bottom, content scrolls) like a normal chat card.
|
||||
// Everything else fits to its content. Scheduling is a chat too (you talk to
|
||||
// it in natural language), so it belongs here, not in the fit-to-content set.
|
||||
const isChatView = card?.view === 'edit_agent' || card?.view === 'fix_agent' || card?.view === 'scheduling';
|
||||
const autoHeight = !isChatView && !localResize && !isResizing;
|
||||
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
|
||||
|
||||
if (!card) return null;
|
||||
|
||||
const isRunning = (runs || []).some((r) => r.status === 'running') || workflow?.last_run_status === 'running';
|
||||
|
||||
// Hairline border for the default idle state (item #19 in target #54
|
||||
// diff). Keeps the card feeling like a soft surface, not a fenced
|
||||
// box. Highlighted / selected / running still bump up so feedback
|
||||
// is unambiguous.
|
||||
const border = isHighlighted
|
||||
? `2px solid ${c.accent.primary}`
|
||||
: isSelected
|
||||
? '2px solid #3b82f6'
|
||||
: isRunning
|
||||
? `1px solid ${c.accent.primary}80`
|
||||
: `1px solid ${c.border.subtle}`;
|
||||
|
||||
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
|
||||
component={motion.div}
|
||||
// Mount animation: a soft pop-in matching AgentCard's spawn so the
|
||||
// Convert-to-workflow swap doesn't feel instant. Springy scale +
|
||||
// opacity over ~220ms; no position morph because the card already
|
||||
// lands at the source chat's exact coords (handled in AgentCard's
|
||||
// convert handler).
|
||||
initial={{ opacity: 0, scale: 0.92 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ scale: { type: 'spring', stiffness: 320, damping: 26, mass: 0.6 }, opacity: { duration: 0.18 } }}
|
||||
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: autoHeight ? 'auto' : displayH,
|
||||
maxHeight: autoHeight ? 'min(82vh, 760px)' : undefined,
|
||||
borderRadius: '14px',
|
||||
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 =====
|
||||
Matches target image #54 spec: drag-grip on the far left, then a
|
||||
single bold title (no pill prefix), then a quiet close X. The
|
||||
run-status indicator moved to the inline "Scheduled:" prose
|
||||
below so the title row stays calm. Padding bumped from 1.1 to
|
||||
1.4 vertical so the title has air around it. */}
|
||||
<Box
|
||||
onPointerDown={handleDragPointerDown}
|
||||
onPointerMove={handleDragPointerMove}
|
||||
onPointerUp={handleDragPointerUp}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
px: 2, py: 1.4,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
touchAction: 'none', userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
zIndex: 16,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<DragIndicatorIcon sx={{ fontSize: 18, color: c.text.muted }} />
|
||||
{isDraft ? (
|
||||
// Draft state: title is inline-editable. Patches the openCard's
|
||||
// draft.title so PreviewView picks it up on Save. Saved cards
|
||||
// keep the read-only Typography below.
|
||||
<InputBase
|
||||
data-no-drag
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
value={(card?.draft?.title as string) || ''}
|
||||
placeholder="New workflow"
|
||||
onChange={(e) => dispatch(updateWorkflowCard({ workflowId, patch: { draft: { ...(card?.draft || {}), title: e.target.value } } }))}
|
||||
sx={{
|
||||
flex: 1, fontWeight: 600, fontSize: '0.95rem', color: c.text.primary,
|
||||
letterSpacing: '-0.005em',
|
||||
'& input::placeholder': { color: c.text.muted, opacity: 1 },
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '0.95rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', letterSpacing: '-0.005em' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<StatusPill view={card.view} workflow={workflow} runs={runs} />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
</>
|
||||
)}
|
||||
{runs && runs.length > 0 && <RunSparkline runs={runs} />}
|
||||
<IconButton
|
||||
size="small"
|
||||
data-no-drag
|
||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ p: 0.5, color: c.text.secondary, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 17 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Action bar matches new design: History + Run flush-right (Edit moved to footer).
|
||||
The flex spacer is the empty left side; History is a quiet text link, Run is the
|
||||
accent pill. */}
|
||||
{isDraft && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 2, pb: 1.25, pt: 0, flexShrink: 0 }}>
|
||||
<SubtitleRow
|
||||
workflow={null}
|
||||
runs={null}
|
||||
fallbackModel={card?.draft?.model}
|
||||
fallbackMode={card?.draft?.mode}
|
||||
fallbackSourceSessionId={card?.draft?.source_session_id}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<TabBtn label="History" icon={<HistoryIcon sx={{ fontSize: 16 }} />} active={false} onClick={() => {}} />
|
||||
<TabBtn label="Run" icon={<PlayArrowIcon sx={{ fontSize: 16 }} />} active={false} accent onClick={() => {}} />
|
||||
</Box>
|
||||
)}
|
||||
{!isDraft && workflow && !isHeaderlessView(card.view) && card.view !== 'running' && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 2, pb: 1.25, pt: 0, flexShrink: 0 }}>
|
||||
<SubtitleRow workflow={workflow} runs={runs} />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<TabBtn
|
||||
label="History"
|
||||
icon={<HistoryIcon sx={{ fontSize: 16 }} />}
|
||||
active={card.view === 'history' || card.view === 'history_detail'}
|
||||
onClick={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: (card.view === 'history' || card.view === 'history_detail') ? 'saved' : 'history' } }))}
|
||||
/>
|
||||
<TabBtn
|
||||
label={runStarting ? 'Starting…' : 'Run'}
|
||||
icon={<PlayArrowIcon sx={{ fontSize: 16 }} />}
|
||||
active={false}
|
||||
accent
|
||||
breathe={!runStarting && isStaleSinceLastRun(workflow)}
|
||||
breatheTooltip="Haven't run this in a few days. Click to run it now."
|
||||
onClick={async () => {
|
||||
if (runStarting) return;
|
||||
setRunStarting(true);
|
||||
try {
|
||||
const result = await dispatch(runWorkflowNow(workflow.id));
|
||||
await dispatch(fetchRuns(workflow.id));
|
||||
if (runWorkflowNow.fulfilled.match(result)) {
|
||||
const payload = result.payload;
|
||||
if (payload.status === 'skipped' && payload.error) {
|
||||
setRunToast(`Run skipped: ${payload.error}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setTimeout(() => setRunStarting(false), 600);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{!isDraft && workflow && card.view === 'running' && (
|
||||
<RunningHeader workflowId={workflowId} />
|
||||
)}
|
||||
|
||||
{/* ===== 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 ref={bodyScrollRef} data-no-drag sx={{ flex: 1, p: 2, overflowY: 'auto', minHeight: 0, position: 'relative', overscrollBehavior: 'contain', display: 'flex', flexDirection: 'column', borderTop: `1px solid ${c.border.subtle}` }}>
|
||||
{/* No AnimatePresence wrapper here on purpose: framer-motion's
|
||||
crossfade was racing user-input events and stealing focus
|
||||
from the title/description/step InputBases on every parent
|
||||
re-render (Redux dispatches from selection/zOrder/etc.). The
|
||||
tab body just swaps directly; the user doesn't notice the
|
||||
missing crossfade. */}
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
{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}
|
||||
runs={runs}
|
||||
activeRunId={(runs || []).find((r) => r.status === 'running')?.id || null}
|
||||
/>
|
||||
)}
|
||||
{card.view === 'edit' && workflow && (
|
||||
<WorkflowEditViews
|
||||
workflow={workflow}
|
||||
facet={card.editFacet || 'General'}
|
||||
onChangeFacet={(f) => dispatch(updateWorkflowCard({ workflowId, patch: { editFacet: f } }))}
|
||||
onDirtyChange={setEditDirty}
|
||||
/>
|
||||
)}
|
||||
{card.view === 'history' && workflow && (
|
||||
<HistoryList
|
||||
runs={runs || []}
|
||||
onOpen={async (run) => {
|
||||
if (!run.session_id) {
|
||||
dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history_detail', historyRunId: run.id } }));
|
||||
return;
|
||||
}
|
||||
const sid = run.session_id;
|
||||
if (!store.getState().agents.sessions[sid]) {
|
||||
try { await dispatch(fetchSession(sid)).unwrap(); } catch { /* fall back to detail */ }
|
||||
}
|
||||
if (!store.getState().agents.sessions[sid]) {
|
||||
dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history_detail', historyRunId: run.id } }));
|
||||
return;
|
||||
}
|
||||
if (!store.getState().dashboardLayout.cards[sid]) {
|
||||
dispatch(placeCard({
|
||||
sessionId: sid,
|
||||
x: cardX + cardWidth + 60,
|
||||
y: cardY,
|
||||
width: DEFAULT_CARD_W,
|
||||
height: DEFAULT_CARD_H,
|
||||
expandedSessionIds,
|
||||
}));
|
||||
}
|
||||
dispatch(setPendingFocusAgentId(sid));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{card.view === 'history_detail' && workflow && (
|
||||
<HistoryDetail
|
||||
run={(runs || []).find((r) => r.id === card.historyRunId) || null}
|
||||
onBack={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }))}
|
||||
/>
|
||||
)}
|
||||
{card.view === 'running' && workflow && (
|
||||
<RunningView workflow={workflow} steps={steps} runs={runs} mode={card.sidecarKind === 'watching' ? 'sidecar-linked' : 'card'} />
|
||||
)}
|
||||
{card.view === 'completed' && workflow && (
|
||||
<CompletedView workflow={workflow} steps={steps} runs={runs} mode={card.sidecarKind === 'viewing-completed' ? 'sidecar-linked' : 'card'} />
|
||||
)}
|
||||
{card.view === 'failed' && workflow && (
|
||||
<FailedView workflow={workflow} steps={steps} runs={runs} mode={card.sidecarKind === 'viewing-error' ? 'sidecar-linked' : 'card'} />
|
||||
)}
|
||||
{card.view === 'scheduling' && workflow && (
|
||||
<SchedulingView workflow={workflow} steps={steps} />
|
||||
)}
|
||||
{(card.view === 'edit_agent' || card.view === 'fix_agent') && workflow && (
|
||||
<EditAgentView workflow={workflow} steps={steps} isFixMode={card.view === 'fix_agent'} />
|
||||
)}
|
||||
</Box>
|
||||
</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,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* First-success celebration. Tiny CSS-only sparkle so we don't
|
||||
pull in a confetti library. ~2s self-clears via the effect. */}
|
||||
{celebrate && (
|
||||
<Box sx={{ position: 'absolute', inset: 0, pointerEvents: 'none', overflow: 'hidden', zIndex: 30 }}>
|
||||
<Box sx={{
|
||||
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
|
||||
fontSize: '1.4rem', fontWeight: 700, color: c.accent.primary,
|
||||
bgcolor: c.bg.surface, px: 1.2, py: 0.5, borderRadius: 999,
|
||||
boxShadow: c.shadow.md,
|
||||
animation: 'first-success-pop 1.4s ease-out forwards',
|
||||
'@keyframes first-success-pop': {
|
||||
'0%': { opacity: 0, transform: 'translate(-50%,-50%) scale(0.6)' },
|
||||
'20%': { opacity: 1, transform: 'translate(-50%,-50%) scale(1.08)' },
|
||||
'60%': { opacity: 1, transform: 'translate(-50%,-50%) scale(1.0)' },
|
||||
'100%': { opacity: 0, transform: 'translate(-50%,-50%) scale(1.0)' },
|
||||
},
|
||||
}}>
|
||||
🎉 First success
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{/* Toast for run outcomes that need explaining beyond the History
|
||||
row (cost cap, "previous run still active," etc.). Auto-hides
|
||||
after 6s; user can click anywhere to dismiss. */}
|
||||
<Snackbar
|
||||
open={Boolean(runToast)}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setRunToast(null)}
|
||||
message={runToast || ''}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
function isHeaderlessView(view: string): boolean {
|
||||
// Edit-agent / fix-agent / scheduling render their own Discard/Save
|
||||
// (or Cancel) header inside the body so the parent skips the default
|
||||
// History/Run row to avoid two stacked toolbars.
|
||||
return view === 'edit_agent' || view === 'fix_agent' || view === 'scheduling';
|
||||
}
|
||||
|
||||
function StatusPill({ view, workflow, runs }: { view: string; workflow: Workflow | undefined; runs: import('@/shared/state/workflowsSlice').WorkflowRun[] | undefined }) {
|
||||
const c = useClaudeTokens();
|
||||
// Pills appear on the title row to mirror Image #34 (completed source
|
||||
// chat), #40 (running), #42 (completed workflow), #41 (running while
|
||||
// watching). Saved / Preview / Edit / Scheduling have no pill.
|
||||
let label = '';
|
||||
let color = c.text.muted;
|
||||
let bg = c.bg.elevated;
|
||||
if (view === 'running') {
|
||||
label = 'running';
|
||||
color = c.status.success;
|
||||
bg = c.status.successBg;
|
||||
} else if (view === 'completed') {
|
||||
label = 'completed';
|
||||
color = c.text.secondary;
|
||||
bg = c.bg.elevated;
|
||||
} else {
|
||||
// Image #46: failed view has NO pill in the title row; the red X
|
||||
// on the failed step row carries the signal. Preview/Saved/Edit/
|
||||
// Scheduling are likewise pill-less.
|
||||
void workflow; void runs;
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
fontSize: '0.74rem', fontWeight: 600,
|
||||
px: 0.8, py: 0.18, borderRadius: `${c.radius.md}px`,
|
||||
color, bgcolor: bg,
|
||||
ml: 0.25,
|
||||
}}>{label}</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SubtitleRow({ workflow, runs, fallbackModel, fallbackMode, fallbackSourceSessionId }: {
|
||||
workflow: Workflow | null;
|
||||
runs: import('@/shared/state/workflowsSlice').WorkflowRun[] | null;
|
||||
fallbackModel?: string;
|
||||
fallbackMode?: string;
|
||||
fallbackSourceSessionId?: string | null;
|
||||
}) {
|
||||
const c = useClaudeTokens();
|
||||
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
|
||||
// Draft preview has no workflow yet; fall back to the converting chat's
|
||||
// model/mode and use its work time for the "28s" so the subtitle reads the
|
||||
// same as the source chat card did.
|
||||
const sourceSession = useAppSelector((s) => fallbackSourceSessionId ? s.agents.sessions[fallbackSourceSessionId] : undefined);
|
||||
// Match Image #34/#35/#36/#38/#40: "Claude Opus 4.6 agent 28s".
|
||||
// Spaces between fields, all in muted text.
|
||||
const effModel = workflow?.model || fallbackModel || '';
|
||||
const modelLabel = React.useMemo(() => {
|
||||
if (!effModel) return '';
|
||||
for (const list of Object.values(modelsByProvider || {})) {
|
||||
for (const m of (list as any[]) || []) {
|
||||
if (m.value === effModel) return m.label || effModel;
|
||||
}
|
||||
}
|
||||
return effModel;
|
||||
}, [effModel, modelsByProvider]);
|
||||
const modeLabel = workflow?.mode || fallbackMode || '';
|
||||
const duration = React.useMemo(() => {
|
||||
const finished = (runs || []).find((r) => r.finished_at);
|
||||
if (finished && finished.finished_at) {
|
||||
const ms = new Date(finished.finished_at).getTime() - new Date(finished.started_at).getTime();
|
||||
if (ms > 0) {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
||||
return `${Math.floor(ms / 60_000)}m`;
|
||||
}
|
||||
}
|
||||
if (sourceSession) {
|
||||
const { total } = getAgentWorkTime(sourceSession.messages || [], sourceSession.status);
|
||||
if (total > 0) return total < 60 ? `${total}s` : `${Math.floor(total / 60)}m`;
|
||||
}
|
||||
return '';
|
||||
}, [runs, sourceSession]);
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1.25, fontSize: '0.82rem', color: c.text.muted, minWidth: 0, overflow: 'hidden' }}>
|
||||
{modelLabel && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{modelLabel}</Box>}
|
||||
{modeLabel && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{modeLabel}</Box>}
|
||||
{duration && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{duration}</Box>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function RunningHeader({ workflowId }: { workflowId: string }) {
|
||||
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]);
|
||||
const runId = card?.runId || null;
|
||||
const run = (runs || []).find((r) => r.id === runId);
|
||||
const onStop = React.useCallback(async () => {
|
||||
if (!run) return;
|
||||
try {
|
||||
const { API_BASE, getAuthToken } = await import('@/shared/config');
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
await fetch(`${API_BASE}/workflows/runs/${encodeURIComponent(run.id)}/stop`, {
|
||||
method: 'POST',
|
||||
headers: tok ? { Authorization: `Bearer ${tok}` } : {},
|
||||
});
|
||||
} catch { /* best-effort */ }
|
||||
dispatch(updateWorkflowCard({ workflowId, patch: { view: 'saved', runId: null } }));
|
||||
}, [dispatch, workflowId, run]);
|
||||
// Pause = "let this run finish, but stop firing future schedules."
|
||||
// Can't actually pause a streaming agent turn mid-call, so we flip
|
||||
// schedule.enabled so the scheduler stops queuing the next fire. The
|
||||
// button label flips to "Resume" while paused; user can re-enable
|
||||
// without leaving the running view.
|
||||
const isPaused = !!workflow && !workflow.schedule.enabled && workflow.schedule.runs_count > 0;
|
||||
const onPauseToggle = React.useCallback(async () => {
|
||||
if (!workflow) return;
|
||||
const next = { ...workflow.schedule, enabled: isPaused };
|
||||
await dispatch(updateWorkflow({
|
||||
id: workflow.id,
|
||||
patch: { schedule: next as Workflow['schedule'] },
|
||||
ifMatch: workflow.updated_at || null,
|
||||
}));
|
||||
}, [dispatch, workflow, isPaused]);
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 2, pb: 1.25, pt: 0, flexShrink: 0 }}>
|
||||
<SubtitleRow workflow={workflow || null} runs={runs || null} />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box
|
||||
onClick={onStop}
|
||||
role="button"
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35, fontSize: '0.82rem', fontWeight: 600, px: 1, py: 0.4, color: c.text.secondary, cursor: 'pointer', borderRadius: 999, '&:hover': { color: c.text.primary, bgcolor: c.bg.elevated } }}>
|
||||
<StopRounded sx={{ fontSize: 15 }} />
|
||||
Stop
|
||||
</Box>
|
||||
<Tooltip title={isPaused ? 'Schedule is paused. Click to resume future fires.' : 'Pause future scheduled fires. This run finishes normally.'}>
|
||||
<Box
|
||||
onClick={onPauseToggle}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.35,
|
||||
fontSize: '0.82rem', fontWeight: 700,
|
||||
px: 1.1, py: 0.4, borderRadius: 999,
|
||||
bgcolor: c.accent.primary, color: '#fff', cursor: 'pointer',
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
<PauseRounded sx={{ fontSize: 15 }} />
|
||||
{isPaused ? 'Resume' : 'Pause'}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TabBtn({ label, icon, active, accent, breathe, breatheTooltip, dot, dotTooltip, onClick }: { label: string; icon: React.ReactNode; active: boolean; accent?: boolean; breathe?: boolean; breatheTooltip?: string; dot?: boolean; dotTooltip?: string; onClick: () => void }) {
|
||||
const c = useClaudeTokens();
|
||||
const btn = (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
role="button"
|
||||
data-no-drag
|
||||
sx={{
|
||||
// Consistent visual weight across Run/Edit/History per target
|
||||
// #54: identical padding + border thickness, matched 32px row
|
||||
// height. `accent` (Run only) gets the colored text + tinted bg
|
||||
// so it reads as the primary verb without screaming "selected".
|
||||
// Tabs no longer flip the bg on `active`; the body view itself
|
||||
// tells the user where they are.
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
px: 1.25, py: 0.5,
|
||||
minHeight: 32,
|
||||
fontSize: '0.82rem', fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
color: accent ? c.accent.primary : c.text.secondary,
|
||||
bgcolor: accent ? c.accent.primary + '14' : 'transparent',
|
||||
// Only the Run (accent) tab carries a border; Edit/History sit as
|
||||
// quiet text-with-icon affordances so the primary verb stands out.
|
||||
border: accent ? `1px solid ${c.accent.primary}50` : '1px solid transparent',
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
cursor: 'pointer', userSelect: 'none',
|
||||
'&:hover': { bgcolor: accent ? c.accent.primary + '22' : c.bg.elevated, borderColor: accent ? c.accent.primary : 'transparent' },
|
||||
// Active state: nudge bg only when this is a non-accent tab so the
|
||||
// user can still see "you're on this view". Run's accent styling
|
||||
// already does that job; piling a darker bg on top reads as
|
||||
// disabled.
|
||||
...(active && !accent && {
|
||||
color: c.text.primary,
|
||||
bgcolor: c.bg.elevated,
|
||||
}),
|
||||
// Subtle "ready" breath when a stale workflow's Run button hasn't
|
||||
// been touched in over 24h. ~3% scale + glow swell, slow enough
|
||||
// to read as ambient rather than urgent. Tooltip is on so users
|
||||
// don't think the button is malfunctioning.
|
||||
...(breathe && {
|
||||
animation: 'workflow-run-breath 3.2s ease-in-out infinite',
|
||||
'@keyframes workflow-run-breath': {
|
||||
'0%, 100%': { boxShadow: `0 0 0 ${c.accent.primary}00`, transform: 'scale(1)' },
|
||||
'50%': { boxShadow: `0 0 14px ${c.accent.primary}55`, transform: 'scale(1.03)' },
|
||||
},
|
||||
}),
|
||||
}}>
|
||||
{icon}
|
||||
{label}
|
||||
{dot && (
|
||||
<Box sx={{
|
||||
width: 7, height: 7, borderRadius: '50%',
|
||||
bgcolor: c.accent.primary,
|
||||
ml: 0.25,
|
||||
}} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
if (dot && dotTooltip) {
|
||||
return <Tooltip title={dotTooltip}>{btn}</Tooltip>;
|
||||
}
|
||||
if (breathe && breatheTooltip) {
|
||||
return <Tooltip title={breatheTooltip}>{btn}</Tooltip>;
|
||||
}
|
||||
return btn;
|
||||
}
|
||||
|
||||
export default React.memo(WorkflowCard);
|
||||
@@ -0,0 +1,554 @@
|
||||
// Run-state views for the workflow card. The card's `view` field flips to
|
||||
// 'running' / 'completed' / 'failed' off of the workflow:run ws stream
|
||||
// (see upsertRun reducer). Each view here renders the same step list
|
||||
// with a different status overlay + a different footer.
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import HistoryIcon from '@mui/icons-material/HistoryRounded';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrowRounded';
|
||||
import StopRounded from '@mui/icons-material/StopRounded';
|
||||
import PauseRounded from '@mui/icons-material/PauseRounded';
|
||||
import RocketLaunchRounded from '@mui/icons-material/RocketLaunchRounded';
|
||||
import BuildRounded from '@mui/icons-material/BuildRounded';
|
||||
import EditOutlined from '@mui/icons-material/EditOutlined';
|
||||
import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined';
|
||||
import VisibilityOffOutlined from '@mui/icons-material/VisibilityOffOutlined';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import {
|
||||
setCardSidecar,
|
||||
toggleExpandedStep,
|
||||
updateWorkflowCard,
|
||||
type Workflow,
|
||||
type WorkflowRun,
|
||||
} from '@/shared/state/workflowsSlice';
|
||||
import { DEFAULT_CARD_W, DEFAULT_CARD_H, placeCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
|
||||
import { fetchSession } from '@/shared/state/agentsSlice';
|
||||
import StepList, { type StepStatus } from './StepList';
|
||||
|
||||
// Helper: open a session next to the workflow card AND mark the card as
|
||||
// sidecar-linked so the footer flips to Stop Watching/Viewing and the
|
||||
// dashboard draws an arrow chip between the two cards.
|
||||
function useOpenSidecar(workflowId: string) {
|
||||
const dispatch = useAppDispatch();
|
||||
const wfCardPos = useAppSelector((s) => s.dashboardLayout.workflowCards[workflowId]);
|
||||
const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds);
|
||||
return React.useCallback(async (sessionId: string, kind: 'watching' | 'viewing-completed' | 'viewing-error' | 'testing') => {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const { store } = await import('@/shared/state/store');
|
||||
if (!store.getState().agents.sessions[sessionId]) {
|
||||
try { await dispatch(fetchSession(sessionId)).unwrap(); } catch { /* not fatal */ }
|
||||
}
|
||||
if (!store.getState().dashboardLayout.cards[sessionId] && wfCardPos) {
|
||||
dispatch(placeCard({
|
||||
sessionId,
|
||||
x: wfCardPos.x + wfCardPos.width + 60,
|
||||
y: wfCardPos.y,
|
||||
width: DEFAULT_CARD_W,
|
||||
height: DEFAULT_CARD_H,
|
||||
expandedSessionIds,
|
||||
}));
|
||||
}
|
||||
dispatch(setPendingFocusAgentId(sessionId));
|
||||
} catch { /* best-effort */ }
|
||||
dispatch(setCardSidecar({ workflowId, sessionId, kind }));
|
||||
}, [dispatch, workflowId, wfCardPos, expandedSessionIds]);
|
||||
}
|
||||
|
||||
type ViewMode = 'card' | 'sidecar-linked';
|
||||
|
||||
// ---------- Shared bits ----------
|
||||
|
||||
function ProgressBar({ value, color }: { value: number; color: string }) {
|
||||
const c = useClaudeTokens();
|
||||
const pct = Math.max(0, Math.min(1, value));
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: 4, borderRadius: 999, bgcolor: c.bg.elevated, overflow: 'hidden' }}>
|
||||
<Box sx={{
|
||||
width: `${pct * 100}%`, height: '100%', bgcolor: color,
|
||||
transition: 'width 0.4s ease',
|
||||
boxShadow: `0 0 6px ${color}66`,
|
||||
}} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function PillButton({ label, onClick, icon, tone, filled, disabled }: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon?: React.ReactNode;
|
||||
tone: 'accent' | 'success' | 'danger' | 'muted';
|
||||
filled?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const c = useClaudeTokens();
|
||||
const colorFor = (t: typeof tone) =>
|
||||
t === 'success' ? c.status.success : t === 'danger' ? c.status.error : t === 'accent' ? c.accent.primary : c.text.secondary;
|
||||
const color = colorFor(tone);
|
||||
const bg = filled ? color : 'transparent';
|
||||
const fg = filled ? '#fff' : color;
|
||||
return (
|
||||
<Box
|
||||
onClick={disabled ? undefined : onClick}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
fontSize: '0.86rem', fontWeight: 700,
|
||||
px: 1.4, py: 0.55, borderRadius: 999,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
color: fg, bgcolor: bg,
|
||||
border: filled ? `1px solid ${color}` : `1px solid ${color}55`,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
'&:hover': { filter: 'brightness(1.05)', bgcolor: filled ? color : color + '14' },
|
||||
}}>
|
||||
{icon}
|
||||
{label}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function GhostTextBtn({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
const c = useClaudeTokens();
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
sx={{
|
||||
fontSize: '0.86rem', fontWeight: 500, color: c.text.secondary,
|
||||
cursor: 'pointer', px: 0.75, py: 0.5,
|
||||
'&:hover': { color: c.text.primary },
|
||||
}}>
|
||||
{label}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- RunningView (Image #40) ----------
|
||||
|
||||
export function RunningView({ workflow, steps, runs, mode = 'card' }: {
|
||||
workflow: Workflow;
|
||||
steps: Workflow['steps'];
|
||||
runs?: WorkflowRun[];
|
||||
mode?: ViewMode;
|
||||
}) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const card = useAppSelector((s) => s.workflows.openCards[workflow.id]);
|
||||
const runId = card?.runId || null;
|
||||
const run = useMemo(() => (runs || []).find((r) => r.id === runId) || null, [runs, runId]);
|
||||
|
||||
// Prefer the backend's real active_step_idx (broadcast on each step
|
||||
// bump in executor.execute). Fall back to elapsed/expected heuristic
|
||||
// when the field is missing (older runs or first-frame race).
|
||||
const heuristicIdx = useActiveStepIdx(steps.length, runs, runId);
|
||||
const activeIdx = typeof run?.active_step_idx === 'number' ? run.active_step_idx : heuristicIdx;
|
||||
const statuses: StepStatus[] = steps.map((_, i) =>
|
||||
i < activeIdx ? 'done' : i === activeIdx ? 'active' : 'pending',
|
||||
);
|
||||
const completeCount = statuses.filter((s) => s === 'done').length;
|
||||
const total = steps.length;
|
||||
|
||||
// Tool-call subtitle for the active step. Backend polls the session's
|
||||
// messages at 1.5s cadence and broadcasts on workflow:run as the agent
|
||||
// makes new tool calls. See executor.py _watch_tool_calls.
|
||||
const activeSubtitle = run?.last_tool_label || null;
|
||||
const activeDuration = formatLiveDuration(run);
|
||||
|
||||
const isLinked = mode === 'sidecar-linked' && card?.sidecarKind === 'watching';
|
||||
|
||||
const onStop = useCallback(async () => {
|
||||
if (!runId) return;
|
||||
try {
|
||||
const { API_BASE, getAuthToken } = await import('@/shared/config');
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
await fetch(`${API_BASE}/workflows/runs/${encodeURIComponent(runId)}/stop`, {
|
||||
method: 'POST',
|
||||
headers: tok ? { Authorization: `Bearer ${tok}` } : {},
|
||||
});
|
||||
} catch { /* best-effort */ }
|
||||
}, [runId]);
|
||||
const onPause = useCallback(() => {
|
||||
// Pause flips the global paused state; the in-flight run continues but
|
||||
// future fires queue up behind it. Maps to the existing /pause-all path.
|
||||
void undefined;
|
||||
}, []);
|
||||
const openSidecar = useOpenSidecar(workflow.id);
|
||||
const onWatchLive = useCallback(() => {
|
||||
if (run?.session_id) void openSidecar(run.session_id, 'watching');
|
||||
}, [openSidecar, run?.session_id]);
|
||||
const onStopWatching = useCallback(() => {
|
||||
dispatch(setCardSidecar({ workflowId: workflow.id, sessionId: null, kind: null }));
|
||||
}, [dispatch, workflow.id]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.92rem', fontWeight: 700, color: c.status.success }}>
|
||||
{completeCount} of {total} complete
|
||||
</Typography>
|
||||
</Box>
|
||||
<ProgressBar value={total > 0 ? completeCount / total : 0} color={c.status.success} />
|
||||
<StepList
|
||||
workflow={workflow}
|
||||
steps={steps}
|
||||
stepStatuses={statuses}
|
||||
activeStepSubtitle={activeSubtitle}
|
||||
activeStepDuration={activeDuration}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
|
||||
{isLinked ? (
|
||||
<PillButton
|
||||
label="Stop Watching"
|
||||
tone="danger"
|
||||
filled={false}
|
||||
icon={<VisibilityOffOutlined sx={{ fontSize: 16 }} />}
|
||||
onClick={onStopWatching}
|
||||
/>
|
||||
) : (
|
||||
<PillButton
|
||||
label="Watch Live"
|
||||
tone="muted"
|
||||
filled={false}
|
||||
icon={<VisibilityOutlined sx={{ fontSize: 16 }} />}
|
||||
onClick={onWatchLive}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{/* Stop / Pause live in the header row, rendered by WorkflowCard.
|
||||
See header-button overrides in WorkflowCard.tsx for the
|
||||
per-view replacement of History/Run. */}
|
||||
<Box sx={{ display: 'none' }} aria-hidden onClick={onStop} />
|
||||
<Box sx={{ display: 'none' }} aria-hidden onClick={onPause} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function useActiveStepIdx(stepCount: number, runs: WorkflowRun[] | undefined, activeRunId: string | null | undefined): number {
|
||||
const [, setTick] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
if (!activeRunId) return;
|
||||
const id = window.setInterval(() => setTick((t) => (t + 1) % 1000000), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [activeRunId]);
|
||||
if (!activeRunId || !runs) return 0;
|
||||
const active = runs.find((r) => r.id === activeRunId && r.status === 'running');
|
||||
if (!active) return 0;
|
||||
const elapsed = Date.now() - new Date(active.started_at).getTime();
|
||||
const completed = runs.filter((r) => (r.status === 'success' || r.status === 'ran_late') && r.finished_at);
|
||||
if (completed.length === 0) {
|
||||
return Math.min(stepCount - 1, Math.max(0, Math.floor(stepCount / 2)));
|
||||
}
|
||||
const durations = completed.slice(0, 10).map((r) => new Date(r.finished_at!).getTime() - new Date(r.started_at).getTime());
|
||||
const avg = durations.reduce((a, b) => a + b, 0) / durations.length || 1;
|
||||
const ratio = Math.min(0.99, Math.max(0, elapsed / avg));
|
||||
return Math.min(stepCount - 1, Math.floor(ratio * stepCount));
|
||||
}
|
||||
|
||||
function formatLiveDuration(run: WorkflowRun | null): string | null {
|
||||
if (!run || run.status !== 'running') return null;
|
||||
try {
|
||||
const ms = Date.now() - new Date(run.started_at).getTime();
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
return `${m}m`;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ---------- CompletedView (Image #42) ----------
|
||||
|
||||
export function CompletedView({ workflow, steps, runs, mode = 'card' }: {
|
||||
workflow: Workflow;
|
||||
steps: Workflow['steps'];
|
||||
runs?: WorkflowRun[];
|
||||
mode?: ViewMode;
|
||||
}) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const card = useAppSelector((s) => s.workflows.openCards[workflow.id]);
|
||||
const runId = card?.runId || null;
|
||||
const run = useMemo(() => (runs || []).find((r) => r.id === runId) || null, [runs, runId]);
|
||||
const statuses: StepStatus[] = steps.map(() => 'done');
|
||||
const isLinked = mode === 'sidecar-linked' && card?.sidecarKind === 'viewing-completed';
|
||||
|
||||
const onDone = useCallback(() => {
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved', runId: null, sidecarSessionId: null, sidecarKind: null } }));
|
||||
}, [dispatch, workflow.id]);
|
||||
const onEdit = useCallback(() => {
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'edit_agent' } }));
|
||||
}, [dispatch, workflow.id]);
|
||||
const openSidecar = useOpenSidecar(workflow.id);
|
||||
const onViewAgent = useCallback(() => {
|
||||
if (run?.session_id) void openSidecar(run.session_id, 'viewing-completed');
|
||||
}, [openSidecar, run?.session_id]);
|
||||
const onStopViewing = useCallback(() => {
|
||||
dispatch(setCardSidecar({ workflowId: workflow.id, sessionId: null, kind: null }));
|
||||
}, [dispatch, workflow.id]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.6 }}>
|
||||
<Box component="span" sx={{ color: c.status.success, fontSize: 18, lineHeight: 1, mr: 0.25 }}>✓</Box>
|
||||
<Typography sx={{ fontSize: '0.92rem', fontWeight: 700, color: c.status.success }}>
|
||||
{steps.length} of {steps.length} complete
|
||||
</Typography>
|
||||
</Box>
|
||||
<ProgressBar value={1} color={c.status.success} />
|
||||
<StepList
|
||||
workflow={workflow}
|
||||
steps={steps}
|
||||
stepStatuses={statuses}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{/* Success card. Hidden in sidecar-linked mode (Image #43) so the
|
||||
compacted card stays tight. Soft green tint + rocket icon. */}
|
||||
{!isLinked && (
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 1.25,
|
||||
p: 1.5, borderRadius: `${c.radius.lg}px`,
|
||||
bgcolor: c.status.successBg,
|
||||
border: `1px solid ${c.status.success}30`,
|
||||
}}>
|
||||
<Box sx={{
|
||||
width: 32, height: 32, borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: c.status.success + '22', color: c.status.success,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<RocketLaunchRounded sx={{ fontSize: 16 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.95rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.3 }}>
|
||||
Workflow Success!
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, mt: 0.25, lineHeight: 1.45 }}>
|
||||
If you're curious, you can click the green button below to see exactly what the agent did.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
|
||||
<PillButton
|
||||
label="Edit"
|
||||
tone="muted"
|
||||
filled={false}
|
||||
icon={<EditOutlined sx={{ fontSize: 15 }} />}
|
||||
onClick={onEdit}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{/* Image #43: Done is hidden in sidecar mode; Stop Viewing alone
|
||||
fills the right slot. Default mode keeps Done + View Agent. */}
|
||||
{!isLinked && <GhostTextBtn label="Done" onClick={onDone} />}
|
||||
{isLinked ? (
|
||||
<PillButton
|
||||
label="Stop Viewing"
|
||||
tone="success"
|
||||
filled={false}
|
||||
icon={<VisibilityOffOutlined sx={{ fontSize: 16 }} />}
|
||||
onClick={onStopViewing}
|
||||
/>
|
||||
) : (
|
||||
<PillButton
|
||||
label="View Agent"
|
||||
tone="success"
|
||||
filled
|
||||
onClick={onViewAgent}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- FailedView (Image #46) ----------
|
||||
|
||||
export function FailedView({ workflow, steps, runs, mode = 'card' }: {
|
||||
workflow: Workflow;
|
||||
steps: Workflow['steps'];
|
||||
runs?: WorkflowRun[];
|
||||
mode?: ViewMode;
|
||||
}) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const card = useAppSelector((s) => s.workflows.openCards[workflow.id]);
|
||||
const runId = card?.runId || null;
|
||||
const run = useMemo(() => (runs || []).find((r) => r.id === runId) || null, [runs, runId]);
|
||||
const failedIdx = guessFailedIdx(run, steps.length);
|
||||
const statuses: StepStatus[] = steps.map((_, i) =>
|
||||
i < failedIdx ? 'done' : i === failedIdx ? 'failed' : 'pending',
|
||||
);
|
||||
const isLinked = mode === 'sidecar-linked' && card?.sidecarKind === 'viewing-error';
|
||||
|
||||
const onIgnore = useCallback(() => {
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved', runId: null, sidecarSessionId: null, sidecarKind: null } }));
|
||||
}, [dispatch, workflow.id]);
|
||||
const openSidecar = useOpenSidecar(workflow.id);
|
||||
const onViewError = useCallback(() => {
|
||||
if (run?.session_id) void openSidecar(run.session_id, 'viewing-error');
|
||||
}, [openSidecar, run?.session_id]);
|
||||
const onStopViewing = useCallback(() => {
|
||||
dispatch(setCardSidecar({ workflowId: workflow.id, sessionId: null, kind: null }));
|
||||
}, [dispatch, workflow.id]);
|
||||
const onFixWithAgent = useCallback(() => {
|
||||
if (!run) return;
|
||||
const stepLabel = steps[failedIdx]?.label || steps[failedIdx]?.text?.slice(0, 60) || `Step ${failedIdx + 1}`;
|
||||
dispatch(updateWorkflowCard({
|
||||
workflowId: workflow.id,
|
||||
patch: {
|
||||
view: 'fix_agent',
|
||||
sidecarSessionId: null,
|
||||
sidecarKind: null,
|
||||
fixSeed: { runId: run.id, stepIdx: failedIdx, stepLabel, error: run.error || 'Step failed.' },
|
||||
},
|
||||
}));
|
||||
}, [dispatch, workflow.id, run, steps, failedIdx]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
<StepList
|
||||
workflow={workflow}
|
||||
steps={steps}
|
||||
stepStatuses={statuses}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 1.25,
|
||||
p: 1.5, borderRadius: `${c.radius.lg}px`,
|
||||
bgcolor: c.status.errorBg,
|
||||
border: `1px solid ${c.status.error}30`,
|
||||
}}>
|
||||
<Box sx={{
|
||||
width: 32, height: 32, borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: c.status.error + '22', color: c.status.error,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<BuildRounded sx={{ fontSize: 16 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.95rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.3 }}>
|
||||
Fix with an Agent
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, mt: 0.25, lineHeight: 1.45 }}>
|
||||
Have an agent modify, test, and iterate on the workflow until it works as expected.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
|
||||
{isLinked ? (
|
||||
<PillButton
|
||||
label="Stop Viewing"
|
||||
tone="danger"
|
||||
filled={false}
|
||||
icon={<VisibilityOffOutlined sx={{ fontSize: 16 }} />}
|
||||
onClick={onStopViewing}
|
||||
/>
|
||||
) : (
|
||||
<PillButton
|
||||
label="View Error"
|
||||
tone="muted"
|
||||
filled={false}
|
||||
icon={<VisibilityOutlined sx={{ fontSize: 16 }} />}
|
||||
onClick={onViewError}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<GhostTextBtn label="Ignore" onClick={onIgnore} />
|
||||
<PillButton
|
||||
label="Fix with Agent"
|
||||
tone="danger"
|
||||
filled
|
||||
onClick={onFixWithAgent}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function guessFailedIdx(run: WorkflowRun | null, total: number): number {
|
||||
if (!run) return Math.max(0, total - 1);
|
||||
// Backend pins active_step_idx at the failed step before flipping
|
||||
// status to 'failure'. Prefer that; fall back to parsing "Step N"
|
||||
// out of the error string for legacy runs.
|
||||
if (typeof run.active_step_idx === 'number') {
|
||||
return Math.max(0, Math.min(total - 1, run.active_step_idx));
|
||||
}
|
||||
if (run.error) {
|
||||
const m = /step\s+(\d+)/i.exec(run.error);
|
||||
if (m) {
|
||||
const n = parseInt(m[1], 10);
|
||||
if (!Number.isNaN(n) && n >= 1 && n <= total) return n - 1;
|
||||
}
|
||||
}
|
||||
return Math.max(0, Math.min(total - 1, 1));
|
||||
}
|
||||
|
||||
// ---------- Header overrides ----------
|
||||
// The card header normally renders {History | Run}. Running shows
|
||||
// {Stop | Pause}, Completed/Failed keep {History | Run}, Edit/Fix shows
|
||||
// {Discard | Save}, Scheduling shows {Cancel task scheduling}. The
|
||||
// WorkflowCard hands off via this helper so each view can declare its
|
||||
// own header without the parent fanning out a switch.
|
||||
|
||||
export interface HeaderActions {
|
||||
left?: React.ReactNode;
|
||||
right: React.ReactNode;
|
||||
}
|
||||
|
||||
export function useHeaderActions(workflow: Workflow | null, view: string): HeaderActions {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
return useMemo<HeaderActions>(() => {
|
||||
if (!workflow) return { right: null };
|
||||
const HistoryRun = (
|
||||
<>
|
||||
<Box
|
||||
onClick={() => dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'history' } }))}
|
||||
role="button"
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: '0.82rem', fontWeight: 600, px: 1, py: 0.4, color: c.text.secondary, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>
|
||||
<HistoryIcon sx={{ fontSize: 15 }} />
|
||||
History
|
||||
</Box>
|
||||
<Box
|
||||
onClick={() => dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } }))}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.35,
|
||||
fontSize: '0.82rem', fontWeight: 700,
|
||||
px: 1.1, py: 0.4, borderRadius: 999,
|
||||
bgcolor: c.accent.primary, color: '#fff', cursor: 'pointer',
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
<PlayArrowIcon sx={{ fontSize: 15 }} />
|
||||
Run
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
if (view === 'running') {
|
||||
return {
|
||||
right: (
|
||||
<>
|
||||
<Box role="button" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35, fontSize: '0.82rem', fontWeight: 600, px: 1, py: 0.4, color: c.text.secondary, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>
|
||||
<StopRounded sx={{ fontSize: 15 }} />
|
||||
Stop
|
||||
</Box>
|
||||
<Box role="button" sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.35,
|
||||
fontSize: '0.82rem', fontWeight: 700,
|
||||
px: 1.1, py: 0.4, borderRadius: 999,
|
||||
bgcolor: c.accent.primary, color: '#fff', cursor: 'pointer',
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
<PauseRounded sx={{ fontSize: 15 }} />
|
||||
Pause
|
||||
</Box>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
return { right: HistoryRun };
|
||||
}, [workflow, view, dispatch, c]);
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
import HistoryIcon from '@mui/icons-material/HistoryToggleOffRounded';
|
||||
import CalendarMonthRounded from '@mui/icons-material/CalendarMonthRounded';
|
||||
import EditOutlined from '@mui/icons-material/EditOutlined';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import {
|
||||
createWorkflow,
|
||||
toggleExpandedStep,
|
||||
updateWorkflow,
|
||||
updateWorkflowCard,
|
||||
type Workflow,
|
||||
type WorkflowRun,
|
||||
} from '@/shared/state/workflowsSlice';
|
||||
import { CostChip, humanDuration, routingFor, StreakBadge } from './workflowVisuals';
|
||||
import StepList from './StepList';
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
type ActionBtnTone = 'muted' | 'success' | 'danger';
|
||||
|
||||
export function ActionBtn({ label, tone, disabled, onClick, icon }: { label: string; tone: ActionBtnTone; disabled?: boolean; onClick: () => void; icon?: 'trash' | 'check' }) {
|
||||
const c = useClaudeTokens();
|
||||
// Tone -> color triple. Matches target #58/#63 styling:
|
||||
// success = green pill (Save)
|
||||
// danger = red/pink pill (Discard)
|
||||
// muted = neutral pill (Undo)
|
||||
const palette = tone === 'success'
|
||||
? { color: c.status.success, bg: c.status.successBg, border: c.status.success + '60', hover: c.status.success + '30' }
|
||||
: tone === 'danger'
|
||||
? { color: c.status.error, bg: c.status.errorBg, border: c.status.error + '60', hover: c.status.error + '30' }
|
||||
: { color: c.text.secondary, bg: c.bg.secondary, border: c.border.subtle, hover: c.bg.elevated };
|
||||
return (
|
||||
<Box
|
||||
onClick={disabled ? undefined : onClick}
|
||||
role="button"
|
||||
sx={{
|
||||
// Compact pill matching target #58/#63. Smaller padding + smaller
|
||||
// glyphs so the buttons stop overshadowing the step body.
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
fontSize: '0.78rem', fontWeight: 600,
|
||||
px: 1, py: 0.35,
|
||||
borderRadius: 999,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
color: palette.color,
|
||||
bgcolor: palette.bg,
|
||||
border: `1px solid ${palette.border}`,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
'&:hover': { bgcolor: palette.hover },
|
||||
}}>
|
||||
{icon === 'trash' && (
|
||||
<Box component="span" sx={{ display: 'inline-flex', fontSize: 12, lineHeight: 1 }}>{'\u{1F5D1}'}</Box>
|
||||
)}
|
||||
{icon === 'check' && (
|
||||
<Box component="span" sx={{ display: 'inline-flex', fontSize: 12, lineHeight: 1 }}>{'✓'}</Box>
|
||||
)}
|
||||
{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);
|
||||
// Title + description live in the openCard draft so the parent header
|
||||
// (which renders the inline-editable title) and PreviewView body (which
|
||||
// renders the inline-editable description + steps) stay in sync. On
|
||||
// Save we pull whatever's currently in the draft, falling back to the
|
||||
// initialDraft passed at mount time.
|
||||
const card = useAppSelector((s) => s.workflows.openCards[workflowId]);
|
||||
const liveDraft = (card?.draft ?? initialDraft ?? {}) as Partial<Workflow>;
|
||||
const title = (liveDraft.title as string) || 'New workflow';
|
||||
const description = (liveDraft.description as string) || '';
|
||||
// The new workflow runs with the user's configured default model/mode (their
|
||||
// subscription, etc.), falling back to whatever the source chat used. Without
|
||||
// this the backend picks its own default, which surprised users who'd set a
|
||||
// subscription default but saw the workflow created on an API-key model.
|
||||
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
|
||||
const defaultMode = useAppSelector((s) => s.settings.data.default_mode);
|
||||
// Steps render compact (label + chevron, capped + "... N more"), same as
|
||||
// the saved card. The raw prompt drills down on click. Keeping them short
|
||||
// is what leaves room for the schedule prompt + buttons to stay on-card.
|
||||
const expandedIds = card?.expandedStepIds || [];
|
||||
const onToggleStep = useCallback((stepId: string) => {
|
||||
dispatch(toggleExpandedStep({ workflowId, stepId }));
|
||||
}, [dispatch, workflowId]);
|
||||
|
||||
const onChangeDescription = useCallback((value: string) => {
|
||||
dispatch(updateWorkflowCard({ workflowId, patch: { draft: { ...liveDraft, description: value } } }));
|
||||
}, [dispatch, workflowId, liveDraft]);
|
||||
|
||||
// Both buttons persist the workflow; the only difference is where they land.
|
||||
// Ignore = save and show the saved card. Schedule = save then open the
|
||||
// natural-language scheduling composer. (Ignore used to delete the card,
|
||||
// which surprised people; the schedule prompt is optional, the workflow isn't.)
|
||||
const saveWorkflow = useCallback(async (): Promise<Workflow | null> => {
|
||||
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,
|
||||
// The user's configured default wins over whatever model the source chat
|
||||
// happened to run on, so a converted workflow behaves like a fresh chat.
|
||||
model: defaultModel || (liveDraft.model as string),
|
||||
mode: defaultMode || (liveDraft.mode as string),
|
||||
} as Partial<Workflow>));
|
||||
const wf = (result as unknown as { payload: Workflow }).payload;
|
||||
if (wf?.id) { onSaved(wf); return wf; }
|
||||
return null;
|
||||
}, [dispatch, title, description, steps, sourceSessionId, onSaved, liveDraft, defaultModel, defaultMode]);
|
||||
|
||||
const onIgnore = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try { await saveWorkflow(); } finally { setBusy(false); }
|
||||
}, [busy, saveWorkflow]);
|
||||
|
||||
const onSaveThenSchedule = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const wf = await saveWorkflow();
|
||||
if (wf?.id) dispatch(updateWorkflowCard({ workflowId: wf.id, patch: { view: 'scheduling' } }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, saveWorkflow, dispatch]);
|
||||
|
||||
void onChangeDescription;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
<StepList steps={steps} expandable expandedIds={expandedIds} onToggleExpand={onToggleStep} />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{/* Schedule prompt card. Soft accent tint + calendar icon. Accent is the
|
||||
same color the human-intervention (AskUserQuestion) popup uses. */}
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 1.25,
|
||||
p: 1.5, borderRadius: `${c.radius.lg}px`,
|
||||
bgcolor: c.accent.primary + '10',
|
||||
border: `1px solid ${c.accent.primary}30`,
|
||||
}}>
|
||||
<Box sx={{
|
||||
width: 32, height: 32, borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: c.accent.primary + '22', color: c.accent.primary,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<CalendarMonthRounded sx={{ fontSize: 18 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.95rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.3 }}>
|
||||
Schedule this workflow?
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, mt: 0.25, lineHeight: 1.45 }}>
|
||||
You can have workflows run on a recurring basis, automatically.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 1.5 }}>
|
||||
<Box
|
||||
onClick={onIgnore}
|
||||
role="button"
|
||||
sx={{
|
||||
fontSize: '0.86rem', fontWeight: 500, color: c.text.secondary,
|
||||
cursor: busy ? 'wait' : 'pointer', px: 0.75, py: 0.5,
|
||||
opacity: busy ? 0.6 : 1,
|
||||
'&:hover': { color: c.text.primary },
|
||||
}}>
|
||||
Ignore
|
||||
</Box>
|
||||
<Box
|
||||
onClick={onSaveThenSchedule}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
fontSize: '0.88rem', fontWeight: 700,
|
||||
px: 1.75, py: 0.6, borderRadius: 999,
|
||||
color: '#fff', bgcolor: c.accent.primary,
|
||||
cursor: busy ? 'wait' : 'pointer',
|
||||
opacity: busy ? 0.6 : 1,
|
||||
'&:hover': { bgcolor: c.accent.primary, filter: 'brightness(1.06)' },
|
||||
}}>
|
||||
Schedule Workflow
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Render the workflow's permission tiers as a flat prose line so the
|
||||
// SavedView reads like a sentence, not a chip salad. Mirrors target #54.
|
||||
function describePermissions(workflow: Workflow): string {
|
||||
const tiers = workflow.permissions || [];
|
||||
if (tiers.length === 0) return 'Notify me in Open Swarm';
|
||||
const parts: string[] = [];
|
||||
for (const t of tiers) {
|
||||
if (t.kind === 'notify') parts.push('notify in app');
|
||||
else if (t.kind === 'text') parts.push('text');
|
||||
else if (t.kind === 'call') parts.push('call');
|
||||
}
|
||||
return `First ${parts.join(', then ')}`;
|
||||
}
|
||||
|
||||
function describeSchedule(workflow: Workflow): string {
|
||||
const s = workflow.schedule;
|
||||
if (!s.enabled) return 'Not scheduled';
|
||||
const h12 = ((s.hour + 11) % 12) + 1;
|
||||
const ampm = s.hour < 12 ? 'am' : 'pm';
|
||||
const time = s.minute === 0 ? `${h12}${ampm}` : `${h12}:${String(s.minute).padStart(2, '0')}${ampm}`;
|
||||
if (s.repeat_unit === 'day') return s.repeat_every === 1 ? `Daily at ${time}` : `Every ${s.repeat_every} days at ${time}`;
|
||||
if (s.repeat_unit === 'month') return s.repeat_every === 1 ? `Monthly at ${time}` : `Every ${s.repeat_every} months at ${time}`;
|
||||
if (s.on_days.length === 5 && [1,2,3,4,5].every((d) => s.on_days.includes(d))) return `Weekdays at ${time}`;
|
||||
if (s.on_days.length === 2 && [0,6].every((d) => s.on_days.includes(d))) return `Weekends at ${time}`;
|
||||
if (s.on_days.length === 1) {
|
||||
// Image #50: "Mondays at 3pm" (plural day, no "Every" prefix). Reads
|
||||
// more naturally than "Every Mon at 3pm".
|
||||
const plurals = ['Sundays', 'Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays'];
|
||||
return `${plurals[s.on_days[0]]} at ${time}`;
|
||||
}
|
||||
return `Weekly at ${time}`;
|
||||
}
|
||||
|
||||
export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Workflow; steps: Workflow['steps']; runs?: WorkflowRun[]; activeRunId?: string | null }) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
void runs; void activeRunId;
|
||||
const card = useAppSelector((s) => s.workflows.openCards[workflow.id]);
|
||||
const expandedIds = card?.expandedStepIds || [];
|
||||
const openEditAgent = useCallback(() => {
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'edit_agent' } }));
|
||||
}, [dispatch, workflow.id]);
|
||||
const openScheduling = useCallback(() => {
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'scheduling' } }));
|
||||
}, [dispatch, workflow.id]);
|
||||
const onToggleStep = useCallback((stepId: string) => {
|
||||
dispatch(toggleExpandedStep({ workflowId: workflow.id, stepId }));
|
||||
}, [dispatch, workflow.id]);
|
||||
|
||||
const scheduleLine = workflow.schedule.enabled ? describeSchedule(workflow) : 'Schedule this workflow';
|
||||
const scheduleClickable = !workflow.schedule.enabled;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
<StepList
|
||||
workflow={workflow}
|
||||
steps={steps}
|
||||
expandable
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={onToggleStep}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Box
|
||||
onClick={scheduleClickable ? openScheduling : undefined}
|
||||
role={scheduleClickable ? 'button' : undefined}
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.6,
|
||||
color: c.text.secondary, fontSize: '0.86rem', minWidth: 0,
|
||||
cursor: scheduleClickable ? 'pointer' : 'default',
|
||||
'&:hover': scheduleClickable ? { color: c.text.primary } : {},
|
||||
}}>
|
||||
<CalendarMonthRounded sx={{ fontSize: 16, color: c.text.muted, flexShrink: 0 }} />
|
||||
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{scheduleLine}</Box>
|
||||
</Box>
|
||||
<Box
|
||||
onClick={openEditAgent}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.45,
|
||||
fontSize: '0.82rem', fontWeight: 600,
|
||||
px: 1.25, py: 0.5,
|
||||
borderRadius: 999,
|
||||
cursor: 'pointer',
|
||||
color: c.text.secondary,
|
||||
bgcolor: 'transparent',
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
'&:hover': { bgcolor: c.bg.elevated, borderColor: c.border.strong || c.border.medium, color: c.text.primary },
|
||||
}}>
|
||||
<EditOutlined sx={{ fontSize: 15 }} />
|
||||
Edit
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// kept on file for legacy uses; once the audit popover migrates, this and
|
||||
// the StreakBadge / habit-suggestion blocks above can be deleted entirely.
|
||||
void StreakBadgeRow;
|
||||
|
||||
// Splits StreakBadge out so the SavedView body doesn't have to ferry
|
||||
// the runs array through both the chip row (gone) and the step list.
|
||||
function StreakBadgeRow({ runs }: { runs?: WorkflowRun[] }) {
|
||||
if (!runs || runs.length === 0) return null;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<StreakBadge runs={runs} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Audit-trace popover. Lazy-fetches the last N edits from /workflows/{id}/audit
|
||||
// on open, renders a compact list. The trigger sits inline with the chip
|
||||
// row so power users can spot it without cluttering the title.
|
||||
function AuditTraceLink({ workflowId }: { workflowId: string }) {
|
||||
const c = useClaudeTokens();
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
const [entries, setEntries] = useState<Array<{ ts: string; who: string; diff: Record<string, { before: unknown; after: unknown }> }> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Probe the audit log once on mount so we can hide the trigger entirely
|
||||
// when there are no edits (item #21 in target #54 diff). Fire-and-forget;
|
||||
// a failure leaves entries=null which renders nothing.
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const { API_BASE, getAuthToken } = await import('@/shared/config');
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflowId)}/audit?limit=5`, {
|
||||
headers: tok ? { Authorization: `Bearer ${tok}` } : {},
|
||||
});
|
||||
const data = await res.json();
|
||||
if (alive) setEntries(Array.isArray(data?.entries) ? data.entries : []);
|
||||
} catch {
|
||||
if (alive) setEntries([]);
|
||||
}
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [workflowId]);
|
||||
// The popover open handler must be declared BEFORE the conditional
|
||||
// return below; otherwise React sees a different hook-count between
|
||||
// the "loading" render (returns early) and the "loaded with entries"
|
||||
// render (calls useCallback), which triggers the "Rendered more hooks
|
||||
// than during the previous render" crash.
|
||||
const open = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setAnchor(e.currentTarget);
|
||||
if (entries !== null) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { API_BASE, getAuthToken } = await import('@/shared/config');
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflowId)}/audit?limit=5`, {
|
||||
headers: tok ? { Authorization: `Bearer ${tok}` } : {},
|
||||
});
|
||||
const data = await res.json();
|
||||
setEntries(Array.isArray(data?.entries) ? data.entries : []);
|
||||
} catch {
|
||||
setEntries([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [entries, workflowId]);
|
||||
// Hide entirely until we know whether there are edits to surface.
|
||||
if (entries === null || entries.length === 0) return null;
|
||||
const close = () => setAnchor(null);
|
||||
const count = entries?.length ?? 0;
|
||||
return (
|
||||
<>
|
||||
<Tooltip title="Recent edits to this workflow">
|
||||
<Box onClick={open} role="button" sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.3,
|
||||
fontSize: '0.7rem', color: c.text.muted, cursor: 'pointer',
|
||||
px: 0.5, py: 0.25, borderRadius: 0.75,
|
||||
'&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated },
|
||||
}}>
|
||||
<HistoryIcon sx={{ fontSize: 12 }} />
|
||||
{entries === null ? 'edits' : `${count} edit${count === 1 ? '' : 's'}`}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
open={Boolean(anchor)}
|
||||
anchorEl={anchor}
|
||||
onClose={close}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}>
|
||||
<Box sx={{ minWidth: 280, maxWidth: 360, p: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.5 }}>
|
||||
RECENT EDITS
|
||||
</Typography>
|
||||
{loading && <Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>Loading…</Typography>}
|
||||
{!loading && (entries === null || entries.length === 0) && (
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>No edits yet.</Typography>
|
||||
)}
|
||||
{!loading && entries && entries.map((e, idx) => {
|
||||
const fields = Object.keys(e.diff || {}).filter((k) => k !== 'updated_at');
|
||||
const summary = fields.length === 0 ? 'no field changes' : fields.slice(0, 3).join(', ') + (fields.length > 3 ? `, +${fields.length - 3} more` : '');
|
||||
return (
|
||||
<Box key={idx} sx={{ display: 'flex', flexDirection: 'column', py: 0.5, borderTop: idx === 0 ? 'none' : `1px solid ${c.border.subtle}` }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary, fontWeight: 600 }}>{e.who || 'user'}</Typography>
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost }}>{relTimeShort(e.ts)}</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.74rem', color: c.text.secondary }}>{summary}</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function relTimeShort(iso: string): string {
|
||||
try {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (ms < 60000) return 'just now';
|
||||
const m = Math.floor(ms / 60000);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d ago`;
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
function runDuration(r: WorkflowRun): string | null {
|
||||
if (!r.finished_at) return null;
|
||||
try {
|
||||
const ms = new Date(r.finished_at).getTime() - new Date(r.started_at).getTime();
|
||||
if (ms <= 0) return null;
|
||||
return humanDuration(ms);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Groups runs into "This week / Last week / Month YYYY" buckets so a
|
||||
// long history list reads as eras rather than 50 same-looking dates.
|
||||
function groupKey(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const day = 24 * 3600 * 1000;
|
||||
const startOfWeek = (x: Date) => { const y = new Date(x); y.setHours(0, 0, 0, 0); y.setDate(y.getDate() - y.getDay()); return y; };
|
||||
const thisWeekStart = startOfWeek(now).getTime();
|
||||
const lastWeekStart = thisWeekStart - 7 * day;
|
||||
if (d.getTime() >= thisWeekStart) return 'This week';
|
||||
if (d.getTime() >= lastWeekStart) return 'Last week';
|
||||
return d.toLocaleString('en', { month: 'long', year: 'numeric' });
|
||||
} catch { return 'Earlier'; }
|
||||
}
|
||||
|
||||
export function HistoryList({ runs, onOpen }: { runs: WorkflowRun[]; onOpen: (r: WorkflowRun) => void }) {
|
||||
const c = useClaudeTokens();
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
// Filter chips: all / failures / late. Power-users debugging a flaky
|
||||
// workflow shouldn't have to scroll past successes.
|
||||
const [filter, setFilter] = useState<'all' | 'failure' | 'ran_late'>('all');
|
||||
const filtered = useMemo(() => {
|
||||
if (filter === 'all') return runs;
|
||||
return (runs || []).filter((r) => r.status === filter);
|
||||
}, [runs, filter]);
|
||||
const groups = useMemo(() => {
|
||||
const out: Array<{ key: string; runs: WorkflowRun[] }> = [];
|
||||
for (const r of filtered || []) {
|
||||
const k = groupKey(r.started_at);
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.key === k) last.runs.push(r);
|
||||
else out.push({ key: k, runs: [r] });
|
||||
}
|
||||
return out;
|
||||
}, [filtered]);
|
||||
// Header sparkline summarising recent successes/failures so users can
|
||||
// see "lately broken" before scrolling.
|
||||
const recent = (runs || []).slice(0, 30);
|
||||
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' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.75 }}>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25 }}>
|
||||
{recent.map((r) => (
|
||||
<Box key={r.id} sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: statusColor(r.status, c) }} />
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{(['all', 'failure', 'ran_late'] as const).map((k) => (
|
||||
<Box key={k} onClick={() => setFilter(k)} role="button" sx={{
|
||||
fontSize: '0.72rem', fontWeight: 600,
|
||||
color: filter === k ? c.accent.primary : c.text.muted,
|
||||
bgcolor: filter === k ? c.accent.primary + '14' : 'transparent',
|
||||
border: `1px solid ${filter === k ? c.accent.primary + '40' : c.border.subtle}`,
|
||||
px: 0.7, py: 0.2, borderRadius: 999, cursor: 'pointer',
|
||||
'&:hover': { color: c.accent.primary },
|
||||
}}>
|
||||
{k === 'all' ? 'All' : k === 'failure' ? 'Failures only' : 'Ran late only'}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{groups.map(({ key, runs: gRuns }) => (
|
||||
<Box key={key} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mt: 0.5, mb: 0.25 }}>
|
||||
{key.toUpperCase()}
|
||||
</Typography>
|
||||
{gRuns.map((r) => {
|
||||
const expanded = expandedId === r.id;
|
||||
const dur = runDuration(r);
|
||||
return (
|
||||
<Box key={r.id}>
|
||||
<Box
|
||||
onClick={() => setExpandedId(expanded ? null : r.id)}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1.25, py: 0.6, 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, flex: 1 }}>{formatRunDate(r.started_at)}</Typography>
|
||||
{dur && <Typography sx={{ fontSize: '0.74rem', color: c.text.ghost }}>{dur}</Typography>}
|
||||
{r.cost_usd > 0 && <Typography sx={{ fontSize: '0.74rem', color: c.text.ghost }}>${r.cost_usd.toFixed(4)}</Typography>}
|
||||
{/* Chevron makes the row read as expandable instead of
|
||||
static text. Rotates 180° while open so the affordance
|
||||
stays visible after click. */}
|
||||
<Box sx={{ fontSize: '0.7rem', color: c.text.ghost, transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s ease' }}>▾</Box>
|
||||
</Box>
|
||||
{expanded && (
|
||||
<Box sx={{ ml: 8, mt: 0.25, mb: 0.75, p: 1, bgcolor: c.bg.elevated, borderRadius: 0.75, border: `1px solid ${c.border.subtle}` }}>
|
||||
{r.error ? (
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.status.error, lineHeight: 1.4 }}>{r.error}</Typography>
|
||||
) : (
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, lineHeight: 1.4 }}>
|
||||
{r.session_id ? `Saved as session ${r.session_id.slice(0, 8)}.` : 'No session was recorded for this run.'} Click below to see the full conversation.
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ mt: 0.5, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Box onClick={(e) => { e.stopPropagation(); onOpen(r); }} role="button" sx={{ fontSize: '0.74rem', fontWeight: 600, color: c.accent.primary, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}>
|
||||
See full conversation →
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</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,162 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Select from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import ArrowBackRounded from '@mui/icons-material/ArrowBackRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { updateWorkflow, updateWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { validateDraft } from './permissionsUtils';
|
||||
import { ActionBtn, HINT_FS, LABEL_FS } from './workflowEditCommon';
|
||||
import GeneralFacet from './GeneralFacet';
|
||||
import ActionsFacet from './ActionsFacet';
|
||||
import ScheduleFacet from './ScheduleFacet';
|
||||
|
||||
interface Props {
|
||||
workflow: Workflow;
|
||||
facet: 'General' | 'Actions' | 'Schedule';
|
||||
onChangeFacet: (facet: 'General' | 'Actions' | 'Schedule') => void;
|
||||
// Lifted dirty state so the parent card can decorate the Edit tab with
|
||||
// an unsaved-changes dot. Optional; older callers don't need to wire it.
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
export default function WorkflowEditViews({ workflow, facet, onChangeFacet, onDirtyChange }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const [draft, setDraft] = useState<Workflow>(workflow);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [savedFlash, setSavedFlash] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(workflow), [draft, workflow]);
|
||||
|
||||
// Push the dirty flag up so the parent card can decorate the Edit tab.
|
||||
useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]);
|
||||
// Clear the parent's flag on unmount so a closed editor doesn't leave
|
||||
// a stale "you have unsaved changes" dot on the tab.
|
||||
useEffect(() => () => { onDirtyChange?.(false); }, [onDirtyChange]);
|
||||
|
||||
// Save is explicit only. The previous auto-save raced the Save button:
|
||||
// the user toggled a field, autosave fired 800ms later, dirty went
|
||||
// false, and a manual Save click became a no-op.
|
||||
const onSave = useCallback(async () => {
|
||||
if (busy || !dirty) return;
|
||||
const reason = validateDraft(draft);
|
||||
if (reason) {
|
||||
setSaveError(reason);
|
||||
return;
|
||||
}
|
||||
setSaveError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
// If-Match: pass the workflow's current updated_at so the backend
|
||||
// can reject a stale write. Without this, two open windows or a
|
||||
// mid-edit background fire silently clobber each other.
|
||||
const result = await dispatch(updateWorkflow({
|
||||
id: workflow.id,
|
||||
patch: draft,
|
||||
ifMatch: workflow.updated_at || null,
|
||||
}));
|
||||
if (updateWorkflow.fulfilled.match(result)) {
|
||||
// Rebase the draft on the server's echoed copy. Without this,
|
||||
// `dirty` would stay true after Save (because updated_at differs)
|
||||
// and the user would see a phantom "unsaved" state.
|
||||
const saved = result.payload as Workflow;
|
||||
if (saved) setDraft(saved);
|
||||
setSavedFlash(true);
|
||||
setTimeout(() => setSavedFlash(false), 1400);
|
||||
} else if (result.payload?.kind === 'stale') {
|
||||
setSaveError('This workflow was changed in another window or by a recent run. Discard to reload the latest, then re-apply your edits.');
|
||||
} else {
|
||||
setSaveError(result.payload?.message || 'Save failed. Please try again.');
|
||||
}
|
||||
} catch (e) {
|
||||
setSaveError((e as Error)?.message || 'Save failed.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, dirty, dispatch, workflow.id, workflow.updated_at, draft]);
|
||||
|
||||
const onDiscard = useCallback(() => {
|
||||
setDraft(workflow);
|
||||
setSaveError(null);
|
||||
}, [workflow]);
|
||||
|
||||
// Right-edge save indicator. dirty + busy + savedFlash collapse to a
|
||||
// single state so the button doesn't flicker between "Save now" and
|
||||
// "Up to date" mid-keystroke. When idle and clean, show a quiet
|
||||
// check-mark "Saved" label that's identical to the post-flash state.
|
||||
const saveState: 'dirty' | 'busy' | 'saved' = busy ? 'busy' : dirty ? 'dirty' : 'saved';
|
||||
const _flash = savedFlash; void _flash;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
||||
{/* Top control row, target image #67:
|
||||
"Currently Editing [Select▾]" spacer [Discard] [Save]
|
||||
Discard + Save are the same pill-style buttons used at the
|
||||
bottom of SavedView; placing them here gives the user a single
|
||||
place to commit OR throw away whatever they just edited. */}
|
||||
{/* Match target image #111: left cluster (label + facet picker)
|
||||
flush-left, action pills flush-right, generous breathing room
|
||||
between. Gap inside each cluster stays tight so the two read
|
||||
as two distinct groups, not five evenly-spaced chips. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'nowrap', minWidth: 0, py: 0.5 }}>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
||||
<Box
|
||||
onClick={() => dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } }))}
|
||||
role="button"
|
||||
aria-label="Back"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 26, height: 26, borderRadius: 999, mr: 0.25,
|
||||
color: c.text.secondary, cursor: 'pointer',
|
||||
'&:hover': { color: c.text.primary, bgcolor: c.bg.elevated },
|
||||
}}>
|
||||
<ArrowBackRounded sx={{ fontSize: 17 }} />
|
||||
</Box>
|
||||
<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, minWidth: 110, '& .MuiSelect-select': { py: 0.4 } }}>
|
||||
<MenuItem value="General">General</MenuItem>
|
||||
<MenuItem value="Actions">Actions</MenuItem>
|
||||
<MenuItem value="Schedule">Schedule</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 24 }} />
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
||||
<ActionBtn
|
||||
label="Discard"
|
||||
tone="danger"
|
||||
icon="trash"
|
||||
disabled={!dirty || busy}
|
||||
onClick={onDiscard}
|
||||
/>
|
||||
<Box sx={{ display: 'inline-flex', minWidth: 80, justifyContent: 'center' }}>
|
||||
<ActionBtn
|
||||
label={busy ? 'Saving…' : 'Save'}
|
||||
tone="success"
|
||||
icon="check"
|
||||
disabled={!dirty || busy || saveState === 'saved'}
|
||||
onClick={onSave}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
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 CallSplitRoundedIcon from '@mui/icons-material/CallSplitRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import {
|
||||
addWorkflowCard,
|
||||
closeWorkflowsHub,
|
||||
setWorkflowsHubPosition,
|
||||
setWorkflowsHubSize,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { openWorkflowCard, fetchPausedState, setPausedAll, updateWorkflow, deleteWorkflow, runWorkflowNow } from '@/shared/state/workflowsSlice';
|
||||
import type { Workflow } from '@/shared/state/workflowsSlice';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { useEffect } from 'react';
|
||||
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';
|
||||
|
||||
// Small badge in the hub header that adds up successful scheduled runs
|
||||
// across all workflows and renders an approximate "time saved" figure.
|
||||
// Heuristic: 3 minutes saved per scheduled run that the user would have
|
||||
// otherwise done by hand. Not precise — meant as a quiet "you got back
|
||||
// X hours" affirmation, not an audit number.
|
||||
function TimeSavedBadge() {
|
||||
const c = useClaudeTokens();
|
||||
const runsByWorkflow = useAppSelector((s) => s.workflows.runs);
|
||||
const items = useAppSelector((s) => s.workflows.items);
|
||||
let count = 0;
|
||||
for (const arr of Object.values(runsByWorkflow)) {
|
||||
for (const r of arr) {
|
||||
if (r.triggered_by === 'schedule' && (r.status === 'success' || r.status === 'ran_late')) count += 1;
|
||||
}
|
||||
}
|
||||
// Fallback: if no runs are loaded yet (cards never opened), use
|
||||
// last_run_status as a coarse proxy so brand-new users don't see 0.
|
||||
if (count === 0) {
|
||||
for (const w of Object.values(items)) {
|
||||
if (w.last_run_status === 'success' || w.last_run_status === 'ran_late') count += 1;
|
||||
}
|
||||
}
|
||||
if (count === 0) return null;
|
||||
const totalMin = count * 3;
|
||||
const hours = totalMin / 60;
|
||||
// Show "X done · ~Y hrs" so the user gets both the run count and a
|
||||
// sense of time. Dot-separator reads quieter than the old green pill.
|
||||
const timeLabel = hours >= 1 ? `~${hours.toFixed(1)} hrs` : `~${totalMin} min`;
|
||||
return (
|
||||
<Tooltip title={`${count} workflow runs completed for you. Rough estimate of ~3 min saved per run vs. doing it by hand.`}>
|
||||
<Box sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
ml: 1, px: 0.85, py: 0.2,
|
||||
fontSize: '0.74rem', fontWeight: 600,
|
||||
color: c.text.secondary,
|
||||
bgcolor: 'transparent',
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: 999,
|
||||
}}>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 14, height: 14, borderRadius: '50%', bgcolor: (c.status.success || c.accent.primary) + '22', color: c.status.success || c.accent.primary, fontSize: 9, fontWeight: 800 }}>✓</Box>
|
||||
<span style={{ color: c.text.primary }}>{count}</span>
|
||||
<span style={{ color: c.text.muted }}>·</span>
|
||||
<span style={{ color: c.text.secondary }}>{timeLabel} back</span>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
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 paused = useAppSelector((s) => s.workflows.paused);
|
||||
|
||||
useEffect(() => { dispatch(fetchPausedState()); }, [dispatch]);
|
||||
|
||||
const togglePaused = useCallback(() => {
|
||||
dispatch(setPausedAll(!paused));
|
||||
}, [dispatch, paused]);
|
||||
|
||||
const [view, setView] = useState<CalendarView>('Week');
|
||||
const [viewOpen, setViewOpen] = useState(false);
|
||||
const [refDate, setRefDate] = useState(new Date());
|
||||
const [search, setSearch] = useState('');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
// Right-click on a sidebar row opens this menu pinned to the cursor.
|
||||
// Mirrors the calendar pill context menu so the two surfaces feel
|
||||
// consistent. closeMenu wipes both state + DOM-focus.
|
||||
const [sidebarCtxMenu, setSidebarCtxMenu] = useState<{ x: number; y: number; workflow: Workflow } | null>(null);
|
||||
const closeSidebarCtxMenu = useCallback(() => setSidebarCtxMenu(null), []);
|
||||
|
||||
// "Scheduled" = the workflow has a real cadence configured at any
|
||||
// point (even if currently paused via the checkbox). Filtering by
|
||||
// `enabled` would yank rows out from under the user the moment they
|
||||
// unticked the box, which feels wrong. on_days/hour/minute being set
|
||||
// is a good proxy for "user already configured this." Falls back to
|
||||
// enabled flag for legacy records.
|
||||
const scheduled = useMemo(() => Object.values(workflows).filter((w) => isSchedulable(w)), [workflows]);
|
||||
const unscheduled = useMemo(() => Object.values(workflows).filter((w) => !isSchedulable(w)), [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: 18, height: 18, color: c.accent.primary }}>
|
||||
{/* CallSplit natively forks upward; rotated 90deg the fork
|
||||
points right, matching the Workflows brand mark. */}
|
||||
<CallSplitRoundedIcon sx={{ fontSize: 16, transform: 'rotate(90deg)' }} />
|
||||
</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 }}>
|
||||
<Tooltip title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}>
|
||||
<IconButton size="small" data-no-drag onClick={() => setSidebarOpen((v) => !v)} sx={{ p: 0.5, color: sidebarOpen ? c.text.secondary : c.text.muted, '&:hover': { color: c.text.primary } }}>
|
||||
<MenuIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<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>
|
||||
<Tooltip title={paused ? 'Scheduled runs are paused. In-flight runs will finish; new fires are blocked until you resume.' : 'Stop all future scheduled runs without disabling them one-by-one. Any run already in flight will finish.'}>
|
||||
<Box
|
||||
onClick={togglePaused}
|
||||
role="button"
|
||||
data-no-drag
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4, ml: 0.5,
|
||||
fontSize: '0.8rem', fontWeight: 600,
|
||||
color: paused ? c.status.warning || c.accent.primary : c.text.secondary,
|
||||
bgcolor: paused ? (c.status.warningBg || c.bg.elevated) : 'transparent',
|
||||
border: `1px solid ${paused ? (c.status.warning || c.accent.primary) + '60' : c.border.subtle}`,
|
||||
px: 0.85, py: 0.3, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
|
||||
'&:hover': { color: c.text.primary, borderColor: c.border.medium },
|
||||
}}>
|
||||
<Switch size="small" checked={paused} sx={{ pointerEvents: 'none', mr: -0.5, ml: -0.5 }} />
|
||||
<span>{paused ? 'Paused' : 'Pause all'}</span>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<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>
|
||||
<TimeSavedBadge />
|
||||
</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 */}
|
||||
{sidebarOpen && (
|
||||
<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 onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} />
|
||||
<SidebarSection title="Un-scheduled workflows" items={unscheduled.filter((w) => match(w.title, search))} onPick={onSelectWorkflow} scheduled={false} onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} />
|
||||
</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>
|
||||
|
||||
{/* Right-click menu shared across all sidebar workflow rows */}
|
||||
<Menu
|
||||
open={Boolean(sidebarCtxMenu)}
|
||||
onClose={closeSidebarCtxMenu}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={sidebarCtxMenu ? { top: sidebarCtxMenu.y, left: sidebarCtxMenu.x } : undefined}>
|
||||
<MenuItem onClick={() => {
|
||||
if (!sidebarCtxMenu) return;
|
||||
dispatch(runWorkflowNow(sidebarCtxMenu.workflow.id));
|
||||
closeSidebarCtxMenu();
|
||||
}}>Run now</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
if (!sidebarCtxMenu) return;
|
||||
const wf = sidebarCtxMenu.workflow;
|
||||
dispatch(updateWorkflow({
|
||||
id: wf.id,
|
||||
patch: { schedule: { ...wf.schedule, enabled: !wf.schedule.enabled } as any },
|
||||
ifMatch: wf.updated_at || null,
|
||||
}));
|
||||
closeSidebarCtxMenu();
|
||||
}}>{sidebarCtxMenu?.workflow.schedule.enabled ? 'Pause schedule' : 'Resume schedule'}</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
if (!sidebarCtxMenu) return;
|
||||
dispatch(addWorkflowCard({ workflowId: sidebarCtxMenu.workflow.id }));
|
||||
dispatch(openWorkflowCard({ workflowId: sidebarCtxMenu.workflow.id, view: 'edit_agent' }));
|
||||
closeSidebarCtxMenu();
|
||||
}}>Edit…</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
if (!sidebarCtxMenu) return;
|
||||
const ok = window.confirm(`Delete "${sidebarCtxMenu.workflow.title}"? Scheduled runs will stop.`);
|
||||
if (ok) dispatch(deleteWorkflow(sidebarCtxMenu.workflow.id));
|
||||
closeSidebarCtxMenu();
|
||||
}}
|
||||
sx={{ color: c.status.error }}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* 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, onContext }: {
|
||||
title: string;
|
||||
items: Workflow[];
|
||||
onPick: (id: string) => void;
|
||||
scheduled: boolean;
|
||||
onContext: (workflow: Workflow, e: React.MouseEvent) => void;
|
||||
}) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const toggleEnabled = useCallback((wf: Workflow, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
dispatch(updateWorkflow({
|
||||
id: wf.id,
|
||||
patch: { schedule: { ...wf.schedule, enabled: !wf.schedule.enabled } as any },
|
||||
ifMatch: wf.updated_at || null,
|
||||
}));
|
||||
}, [dispatch]);
|
||||
|
||||
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)}
|
||||
onContextMenu={(e) => { e.preventDefault(); onContext(w, e); }}
|
||||
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 ? (
|
||||
<Tooltip title={w.schedule.enabled ? 'Pause this schedule' : 'Resume this schedule'}>
|
||||
<Box
|
||||
onClick={(e) => toggleEnabled(w, e)}
|
||||
sx={{
|
||||
width: 14, height: 14, borderRadius: '3px', flexShrink: 0,
|
||||
border: `1.5px solid ${w.schedule.enabled ? c.accent.primary : c.border.medium}`,
|
||||
bgcolor: w.schedule.enabled ? c.accent.primary : 'transparent',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', fontSize: 10, lineHeight: 1, fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { borderColor: c.accent.primary },
|
||||
}}>
|
||||
{w.schedule.enabled ? '✓' : ''}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<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', textDecoration: scheduled && !w.schedule.enabled ? 'line-through' : 'none', opacity: scheduled && !w.schedule.enabled ? 0.6 : 1 }}>{w.title}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function isSchedulable(w: Workflow): boolean {
|
||||
if (w.schedule.enabled) return true;
|
||||
// Heuristic: any prior config means the user already opened the
|
||||
// Schedule facet and committed something. Pure defaults stay in
|
||||
// "Un-scheduled" so brand-new workflows don't pollute the list.
|
||||
const s = w.schedule;
|
||||
return Boolean(s.on_days?.length || s.ends_at || s.max_runs || s.runs_count);
|
||||
}
|
||||
|
||||
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,31 @@
|
||||
import type { Workflow, PermissionTier } from '@/shared/state/workflowsSlice';
|
||||
|
||||
// Pre-save validation. Returns the first user-visible reason save should
|
||||
// be blocked, or null when the draft is good to ship. Phone numbers on
|
||||
// text/call tiers must be non-empty and at least 7 digits so the eventual
|
||||
// SMS/voice bridge has something usable to dial.
|
||||
export 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;
|
||||
}
|
||||
|
||||
// Walk the existing permissions list and produce the next tier in the
|
||||
// chain (notify -> text -> call). Returns null if we're already at call,
|
||||
// which the UI uses to hide the "+ add backup" affordance.
|
||||
export function nextTierAfter(tiers: PermissionTier[]): PermissionTier | null {
|
||||
const last = tiers.length ? tiers[tiers.length - 1].kind : 'notify';
|
||||
if (last === 'notify') return { kind: 'text', after_minutes: 5, phone: '' };
|
||||
if (last === 'text') return { kind: 'call', after_minutes: 60, phone: '' };
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Lightweight text-to-schedule detector. Runs on agent replies (and user
|
||||
// prompts) to surface a "Schedule this?" chip when the conversation has
|
||||
// time-shaped language. Cheap regex pass, no LLM call. Returns the best
|
||||
// matching preset or null. Conservative on purpose: a false positive
|
||||
// shows a quietly-dismissable chip; a false negative just means the user
|
||||
// uses the regular Schedule button.
|
||||
|
||||
import type { ScheduleConfig } from '@/shared/state/workflowsSlice';
|
||||
import { defaultSchedule } from './scheduleUtils';
|
||||
|
||||
export interface DetectedSchedule {
|
||||
schedule: ScheduleConfig;
|
||||
presetLabel: string;
|
||||
}
|
||||
|
||||
const HOUR_WORDS: Record<string, number> = {
|
||||
morning: 9, noon: 12, afternoon: 14, evening: 18, night: 21, midnight: 0,
|
||||
};
|
||||
|
||||
// Match "9am" / "9 a.m." / "10:30 PM" / "at 7am" — but ONLY when there's
|
||||
// either an explicit am/pm suffix or an "at " prefix. Plain digits with
|
||||
// no time context ("3 new messages", "May 16", "$50 offer") used to slip
|
||||
// through and we'd misread them as the schedule hour. Anchoring on
|
||||
// `(am|pm)` OR `at ` blocks that.
|
||||
const HOUR_RE = /\b(?:at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.|p\.m\.)?|(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.|p\.m\.))\b/i;
|
||||
|
||||
const DAY_RE = /\b(sun|mon|tue|wed|thu|fri|sat)(?:day)?s?\b/gi;
|
||||
const DAY_MAP: Record<string, number> = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
|
||||
|
||||
export function detectSchedule(text: string): DetectedSchedule | null {
|
||||
if (!text) return null;
|
||||
const t = text.toLowerCase();
|
||||
// Require either an explicit frequency keyword or a clear weekday +
|
||||
// time pattern. Avoids false positives on stray "tomorrow at 9."
|
||||
const isDaily = /\b(every ?day|each day|daily)\b/.test(t);
|
||||
const isWeekdays = /\b(weekdays?|each weekday|every weekday|mon(?:day)?\s*(?:to|-|through|–)\s*fri(?:day)?)\b/.test(t);
|
||||
const isWeekly = /\b(every week|weekly|each week|once a week)\b/.test(t);
|
||||
const isMonthly = /\b(every month|monthly|each month|once a month)\b/.test(t);
|
||||
const dayMatches = Array.from(t.matchAll(DAY_RE)).map((m) => DAY_MAP[m[1].toLowerCase().slice(0, 3)]);
|
||||
const hasExplicitDays = dayMatches.length > 0;
|
||||
if (!isDaily && !isWeekdays && !isWeekly && !isMonthly && !hasExplicitDays) return null;
|
||||
|
||||
// Extract hour:minute.
|
||||
let hour = 9;
|
||||
let minute = 0;
|
||||
let presetTimeWord: string | null = null;
|
||||
for (const word of Object.keys(HOUR_WORDS)) {
|
||||
if (t.includes(word)) { hour = HOUR_WORDS[word]; presetTimeWord = word; break; }
|
||||
}
|
||||
const hm = t.match(HOUR_RE);
|
||||
if (hm) {
|
||||
// The two branches of HOUR_RE give us hour/minute/ampm in either
|
||||
// capture group 1-3 (the "at H" branch) or 4-6 (the "Ham/pm" branch).
|
||||
const rawStr = hm[1] || hm[4];
|
||||
const minStr = hm[2] || hm[5];
|
||||
const ampm = (hm[3] || hm[6] || '').toLowerCase();
|
||||
const raw = rawStr ? parseInt(rawStr, 10) : NaN;
|
||||
const m = minStr ? parseInt(minStr, 10) : 0;
|
||||
let h = raw;
|
||||
if (ampm.startsWith('p') && h < 12) h += 12;
|
||||
if (ampm.startsWith('a') && h === 12) h = 0;
|
||||
if (Number.isFinite(h) && h >= 0 && h < 24) {
|
||||
if (!presetTimeWord) { hour = h; minute = m; }
|
||||
}
|
||||
}
|
||||
|
||||
const base = defaultSchedule();
|
||||
if (isMonthly) {
|
||||
return {
|
||||
schedule: { ...base, enabled: true, repeat_unit: 'month', repeat_every: 1, hour, minute },
|
||||
presetLabel: `Every month at ${formatHour(hour, minute)}`,
|
||||
};
|
||||
}
|
||||
if (isWeekdays) {
|
||||
return {
|
||||
schedule: { ...base, enabled: true, repeat_unit: 'week', repeat_every: 1, on_days: [1, 2, 3, 4, 5], hour, minute },
|
||||
presetLabel: `Weekdays at ${formatHour(hour, minute)}`,
|
||||
};
|
||||
}
|
||||
if (isDaily) {
|
||||
return {
|
||||
schedule: { ...base, enabled: true, repeat_unit: 'day', repeat_every: 1, hour, minute },
|
||||
presetLabel: `Every day at ${formatHour(hour, minute)}`,
|
||||
};
|
||||
}
|
||||
if (hasExplicitDays || isWeekly) {
|
||||
const days = Array.from(new Set(dayMatches.length ? dayMatches : [new Date().getDay()]));
|
||||
days.sort();
|
||||
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const label = days.length === 1 ? `Every ${dayNames[days[0]]} at ${formatHour(hour, minute)}` : `${days.map((d) => dayNames[d]).join('/')} at ${formatHour(hour, minute)}`;
|
||||
return {
|
||||
schedule: { ...base, enabled: true, repeat_unit: 'week', repeat_every: 1, on_days: days, hour, minute },
|
||||
presetLabel: label,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatHour(h: number, m: number): string {
|
||||
const suffix = h < 12 ? 'am' : 'pm';
|
||||
const h12 = ((h + 11) % 12) + 1;
|
||||
return m === 0 ? `${h12}${suffix}` : `${h12}:${String(m).padStart(2, '0')}${suffix}`;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
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 {
|
||||
// Pick the host's IANA tz so new schedules start with an explicit zone
|
||||
// instead of the legacy "local" sentinel. Backend storage still coerces
|
||||
// "local" if a record predates this default; new records skip that path.
|
||||
let tz = 'local';
|
||||
try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'local'; } catch { /* keep 'local' */ }
|
||||
return {
|
||||
enabled: false,
|
||||
repeat_every: 1,
|
||||
repeat_unit: 'week',
|
||||
on_days: [],
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
timezone: tz,
|
||||
on_missed: 'skip',
|
||||
ends_at: null,
|
||||
max_runs: null,
|
||||
runs_count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function lastDayOfMonth(year: number, monthZeroBased: number): number {
|
||||
// Date(year, month, 0) returns the last day of the previous month, so
|
||||
// passing month+1 gives the last day of `monthZeroBased`. Matches the
|
||||
// backend's calendar.monthrange behavior so the FE preview no longer
|
||||
// clamps to day 28 (the old shared bug between this and previewNextRun).
|
||||
return new Date(year, monthZeroBased + 1, 0).getDate();
|
||||
}
|
||||
|
||||
export function fireTimesWithin(workflow: Workflow, from: Date, to: Date, cap = 40): Date[] {
|
||||
const sched = workflow.schedule;
|
||||
if (!sched.enabled) return [];
|
||||
// Honor end conditions on the FE preview too, so the calendar doesn't
|
||||
// paint pills for fires the backend will refuse to run. ends_at is an
|
||||
// ISO string in workflow state; max_runs/runs_count are numbers.
|
||||
if (sched.ends_at) {
|
||||
const endsAt = new Date(sched.ends_at);
|
||||
if (!Number.isNaN(endsAt.getTime()) && endsAt.getTime() <= from.getTime()) return [];
|
||||
if (!Number.isNaN(endsAt.getTime()) && endsAt.getTime() < to.getTime()) to = endsAt;
|
||||
}
|
||||
// Don't paint fires for days that predate the workflow itself. A
|
||||
// workflow created this Wednesday shouldn't show pills on Sun/Mon/Tue
|
||||
// of the same week. created_at is an ISO string; only floor on success.
|
||||
if (workflow.created_at) {
|
||||
const createdAt = new Date(workflow.created_at);
|
||||
if (!Number.isNaN(createdAt.getTime()) && createdAt.getTime() > from.getTime()) {
|
||||
from = createdAt;
|
||||
}
|
||||
}
|
||||
if (sched.max_runs != null && sched.runs_count >= sched.max_runs) return [];
|
||||
const remainingRuns = sched.max_runs != null ? Math.max(0, sched.max_runs - sched.runs_count) : Infinity;
|
||||
const effectiveCap = Math.min(cap, remainingRuns);
|
||||
if (effectiveCap === 0) 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 < effectiveCap; 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') {
|
||||
const startDay = from.getDate();
|
||||
let year = from.getFullYear();
|
||||
let month = from.getMonth();
|
||||
let guard = 0;
|
||||
while (out.length < effectiveCap && guard < 60) {
|
||||
const day = Math.min(startDay, lastDayOfMonth(year, month));
|
||||
const d = new Date(year, month, day, sched.hour, sched.minute, 0, 0);
|
||||
if (d > to) break;
|
||||
if (d >= from) out.push(d);
|
||||
month += Math.max(1, sched.repeat_every);
|
||||
year += Math.floor(month / 12);
|
||||
month = ((month % 12) + 12) % 12;
|
||||
guard += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const allowed = sched.on_days.length ? sched.on_days : [from.getDay()];
|
||||
for (let i = 0; i < 60 && out.length < effectiveCap; 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;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
export const BODY_FS = '0.88rem';
|
||||
export const LABEL_FS = '0.82rem';
|
||||
export const HINT_FS = '0.78rem';
|
||||
export const INPUT_FS = '0.88rem';
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
|
||||
type ActionBtnTone = 'muted' | 'success' | 'danger';
|
||||
|
||||
export function ActionBtn({ label, tone, disabled, onClick, icon }: { label: string; tone: ActionBtnTone; disabled?: boolean; onClick: () => void; icon?: 'trash' | 'check' }) {
|
||||
const c = useClaudeTokens();
|
||||
const palette = tone === 'success'
|
||||
? { color: c.status.success, bg: c.status.successBg, border: c.status.success + '60', hover: c.status.success + '30' }
|
||||
: tone === 'danger'
|
||||
? { color: c.status.error, bg: c.status.errorBg, border: c.status.error + '60', hover: c.status.error + '30' }
|
||||
: { color: c.text.secondary, bg: c.bg.secondary, border: c.border.subtle, hover: c.bg.elevated };
|
||||
return (
|
||||
<Box
|
||||
onClick={disabled ? undefined : onClick}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.45,
|
||||
fontSize: LABEL_FS, fontWeight: 600, px: 1.25, py: 0.5,
|
||||
borderRadius: 999,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
color: palette.color,
|
||||
bgcolor: palette.bg,
|
||||
border: `1px solid ${palette.border}`,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
'&:hover': { bgcolor: palette.hover },
|
||||
}}>
|
||||
{icon === 'trash' && <DeleteOutlineIcon sx={{ fontSize: 15 }} />}
|
||||
{icon === 'check' && <CheckIcon sx={{ fontSize: 15 }} />}
|
||||
{label}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
// Shared visual helpers for the workflow card UI tier: schedule/permission
|
||||
// pill chips, status dot, run-status sparkline, step connector, step icon
|
||||
// auto-classifier. Kept as plain functions/components so individual views
|
||||
// can compose without owning the styling.
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import Select from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { updateWorkflow } from '@/shared/state/workflowsSlice';
|
||||
import ScheduleIcon from '@mui/icons-material/ScheduleRounded';
|
||||
import NotificationsIcon from '@mui/icons-material/NotificationsRounded';
|
||||
import SmsIcon from '@mui/icons-material/SmsRounded';
|
||||
import PhoneInTalkIcon from '@mui/icons-material/PhoneInTalkRounded';
|
||||
import EmailIcon from '@mui/icons-material/MailOutlineRounded';
|
||||
import EventNoteIcon from '@mui/icons-material/EventNoteRounded';
|
||||
import ChromeReaderModeIcon from '@mui/icons-material/ChromeReaderModeRounded';
|
||||
import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
|
||||
import CalendarTodayIcon from '@mui/icons-material/CalendarTodayRounded';
|
||||
import ArticleIcon from '@mui/icons-material/ArticleRounded';
|
||||
import LanguageIcon from '@mui/icons-material/LanguageRounded';
|
||||
import AttachMoneyIcon from '@mui/icons-material/AttachMoneyRounded';
|
||||
import AllInclusiveIcon from '@mui/icons-material/AllInclusiveRounded';
|
||||
import CodeIcon from '@mui/icons-material/CodeRounded';
|
||||
import SearchIcon from '@mui/icons-material/SearchRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import type { Workflow, WorkflowRun, ScheduleConfig, PermissionTier } from '@/shared/state/workflowsSlice';
|
||||
import { formatTime, WEEKDAY_LABEL } from './scheduleUtils';
|
||||
|
||||
// ---------- Status colors ----------
|
||||
|
||||
export type LastRunStatus = NonNullable<Workflow['last_run_status']>;
|
||||
|
||||
export function statusDotColor(status: LastRunStatus | null | undefined, c: ReturnType<typeof useClaudeTokens>) {
|
||||
switch (status) {
|
||||
case 'success': return c.status.success;
|
||||
case 'ran_late': return c.status.warning || '#f59e0b';
|
||||
case 'failure': return c.status.error;
|
||||
case 'running': return c.accent.primary;
|
||||
case 'skipped': return c.text.muted;
|
||||
default: return c.text.ghost;
|
||||
}
|
||||
}
|
||||
|
||||
// Human-readable status word. We surface "ran late" instead of the
|
||||
// underscore-y "ran_late" everywhere it'd be visible to a user.
|
||||
export function statusWord(status: LastRunStatus | null | undefined): string {
|
||||
if (!status) return 'Never run';
|
||||
if (status === 'ran_late') return 'Ran late';
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
|
||||
// Status pill rendered next to the title. Bigger than the previous 9px
|
||||
// dot and pairs the color with a short word so a non-dev knows what
|
||||
// they're looking at instead of squinting at a single grey pixel.
|
||||
export function StatusDot({ status }: { status: LastRunStatus | null | undefined }) {
|
||||
const c = useClaudeTokens();
|
||||
const word = statusWord(status);
|
||||
const dotColor = statusDotColor(status, c);
|
||||
return (
|
||||
<Tooltip title={status ? `Last run: ${word.toLowerCase()}` : 'This workflow has never run.'}>
|
||||
<Box sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
height: 18, px: 0.6, borderRadius: 999,
|
||||
bgcolor: status === 'failure' ? c.status.errorBg : status === 'ran_late' ? c.status.warningBg : status === 'success' ? c.status.successBg : c.bg.elevated,
|
||||
border: `1px solid ${dotColor}55`,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: dotColor, boxShadow: status === 'failure' ? `0 0 4px ${c.status.error}` : 'none' }} />
|
||||
<Typography sx={{ fontSize: '0.66rem', fontWeight: 700, color: dotColor, letterSpacing: '0.02em' }}>
|
||||
{word}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Pill chips ----------
|
||||
|
||||
function scheduleShort(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 ? `Daily ${time}` : `Every ${sched.repeat_every}d ${time}`;
|
||||
}
|
||||
if (sched.repeat_unit === 'month') {
|
||||
return sched.repeat_every === 1 ? `Monthly ${time}` : `Every ${sched.repeat_every}mo ${time}`;
|
||||
}
|
||||
if (sched.on_days.length === 5 && [1, 2, 3, 4, 5].every((d) => sched.on_days.includes(d))) return `Weekdays ${time}`;
|
||||
if (sched.on_days.length === 2 && [0, 6].every((d) => sched.on_days.includes(d))) return `Weekends ${time}`;
|
||||
if (sched.on_days.length === 1) {
|
||||
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
return `${labels[sched.on_days[0]]} ${time}`;
|
||||
}
|
||||
if (sched.on_days.length === 0) return `Weekly ${time}`;
|
||||
return `${sched.on_days.length}×/wk ${time}`;
|
||||
}
|
||||
|
||||
// Weekday-dot strip "S M T W T F S" with active days filled. Rendered
|
||||
// inline next to the chip when the schedule is weekly so users can
|
||||
// pattern-match days without parsing prose. Active = filled accent dot.
|
||||
export function WeekdayDots({ on_days }: { on_days: number[] }) {
|
||||
const c = useClaudeTokens();
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35, ml: 0.5 }}>
|
||||
{WEEKDAY_LABEL.map((lbl, idx) => {
|
||||
const active = on_days.includes(idx);
|
||||
return (
|
||||
<Box key={`${lbl}-${idx}`} sx={{
|
||||
width: 12, height: 12, borderRadius: '50%',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '0.6rem', fontWeight: 700,
|
||||
color: active ? '#fff' : c.text.ghost,
|
||||
bgcolor: active ? c.accent.primary : 'transparent',
|
||||
border: `1px solid ${active ? c.accent.primary : c.border.subtle}`,
|
||||
lineHeight: 1,
|
||||
}}>
|
||||
{lbl}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function permIcon(kind: PermissionTier['kind'], size = 13) {
|
||||
if (kind === 'text') return <SmsIcon sx={{ fontSize: size }} />;
|
||||
if (kind === 'call') return <PhoneInTalkIcon sx={{ fontSize: size }} />;
|
||||
return <NotificationsIcon sx={{ fontSize: size }} />;
|
||||
}
|
||||
|
||||
// Compact "🔔 → 💬 → 📞" representation of the escalation chain. Hover
|
||||
// shows the literal prose (notify, text, call, with delays).
|
||||
export function PermissionChip({ workflow }: { workflow: Workflow }) {
|
||||
const c = useClaudeTokens();
|
||||
const tiers = workflow.permissions || [];
|
||||
if (tiers.length === 0) return null;
|
||||
const label = tiers.map((t) => {
|
||||
if (t.kind === 'notify') return 'notify in app';
|
||||
const unit = t.kind === 'call' ? 'h' : 'm';
|
||||
return `${t.kind} after ${t.after_minutes}${unit}`;
|
||||
}).join(' → ');
|
||||
return (
|
||||
<Tooltip title={label}>
|
||||
<Box sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.35,
|
||||
fontSize: '0.74rem', fontWeight: 500,
|
||||
color: c.text.secondary,
|
||||
bgcolor: c.bg.elevated,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
px: 0.85, py: 0.3, borderRadius: 999,
|
||||
}}>
|
||||
{tiers.map((t, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{permIcon(t.kind)}
|
||||
{i < tiers.length - 1 && <Box sx={{ fontSize: '0.7rem', color: c.text.ghost, mx: 0.1 }}>→</Box>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScheduleChip({ workflow }: { workflow: Workflow }) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const enabled = workflow.schedule.enabled;
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
// Inline edit: time + AM/PM only. Anything richer should open the
|
||||
// full editor. Saves on change with optimistic updated_at If-Match.
|
||||
const sched = workflow.schedule;
|
||||
const patchSched = (patch: Partial<typeof sched>) => {
|
||||
const next = { ...sched, ...patch };
|
||||
dispatch(updateWorkflow({
|
||||
id: workflow.id,
|
||||
patch: { schedule: next as any },
|
||||
ifMatch: workflow.updated_at || null,
|
||||
}));
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={enabled ? `Click to tweak time. Full editor lives in the Edit tab.` : 'Not scheduled'}>
|
||||
<Box
|
||||
onClick={(e) => enabled && setAnchor(e.currentTarget as HTMLElement)}
|
||||
role={enabled ? 'button' : undefined}
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
fontSize: '0.74rem', fontWeight: 600,
|
||||
color: enabled ? c.accent.primary : c.text.muted,
|
||||
bgcolor: enabled ? c.accent.primary + '14' : c.bg.elevated,
|
||||
border: `1px solid ${enabled ? c.accent.primary + '40' : c.border.subtle}`,
|
||||
px: 0.85, py: 0.3, borderRadius: 999,
|
||||
cursor: enabled ? 'pointer' : 'default',
|
||||
'&:hover': enabled ? { bgcolor: c.accent.primary + '22' } : undefined,
|
||||
}}>
|
||||
<ScheduleIcon sx={{ fontSize: 13 }} />
|
||||
{scheduleShort(workflow.schedule)}
|
||||
{enabled && workflow.schedule.repeat_unit === 'week' && (
|
||||
<WeekdayDots on_days={workflow.schedule.on_days} />
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
open={Boolean(anchor)}
|
||||
anchorEl={anchor}
|
||||
onClose={() => setAnchor(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}>
|
||||
<Box sx={{ p: 1, display: 'flex', flexDirection: 'column', gap: 0.5, minWidth: 220 }}>
|
||||
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em' }}>
|
||||
QUICK TIME EDIT
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Select
|
||||
size="small"
|
||||
value={((sched.hour + 11) % 12) + 1}
|
||||
onChange={(e) => {
|
||||
const h12 = Number(e.target.value);
|
||||
const isPm = sched.hour >= 12;
|
||||
patchSched({ hour: (h12 % 12) + (isPm ? 12 : 0) });
|
||||
}}
|
||||
sx={{ fontSize: '0.78rem', '& .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: '0.85rem' }}>:</Typography>
|
||||
<Select
|
||||
size="small"
|
||||
value={sched.minute}
|
||||
onChange={(e) => patchSched({ minute: Number(e.target.value) })}
|
||||
sx={{ fontSize: '0.78rem', '& .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={sched.hour < 12 ? 'AM' : 'PM'}
|
||||
onChange={(e) => {
|
||||
const wasPm = sched.hour >= 12;
|
||||
const willBePm = e.target.value === 'PM';
|
||||
if (wasPm === willBePm) return;
|
||||
patchSched({ hour: willBePm ? sched.hour + 12 : sched.hour - 12 });
|
||||
}}
|
||||
sx={{ fontSize: '0.78rem', '& .MuiSelect-select': { py: 0.4 } }}>
|
||||
<MenuItem value="AM">AM</MenuItem>
|
||||
<MenuItem value="PM">PM</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.text.ghost, mt: 0.25 }}>
|
||||
Saved as you change.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Classify a workflow's billing route based on its model id + the user's
|
||||
// global connection mode. Mirrors the per-session logic in AgentChat so
|
||||
// the workflow card tells the same story the chat header does. Returns
|
||||
// 'metered' when the user pays per call (Anthropic/OpenAI/Gemini API
|
||||
// keys, custom OpenAI-compatible) or 'subscription' when a flat-rate
|
||||
// account is doing the work (Claude Pro/Max, ChatGPT Plus/Pro, Gemini
|
||||
// Advanced, OpenSwarm Pro proxy). `subLabel` names the plan for tooltips.
|
||||
export type RoutingKind = 'metered' | 'subscription';
|
||||
export interface Routing {
|
||||
kind: RoutingKind;
|
||||
subLabel?: string;
|
||||
}
|
||||
|
||||
export function routingFor(model: string, connectionMode: string | undefined): Routing {
|
||||
const m = (model || '').toLowerCase();
|
||||
if (m.endsWith('-api')) return { kind: 'metered' };
|
||||
if (m.endsWith('-cc')) return { kind: 'subscription', subLabel: 'Claude Pro/Max' };
|
||||
const isPlainAnthropic = m === 'sonnet' || m === 'opus' || m === 'haiku';
|
||||
if (isPlainAnthropic && connectionMode === 'openswarm-pro') {
|
||||
return { kind: 'subscription', subLabel: 'OpenSwarm Pro' };
|
||||
}
|
||||
if (isPlainAnthropic) return { kind: 'metered' };
|
||||
if (m.startsWith('gpt-5') || m.startsWith('gpt-4') || m.startsWith('o1') || m.startsWith('o3') || m.startsWith('o4')) {
|
||||
return { kind: 'subscription', subLabel: 'ChatGPT Plus/Pro' };
|
||||
}
|
||||
if (m.startsWith('gemini-')) {
|
||||
return { kind: 'subscription', subLabel: 'Gemini Advanced' };
|
||||
}
|
||||
// Unknown model id, default to metered so we don't oversell "free."
|
||||
return { kind: 'metered' };
|
||||
}
|
||||
|
||||
export function CostChip({ workflow, connectionMode }: { workflow: Workflow; connectionMode?: string }) {
|
||||
const c = useClaudeTokens();
|
||||
const est = workflow.cost_estimate;
|
||||
const route = routingFor(workflow.model, connectionMode);
|
||||
|
||||
// Subscription-routed workflows have no metered per-call cost. Surface
|
||||
// a usage chip instead so the user knows runs are "free" under their
|
||||
// existing plan but still sees the projected fire frequency.
|
||||
if (route.kind === 'subscription') {
|
||||
if (!est || est.fires_per_month === 0) {
|
||||
return (
|
||||
<Tooltip title={`Runs are covered by your ${route.subLabel} plan. No upcoming runs scheduled.`}>
|
||||
<Box sx={chipSx(c)}>
|
||||
<AllInclusiveIcon sx={{ fontSize: 12 }} />
|
||||
{route.subLabel || 'Subscription'}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip title={`Routed through your ${route.subLabel} plan; no per-run cost. About ${est.fires_per_month} runs per month at the current schedule.`}>
|
||||
<Box sx={chipSx(c)}>
|
||||
<AllInclusiveIcon sx={{ fontSize: 12 }} />
|
||||
~{est.fires_per_month} runs/mo
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Metered route: only render the cost chip once we actually have a
|
||||
// last-run figure to project from. Avoids "$0.00/mo" gaslighting.
|
||||
if (!est || est.fires_per_month === 0 || est.last_run_usd <= 0) return null;
|
||||
const monthly = est.monthly_usd || 0;
|
||||
return (
|
||||
<Tooltip title={`About $${est.last_run_usd.toFixed(4)} per run, times ${est.fires_per_month} runs per month.`}>
|
||||
<Box sx={chipSx(c)}>
|
||||
<AttachMoneyIcon sx={{ fontSize: 12, ml: -0.25 }} />
|
||||
{monthly < 0.01 ? '<0.01' : monthly.toFixed(2)}/mo
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function chipSx(c: ReturnType<typeof useClaudeTokens>) {
|
||||
return {
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.3,
|
||||
fontSize: '0.74rem', fontWeight: 600,
|
||||
color: c.text.secondary,
|
||||
bgcolor: c.bg.elevated,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
px: 0.75, py: 0.3, borderRadius: 999,
|
||||
} as const;
|
||||
}
|
||||
|
||||
// Compact "last fired" mini-label, used inside the Run-tab summary.
|
||||
export function LastFiredHint({ workflow }: { workflow: Workflow }) {
|
||||
const c = useClaudeTokens();
|
||||
if (!workflow.last_run_at) return null;
|
||||
const ms = Date.now() - new Date(workflow.last_run_at).getTime();
|
||||
const ago = relTime(ms);
|
||||
return (
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.ghost }}>Last ran {ago}</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
function relTime(ms: number): string {
|
||||
if (ms < 0) return 'just now';
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 30) return `${d}d ago`;
|
||||
const mo = Math.floor(d / 30);
|
||||
return `${mo}mo ago`;
|
||||
}
|
||||
|
||||
// ---------- Run history sparkline ----------
|
||||
|
||||
// 10-dot horizontal strip of last N runs colored by status. Easy "lately
|
||||
// healthy?" check without opening the History tab. Tooltip names the
|
||||
// pattern out loud so a non-dev knows the dots aren't decorative.
|
||||
export function RunSparkline({ runs, max = 10 }: { runs: WorkflowRun[]; max?: number }) {
|
||||
const c = useClaudeTokens();
|
||||
if (!runs || runs.length === 0) return null;
|
||||
const slice = runs.slice(0, max).reverse();
|
||||
const successes = slice.filter((r) => r.status === 'success').length;
|
||||
const failures = slice.filter((r) => r.status === 'failure').length;
|
||||
const tooltip = `Last ${slice.length} run${slice.length === 1 ? '' : 's'}: ${successes} ok, ${failures} failed (oldest left → newest right). Green = success, red = failure, amber = ran late.`;
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.3, ml: 0.5 }}>
|
||||
{slice.map((r) => (
|
||||
<Box key={r.id} sx={{
|
||||
width: 6, height: 6, borderRadius: '50%',
|
||||
bgcolor: statusDotColor(r.status as LastRunStatus, c),
|
||||
}} />
|
||||
))}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Streak badge ----------
|
||||
|
||||
// Count consecutive successful runs at the head of the runs list.
|
||||
// `runs[0]` is the most recent run, so we walk forward until we hit a
|
||||
// non-success. Returns 0 when no streak is active.
|
||||
export function successStreak(runs: WorkflowRun[] | undefined): number {
|
||||
if (!runs || runs.length === 0) return 0;
|
||||
let n = 0;
|
||||
for (const r of runs) {
|
||||
if (r.status === 'success' || r.status === 'ran_late') n += 1;
|
||||
else break;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export function StreakBadge({ runs }: { runs: WorkflowRun[] | undefined }) {
|
||||
const c = useClaudeTokens();
|
||||
const n = successStreak(runs);
|
||||
if (n < 3) return null;
|
||||
return (
|
||||
<Tooltip title={`${n} successful runs in a row.`}>
|
||||
<Box sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.3,
|
||||
fontSize: '0.72rem', fontWeight: 700,
|
||||
color: c.status.warning || '#f59e0b',
|
||||
bgcolor: (c.status.warningBg || c.bg.elevated),
|
||||
border: `1px solid ${(c.status.warning || '#f59e0b') + '60'}`,
|
||||
px: 0.7, py: 0.2, borderRadius: 999,
|
||||
}}>
|
||||
🔥 {n}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Step icon auto-classifier ----------
|
||||
|
||||
// Pick a glyph by keyword scan of the step text. Falls back to the
|
||||
// step number when nothing matches. Same Roman-numeral simple heuristic
|
||||
// the user sees: "summarize email" -> mail icon, "make notion page" ->
|
||||
// article icon, etc.
|
||||
const ICON_RULES: Array<{ pattern: RegExp; Icon: React.ElementType }> = [
|
||||
{ pattern: /\b(email|inbox|gmail|outlook|mail)\b/i, Icon: EmailIcon },
|
||||
{ pattern: /\b(calendar|schedule|event|meeting)\b/i, Icon: CalendarTodayIcon },
|
||||
{ pattern: /\b(notion|doc|page|page template|document|article)\b/i, Icon: ArticleIcon },
|
||||
{ pattern: /\b(text|sms|message|whatsapp|imessage)\b/i, Icon: SmsIcon },
|
||||
{ pattern: /\b(call|phone|dial|ring)\b/i, Icon: PhoneInTalkIcon },
|
||||
{ pattern: /\b(browser|web|website|url|fetch|visit|navigate)\b/i, Icon: LanguageIcon },
|
||||
{ pattern: /\b(search|find|look up|google)\b/i, Icon: SearchIcon },
|
||||
{ pattern: /\b(code|github|repo|script|bash|run)\b/i, Icon: CodeIcon },
|
||||
{ pattern: /\b(read|review|summarize|summary)\b/i, Icon: ChromeReaderModeIcon },
|
||||
{ pattern: /\b(chat|reply|respond|dm)\b/i, Icon: ChatBubbleOutlineIcon },
|
||||
{ pattern: /\b(note|memo|journal|log)\b/i, Icon: EventNoteIcon },
|
||||
];
|
||||
|
||||
export function stepIconFor(text: string): React.ElementType | null {
|
||||
for (const rule of ICON_RULES) {
|
||||
if (rule.pattern.test(text)) return rule.Icon;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- Step duration learner ----------
|
||||
|
||||
// Estimates per-step duration by averaging recent runs. Today we only
|
||||
// have whole-run duration on each WorkflowRun (started_at -> finished_at),
|
||||
// so the heuristic spreads it evenly across the step count. When per-step
|
||||
// telemetry lands later, swap this for a per-step lookup.
|
||||
export function estimateStepDuration(workflow: Workflow, runs: WorkflowRun[] | undefined, stepIdx: number): string | null {
|
||||
if (!runs || runs.length === 0) return null;
|
||||
const steps = workflow.steps?.length || 1;
|
||||
const successful = runs.filter((r) => (r.status === 'success' || r.status === 'ran_late') && r.finished_at);
|
||||
if (successful.length === 0) return null;
|
||||
const durations = successful.slice(0, 10).map((r) => {
|
||||
const start = new Date(r.started_at).getTime();
|
||||
const end = new Date(r.finished_at!).getTime();
|
||||
return Math.max(0, end - start);
|
||||
});
|
||||
const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
|
||||
const perStepMs = avg / steps;
|
||||
void stepIdx;
|
||||
return humanDuration(perStepMs);
|
||||
}
|
||||
|
||||
export function humanDuration(ms: number): string {
|
||||
if (ms < 1000) return '<1s';
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s % 60;
|
||||
return rem > 0 && m < 5 ? `${m}m ${rem}s` : `${m}m`;
|
||||
}
|
||||
|
||||
// ---------- Run-button breath logic ----------
|
||||
|
||||
// Returns true when the workflow hasn't been run in over 24h. Used by
|
||||
// the Run tab to add a subtle CSS breathing animation so the button
|
||||
// invites use without yelling.
|
||||
export function isStaleSinceLastRun(workflow: Workflow): boolean {
|
||||
if (!workflow.last_run_at) return false;
|
||||
const age = Date.now() - new Date(workflow.last_run_at).getTime();
|
||||
return age > 24 * 3600 * 1000;
|
||||
}
|
||||
@@ -7,6 +7,9 @@ const fetchSessionRejectedAction = createAction<
|
||||
{ sessionId?: string; status?: number } | undefined
|
||||
>('agents/fetchSession/rejected');
|
||||
|
||||
// Cascade workflow delete to layout so the "Make workflow" tether stops pointing at empty space.
|
||||
const deleteWorkflowFulfilledAction = createAction<string>('workflows/delete/fulfilled');
|
||||
|
||||
const DASHBOARDS_API = `${API_BASE}/dashboards`;
|
||||
|
||||
export const DEFAULT_CARD_W = 480;
|
||||
@@ -15,12 +18,16 @@ 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 = 480;
|
||||
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 };
|
||||
const GRID_COLS_FALLBACK = 4;
|
||||
|
||||
export type CardType = 'agent' | 'view' | 'browser' | 'note';
|
||||
export type CardType = 'agent' | 'view' | 'browser' | 'note' | 'workflow';
|
||||
|
||||
export interface CardPosition {
|
||||
session_id: string;
|
||||
@@ -61,6 +68,25 @@ 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; only one Workflows Hub card open at a time. */
|
||||
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 {
|
||||
@@ -77,10 +103,21 @@ export interface NotePosition {
|
||||
export const DEFAULT_NOTE_W = 240;
|
||||
export const DEFAULT_NOTE_H = 200;
|
||||
|
||||
export interface ConfigurePanelPosition {
|
||||
workflow_id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface DashboardLayoutState {
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
workflowCards: Record<string, WorkflowCardPosition>;
|
||||
configurePanels: Record<string, ConfigurePanelPosition>;
|
||||
workflowsHub: WorkflowsHubPosition | null;
|
||||
notes: Record<string, NotePosition>;
|
||||
closedCardPositions: Record<string, CardPosition>;
|
||||
glowingBrowserCards: Record<string, { sourceId: string; fading: boolean; label?: string }>;
|
||||
@@ -92,12 +129,18 @@ export interface DashboardLayoutState {
|
||||
/** Transient: new browser card id; Dashboard pans/zooms to it then clears via clearPendingFocusBrowserId. */
|
||||
pendingFocusBrowserId: string | null;
|
||||
pendingFocusNoteId: string | null;
|
||||
pendingFocusWorkflowId: string | null;
|
||||
/** Transient: signals Dashboard to pan/zoom to the singleton Workflows Hub on open. */
|
||||
pendingFocusWorkflowsHub: boolean;
|
||||
}
|
||||
|
||||
const initialState: DashboardLayoutState = {
|
||||
cards: {},
|
||||
viewCards: {},
|
||||
browserCards: {},
|
||||
workflowCards: {},
|
||||
configurePanels: {},
|
||||
workflowsHub: null,
|
||||
notes: {},
|
||||
closedCardPositions: {},
|
||||
glowingBrowserCards: {},
|
||||
@@ -108,12 +151,17 @@ 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>;
|
||||
configurePanels: Record<string, ConfigurePanelPosition>;
|
||||
workflowsHub: WorkflowsHubPosition | null;
|
||||
notes: Record<string, NotePosition>;
|
||||
expandedSessionIds: string[];
|
||||
}
|
||||
@@ -146,6 +194,9 @@ 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>,
|
||||
configurePanels: (layout.configure_panels ?? {}) as Record<string, ConfigurePanelPosition>,
|
||||
workflowsHub: (layout.workflows_hub ?? null) as WorkflowsHubPosition | null,
|
||||
notes: (layout.notes ?? {}) as Record<string, NotePosition>,
|
||||
expandedSessionIds: (layout.expanded_session_ids ?? []) as string[],
|
||||
} satisfies LayoutPayload;
|
||||
@@ -167,6 +218,9 @@ export const saveLayout = createAsyncThunk(
|
||||
cards: payload.cards,
|
||||
view_cards: payload.viewCards,
|
||||
browser_cards: payload.browserCards,
|
||||
workflow_cards: payload.workflowCards,
|
||||
configure_panels: payload.configurePanels,
|
||||
workflows_hub: payload.workflowsHub,
|
||||
notes: payload.notes,
|
||||
expanded_session_ids: payload.expandedSessionIds,
|
||||
},
|
||||
@@ -203,6 +257,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 });
|
||||
}
|
||||
@@ -358,7 +418,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;
|
||||
// Compute the current top zOrder across ALL card types so we can
|
||||
@@ -366,17 +426,22 @@ const dashboardLayoutSlice = createSlice({
|
||||
// guard, every click on a card (which fires onPointerDownCapture +
|
||||
// onClick + onDoubleClick) bumps zOrder and triggers a Redux
|
||||
// mutation. That mutation cascades into a re-render that unmounts
|
||||
// inputs mid-keystroke.
|
||||
// inputs mid-keystroke, causing the workflow card's title /
|
||||
// description / step textareas to lose focus on every click.
|
||||
let maxZ = 0;
|
||||
let currentZ = 0;
|
||||
const tally = (z: number | undefined) => { if (typeof z === 'number' && z > maxZ) maxZ = z; };
|
||||
for (const c of Object.values(state.cards)) tally(c.zOrder);
|
||||
for (const c of Object.values(state.viewCards)) tally(c.zOrder);
|
||||
for (const c of Object.values(state.browserCards)) tally(c.zOrder);
|
||||
for (const c of Object.values(state.workflowCards)) tally(c.zOrder);
|
||||
for (const n of Object.values(state.notes)) tally(n.zOrder);
|
||||
if (state.workflowsHub) tally(state.workflowsHub.zOrder);
|
||||
if (type === 'agent') currentZ = state.cards[id]?.zOrder ?? 0;
|
||||
else if (type === 'view') currentZ = state.viewCards[id]?.zOrder ?? 0;
|
||||
else if (type === 'note') currentZ = state.notes[id]?.zOrder ?? 0;
|
||||
else if (type === 'workflow') currentZ = state.workflowCards[id]?.zOrder ?? 0;
|
||||
else if (type === 'workflows-hub') currentZ = state.workflowsHub?.zOrder ?? 0;
|
||||
else currentZ = state.browserCards[id]?.zOrder ?? 0;
|
||||
if (currentZ >= maxZ) return; // Already on top: no-op.
|
||||
|
||||
@@ -390,6 +455,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;
|
||||
@@ -445,13 +515,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);
|
||||
|
||||
@@ -476,6 +548,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; }
|
||||
@@ -606,6 +681,187 @@ 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]) {
|
||||
state.workflowCards[workflowId].zOrder = state.nextZOrder++;
|
||||
state.pendingFocusWorkflowId = workflowId;
|
||||
return;
|
||||
}
|
||||
// Fall back to persistedExpandedSessionIds when the caller didn't
|
||||
// wire the live list through. Without it, collectOccupiedRects sees
|
||||
// every chat at its stored (collapsed) height, and a workflow
|
||||
// spawned from an open chat lands on top of the visibly-tall card.
|
||||
const expanded = expandedSessionIds ?? state.persistedExpandedSessionIds;
|
||||
const rects = collectOccupiedRects(state, expanded);
|
||||
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];
|
||||
},
|
||||
|
||||
// Rekey draft- id to the server-assigned id without visually hopping the card.
|
||||
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 };
|
||||
// Carry any open Action-Library panel along with the rekey so the
|
||||
// popout doesn't disappear when a draft is saved.
|
||||
const panel = state.configurePanels[oldId];
|
||||
if (panel) {
|
||||
delete state.configurePanels[oldId];
|
||||
state.configurePanels[newId] = { ...panel, workflow_id: newId };
|
||||
}
|
||||
if (state.pendingFocusWorkflowId === oldId) state.pendingFocusWorkflowId = newId;
|
||||
},
|
||||
|
||||
openConfigurePanel(
|
||||
state,
|
||||
action: PayloadAction<{ workflowId: string }>,
|
||||
) {
|
||||
const { workflowId } = action.payload;
|
||||
// Anchor the panel just to the right of the workflow card.
|
||||
const wfCard = state.workflowCards[workflowId];
|
||||
const baseX = wfCard ? wfCard.x + wfCard.width + GRID_GAP * 6 : 600;
|
||||
const baseY = wfCard ? wfCard.y : 200;
|
||||
const existing = state.configurePanels[workflowId];
|
||||
if (existing) {
|
||||
existing.x = baseX;
|
||||
existing.y = baseY;
|
||||
return;
|
||||
}
|
||||
state.configurePanels[workflowId] = {
|
||||
workflow_id: workflowId,
|
||||
x: baseX,
|
||||
y: baseY,
|
||||
width: 580,
|
||||
height: 600,
|
||||
};
|
||||
},
|
||||
|
||||
setConfigurePanelPosition(
|
||||
state,
|
||||
action: PayloadAction<{ workflowId: string; x: number; y: number }>,
|
||||
) {
|
||||
const { workflowId, x, y } = action.payload;
|
||||
const p = state.configurePanels[workflowId];
|
||||
if (p) { p.x = x; p.y = y; }
|
||||
},
|
||||
|
||||
setConfigurePanelSize(
|
||||
state,
|
||||
action: PayloadAction<{ workflowId: string; width: number; height: number }>,
|
||||
) {
|
||||
const { workflowId, width, height } = action.payload;
|
||||
const p = state.configurePanels[workflowId];
|
||||
if (p) {
|
||||
p.width = Math.max(360, width);
|
||||
p.height = Math.max(280, height);
|
||||
}
|
||||
},
|
||||
|
||||
closeConfigurePanel(state, action: PayloadAction<string>) {
|
||||
delete state.configurePanels[action.payload];
|
||||
},
|
||||
|
||||
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<{
|
||||
@@ -754,7 +1010,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;
|
||||
}>,
|
||||
@@ -779,6 +1035,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) {
|
||||
@@ -906,6 +1168,9 @@ const dashboardLayoutSlice = createSlice({
|
||||
state.cards = {};
|
||||
state.viewCards = {};
|
||||
state.browserCards = {};
|
||||
state.workflowCards = {};
|
||||
state.configurePanels = {};
|
||||
state.workflowsHub = null;
|
||||
state.notes = {};
|
||||
state.closedCardPositions = {};
|
||||
state.glowingBrowserCards = {};
|
||||
@@ -914,6 +1179,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
state.nextZOrder = 1;
|
||||
state.initialized = false;
|
||||
state.pendingFocusNoteId = null;
|
||||
state.pendingFocusWorkflowId = null;
|
||||
},
|
||||
|
||||
},
|
||||
@@ -928,6 +1194,9 @@ const dashboardLayoutSlice = createSlice({
|
||||
state.cards = action.payload.cards;
|
||||
state.viewCards = action.payload.viewCards;
|
||||
state.browserCards = action.payload.browserCards;
|
||||
state.workflowCards = action.payload.workflowCards || {};
|
||||
state.configurePanels = action.payload.configurePanels || {};
|
||||
state.workflowsHub = action.payload.workflowsHub || null;
|
||||
state.notes = action.payload.notes || {};
|
||||
state.persistedExpandedSessionIds = action.payload.expandedSessionIds;
|
||||
|
||||
@@ -944,6 +1213,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;
|
||||
@@ -963,6 +1236,11 @@ const dashboardLayoutSlice = createSlice({
|
||||
if (state.cards[id]) delete state.cards[id];
|
||||
if (state.closedCardPositions[id]) delete state.closedCardPositions[id];
|
||||
})
|
||||
.addCase(deleteWorkflowFulfilledAction, (state, action) => {
|
||||
const id = action.payload;
|
||||
if (id && state.workflowCards[id]) delete state.workflowCards[id];
|
||||
if (id && state.configurePanels[id]) delete state.configurePanels[id];
|
||||
})
|
||||
.addCase(launchAndSendFirstMessage.fulfilled, (state, action) => {
|
||||
const { draftId, session } = action.payload;
|
||||
const card = state.cards[draftId];
|
||||
@@ -1010,6 +1288,21 @@ export const {
|
||||
fadeGlowingAgentCard,
|
||||
clearGlowingAgentCard,
|
||||
clearPendingFocusBrowserId,
|
||||
addWorkflowCard,
|
||||
setWorkflowCardPosition,
|
||||
setWorkflowCardSize,
|
||||
removeWorkflowCard,
|
||||
rekeyWorkflowCard,
|
||||
openConfigurePanel,
|
||||
closeConfigurePanel,
|
||||
setConfigurePanelPosition,
|
||||
setConfigurePanelSize,
|
||||
clearPendingFocusWorkflowId,
|
||||
openWorkflowsHub,
|
||||
closeWorkflowsHub,
|
||||
setWorkflowsHubPosition,
|
||||
setWorkflowsHubSize,
|
||||
clearPendingFocusWorkflowsHub,
|
||||
addNote,
|
||||
setNotePosition,
|
||||
setNoteSize,
|
||||
|
||||
@@ -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 '@/shared/state/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 +
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
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';
|
||||
/** End conditions; null on both = forever. Scheduler auto-disables on threshold. */
|
||||
ends_at: string | null;
|
||||
max_runs: number | null;
|
||||
runs_count: number;
|
||||
}
|
||||
|
||||
export interface CostEstimate {
|
||||
monthly_usd: number;
|
||||
last_run_usd: number;
|
||||
fires_per_month: number;
|
||||
}
|
||||
|
||||
export interface ActiveRun {
|
||||
workflow_id: string;
|
||||
run_id: string;
|
||||
title: string;
|
||||
started_at: string | null;
|
||||
}
|
||||
|
||||
export interface ActionsConfig {
|
||||
prevent_unused: boolean;
|
||||
freeze: boolean;
|
||||
configured_sets: string[];
|
||||
}
|
||||
|
||||
export interface WorkflowStep {
|
||||
id: string;
|
||||
text: string;
|
||||
/** LLM-generated 3-6 word label shown when the step row is collapsed. The
|
||||
* full `text` is what the agent actually runs; this is just the title. */
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
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' | 'skipped' | null;
|
||||
last_run_id: string | null;
|
||||
next_run_at: string | null;
|
||||
cost_cap_usd_monthly: number | null;
|
||||
cost_estimate?: CostEstimate;
|
||||
/** Sticky session id for the Edit Agent embedded in the workflow card. */
|
||||
edit_agent_session_id?: 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';
|
||||
/** Live "what's the agent doing" subtitle while status is 'running'. */
|
||||
last_tool_label?: string | null;
|
||||
/** Currently-executing 0-based step index while status is 'running';
|
||||
* freezes on the failed step when status flips to 'failure'. */
|
||||
active_step_idx?: number | null;
|
||||
}
|
||||
|
||||
/** Transient view-only state per card; position lives in dashboardLayoutSlice.workflowCards. */
|
||||
export interface OpenCard {
|
||||
workflowId: string;
|
||||
sourceSessionId?: string | null;
|
||||
draft?: Partial<Workflow> | null;
|
||||
view:
|
||||
| 'preview'
|
||||
| 'saved'
|
||||
| 'edit'
|
||||
| 'history'
|
||||
| 'history_detail'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'scheduling'
|
||||
| 'edit_agent'
|
||||
| 'fix_agent';
|
||||
editFacet?: 'General' | 'Actions' | 'Schedule';
|
||||
historyRunId?: string | null;
|
||||
/** The run id currently surfaced by Running/Completed/Failed views. */
|
||||
runId?: string | null;
|
||||
/** When set, the workflow card is "linked" to a sibling session card via
|
||||
* a labeled arrow chip, and the card footer shifts to Stop Watching /
|
||||
* Stop Viewing / Force Stop. The session id points at the sibling agent. */
|
||||
sidecarSessionId?: string | null;
|
||||
sidecarKind?: 'watching' | 'viewing-completed' | 'viewing-error' | 'testing' | null;
|
||||
/** Per-step expand state for ExpandedView. Stores step ids. */
|
||||
expandedStepIds?: string[];
|
||||
/** Pre-seed message for the Fix-with-Agent flow so the EditAgent composer
|
||||
* knows which failure context to lead with. Cleared once consumed. */
|
||||
fixSeed?: { runId: string; stepIdx: number; stepLabel: string; error: string } | null;
|
||||
}
|
||||
|
||||
interface State {
|
||||
items: Record<string, Workflow>;
|
||||
runs: Record<string, WorkflowRun[]>;
|
||||
openCards: Record<string, OpenCard>;
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
paused: boolean;
|
||||
active: ActiveRun[];
|
||||
cloudSmsEnabled: boolean;
|
||||
}
|
||||
|
||||
const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: 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;
|
||||
},
|
||||
);
|
||||
|
||||
// Optimistic concurrency via If-Match: server 409s on stale writes; rejectWithValue lets FE distinguish.
|
||||
export const updateWorkflow = createAsyncThunk<
|
||||
Workflow,
|
||||
{ id: string; patch: Partial<Workflow>; ifMatch?: string | null },
|
||||
{ rejectValue: { kind: 'stale' | 'network' | 'server'; message: string; current_updated_at?: string } }
|
||||
>(
|
||||
'workflows/update',
|
||||
async ({ id, patch, ifMatch }, { rejectWithValue }) => {
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (ifMatch) headers['If-Match'] = ifMatch;
|
||||
const res = await fetch(`${API}/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (res.status === 409) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const detail = (data && (data.detail || data)) || {};
|
||||
return rejectWithValue({
|
||||
kind: 'stale',
|
||||
message: detail.message || 'This workflow changed elsewhere. Reload and try again.',
|
||||
current_updated_at: detail.current_updated_at,
|
||||
});
|
||||
}
|
||||
if (!res.ok) {
|
||||
return rejectWithValue({ kind: 'server', message: `Update failed (${res.status}).` });
|
||||
}
|
||||
return (await res.json()) as Workflow;
|
||||
} catch (e) {
|
||||
return rejectWithValue({ kind: 'network', message: (e as Error)?.message || 'Network error.' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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,
|
||||
status: (data.status || null) as string | null,
|
||||
error: (data.error || null) as string | null,
|
||||
};
|
||||
});
|
||||
|
||||
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[] };
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchPausedState = createAsyncThunk('workflows/paused', async () => {
|
||||
const res = await fetch(`${API}/paused`);
|
||||
const data = await res.json();
|
||||
return Boolean(data.paused);
|
||||
});
|
||||
|
||||
export const fetchActiveRuns = createAsyncThunk('workflows/active', async () => {
|
||||
const res = await fetch(`${API}/active`);
|
||||
const data = await res.json();
|
||||
return (data.active || []) as ActiveRun[];
|
||||
});
|
||||
|
||||
export const setPausedAll = createAsyncThunk('workflows/setPaused', async (paused: boolean) => {
|
||||
const res = await fetch(`${API}/${paused ? 'pause-all' : 'resume-all'}`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`pause-all toggle failed ${res.status}`);
|
||||
const data = await res.json();
|
||||
return Boolean(data.paused);
|
||||
});
|
||||
|
||||
export const ackRun = createAsyncThunk('workflows/ackRun', async (runId: string) => {
|
||||
const res = await fetch(`${API}/runs/${encodeURIComponent(runId)}/ack`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`ack failed ${res.status}`);
|
||||
return runId;
|
||||
});
|
||||
|
||||
export const fetchCloudSmsStatus = createAsyncThunk('workflows/cloudSms', async () => {
|
||||
try {
|
||||
const res = await fetch(`${API}/cloud/sms/status`);
|
||||
const data = await res.json();
|
||||
return Boolean(data.enabled);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
const prev = idx >= 0 ? arr[idx] : null;
|
||||
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;
|
||||
}
|
||||
// Auto-flip the card view on run state transitions so the user sees
|
||||
// Running while it streams, Completed on success, Failed on failure.
|
||||
// Only nudge from views that the user hasn't actively navigated away
|
||||
// from (saved / running). Edit, history, scheduling etc. stay put.
|
||||
const card = state.openCards[r.workflow_id];
|
||||
if (card) {
|
||||
const fromRunnable = card.view === 'saved' || card.view === 'running';
|
||||
if (r.status === 'running' && fromRunnable) {
|
||||
card.view = 'running';
|
||||
card.runId = r.id;
|
||||
} else if (prev && prev.status === 'running' && r.status === 'success' && (card.view === 'running' || card.view === 'saved')) {
|
||||
card.view = 'completed';
|
||||
card.runId = r.id;
|
||||
} else if (prev && prev.status === 'running' && r.status === 'failure' && (card.view === 'running' || card.view === 'saved')) {
|
||||
card.view = 'failed';
|
||||
card.runId = r.id;
|
||||
}
|
||||
}
|
||||
},
|
||||
toggleExpandedStep(state, action: { payload: { workflowId: string; stepId: string } }) {
|
||||
const card = state.openCards[action.payload.workflowId];
|
||||
if (!card) return;
|
||||
const arr = card.expandedStepIds || [];
|
||||
const has = arr.includes(action.payload.stepId);
|
||||
card.expandedStepIds = has ? arr.filter((x) => x !== action.payload.stepId) : [...arr, action.payload.stepId];
|
||||
},
|
||||
setCardSidecar(state, action: { payload: { workflowId: string; sessionId: string | null; kind: OpenCard['sidecarKind'] } }) {
|
||||
const card = state.openCards[action.payload.workflowId];
|
||||
if (!card) return;
|
||||
card.sidecarSessionId = action.payload.sessionId;
|
||||
card.sidecarKind = action.payload.kind;
|
||||
},
|
||||
clearFixSeed(state, action: { payload: string }) {
|
||||
const card = state.openCards[action.payload];
|
||||
if (card) card.fixSeed = null;
|
||||
},
|
||||
},
|
||||
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;
|
||||
})
|
||||
.addCase(fetchPausedState.fulfilled, (state, action) => { state.paused = action.payload; })
|
||||
.addCase(setPausedAll.fulfilled, (state, action) => { state.paused = action.payload; })
|
||||
.addCase(fetchActiveRuns.fulfilled, (state, action) => { state.active = action.payload; })
|
||||
.addCase(fetchCloudSmsStatus.fulfilled, (state, action) => { state.cloudSmsEnabled = action.payload; });
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
upsertRun,
|
||||
openWorkflowCard,
|
||||
updateWorkflowCard,
|
||||
closeWorkflowCard,
|
||||
rekeyOpenCard,
|
||||
toggleExpandedStep,
|
||||
setCardSidecar,
|
||||
clearFixSeed,
|
||||
} = slice.actions;
|
||||
export default slice.reducer;
|
||||
@@ -23,8 +23,9 @@ import {
|
||||
clearTurnLabel,
|
||||
} from '../state/agentsSlice';
|
||||
import { streamStart, streamDelta, streamEnd } from '../state/streamingSlice';
|
||||
import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice';
|
||||
import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP, addWorkflowCard } from '../state/dashboardLayoutSlice';
|
||||
import { upsertOutput } from '../state/outputsSlice';
|
||||
import { upsertRun, ackRun, runWorkflowNow, openWorkflowCard } from '../state/workflowsSlice';
|
||||
import { getAuthToken } from '../config';
|
||||
import { notifyAgentCompletion } from '../notifications';
|
||||
|
||||
@@ -703,6 +704,65 @@ 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 */ }
|
||||
try {
|
||||
const w: any = (window as any).openswarm;
|
||||
if (w?.notify) {
|
||||
// Seed by workflow id + current minute so multiple workflows pick different copy
|
||||
// while a single workflow stays stable within a few minutes.
|
||||
const seed = ((data.workflow_id || '').length + Math.floor(Date.now() / 60000)) | 0;
|
||||
const SUCCESS_TITLES = [
|
||||
`${data.workflow_title || 'Workflow'} — done`,
|
||||
`${data.workflow_title || 'Workflow'} just wrapped up`,
|
||||
`Heads up: ${data.workflow_title || 'Workflow'} finished`,
|
||||
`${data.workflow_title || 'Workflow'} is ready`,
|
||||
];
|
||||
const FAILURE_TITLES = [
|
||||
`${data.workflow_title || 'Workflow'} hit a snag`,
|
||||
`${data.workflow_title || 'Workflow'} couldn't finish`,
|
||||
`Something went sideways on ${data.workflow_title || 'Workflow'}`,
|
||||
];
|
||||
const LATE_TITLES = [
|
||||
`${data.workflow_title || 'Workflow'} caught up late`,
|
||||
`${data.workflow_title || 'Workflow'} ran late but made it`,
|
||||
];
|
||||
const pool = data.status === 'success' ? SUCCESS_TITLES
|
||||
: data.status === 'failure' ? FAILURE_TITLES
|
||||
: data.status === 'ran_late' ? LATE_TITLES
|
||||
: [`${data.workflow_title || 'Workflow'} • ${data.status}`];
|
||||
const title = pool[Math.abs(seed) % pool.length];
|
||||
const isMac = (typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform));
|
||||
const body = data.tier_kind && data.fallback
|
||||
? `Would have ${data.tier_kind === 'call' ? 'called' : 'texted'} you. (Cloud SMS not wired yet.)`
|
||||
: data.status === 'success'
|
||||
? (isMac ? 'Tap to see what it did.' : 'Click to see what it did.')
|
||||
: data.status === 'failure'
|
||||
? (isMac ? 'Tap to see what went wrong.' : 'Click to see what went wrong.')
|
||||
: (isMac ? 'Tap to open the run.' : 'Click to open the run.');
|
||||
const deepLink = data.workflow_id ? `openswarm://workflow/${data.workflow_id}/run/${data.run_id || ''}` : undefined;
|
||||
const actions = [
|
||||
{ text: 'Looks good', outcome: 'ack' },
|
||||
{ text: 'Re-run', outcome: 'rerun' },
|
||||
{ text: 'Adjust', outcome: 'edit' },
|
||||
];
|
||||
w.notify({ title, body, deepLink, runId: data.run_id, workflowId: data.workflow_id, actions });
|
||||
}
|
||||
} catch { /* native notif optional */ }
|
||||
break;
|
||||
|
||||
case 'dashboard:browser_card_added':
|
||||
if (data.browser_card) {
|
||||
store.dispatch(addBrowserCardFromBackend(data.browser_card));
|
||||
@@ -800,6 +860,31 @@ class WebSocketManager {
|
||||
|
||||
import { WS_BASE } from '@/shared/config';
|
||||
|
||||
// Bridge native-notification button actions to workflow actions. Subscribe at module
|
||||
// import time so we never miss an early callback fired before any component mounts.
|
||||
(() => {
|
||||
try {
|
||||
const w: any = (typeof window !== 'undefined') ? (window as any).openswarm : null;
|
||||
if (!w?.onNotificationAction) return;
|
||||
w.onNotificationAction(({ outcome, runId, workflowId }: { outcome: string; runId?: string; workflowId?: string }) => {
|
||||
if (!workflowId) return;
|
||||
if (outcome === 'ack' && runId) {
|
||||
store.dispatch(ackRun(runId));
|
||||
return;
|
||||
}
|
||||
if (outcome === 'rerun') {
|
||||
store.dispatch(runWorkflowNow(workflowId));
|
||||
return;
|
||||
}
|
||||
if (outcome === 'edit' || outcome === 'open') {
|
||||
store.dispatch(addWorkflowCard({ workflowId }));
|
||||
store.dispatch(openWorkflowCard({ workflowId, view: outcome === 'edit' ? 'edit' : 'saved', editFacet: outcome === 'edit' ? 'Schedule' : undefined }));
|
||||
return;
|
||||
}
|
||||
});
|
||||
} catch { /* native notifications optional */ }
|
||||
})();
|
||||
|
||||
export const dashboardWs = new WebSocketManager(`${WS_BASE}/ws/dashboard`, { skipStreamEvents: true });
|
||||
|
||||
// Per-session high-water mark for the resume protocol. Survives across
|
||||
|
||||
Reference in New Issue
Block a user