mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 20:57:42 +02:00
[eric] workflows: port frontend slice + Workflows pages + canvas card mounting from dev
This commit is contained in:
@@ -34,10 +34,30 @@ import { useDashboardActive } from '@/shared/hooks/useDashboardActive';
|
||||
import { useOverlayScrollPassthrough } from './useOverlayScrollPassthrough';
|
||||
import { useStreamingMessage } from '@/shared/state/streamingSlice';
|
||||
import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canvasInteractionState';
|
||||
import { openWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesomeOutlined';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper components & functions (unchanged)
|
||||
// ---------------------------------------------------------------------------
|
||||
/** 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') {
|
||||
@@ -82,10 +102,7 @@ function fmtSeconds(seconds: number): string {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
|
||||
// Self-ticking elapsed-time renderer. Owns its own 1Hz interval so only
|
||||
// this leaf re-renders per second while a session is active; the rest
|
||||
// of AgentCard stays put. Memoized on `status` + `messages` so it
|
||||
// doesn't re-tick after the session goes terminal.
|
||||
/** Self-ticking elapsed-time leaf; owns its 1Hz interval so AgentCard doesn't re-render every second. */
|
||||
const ElapsedTimer: React.FC<{
|
||||
messages: Array<{ role: string; timestamp: string; elapsed_ms?: number; hidden?: boolean }>;
|
||||
status: string;
|
||||
@@ -103,26 +120,7 @@ function getAgentWorkTime(
|
||||
messages: Array<{ role: string; timestamp: string; elapsed_ms?: number; hidden?: boolean }>,
|
||||
status: string,
|
||||
): { total: number; last: number } {
|
||||
// True wall-clock duration: how long the user actually waited, from
|
||||
// their prompt to the LAST assistant/system message of that turn.
|
||||
// Covers thinking + every tool call + assistant text generation +
|
||||
// any subagent/MCP work — anything that consumed user attention.
|
||||
//
|
||||
// This is intentionally NOT the sum of `thinking.elapsed_ms` (which
|
||||
// would cover only reasoning time and miss tool execution). The
|
||||
// thinking pill in the chat already exposes reasoning-only as a
|
||||
// distinct signal; the header timer's job is to answer "how long
|
||||
// did this take?" which is a different question.
|
||||
//
|
||||
// For each user message we find the LAST adjacent assistant/system
|
||||
// message before the next user message — that's the turn boundary.
|
||||
// If the turn is still in flight (last user message has no assistant
|
||||
// reply yet AND session is running/waiting), extrapolate to now so
|
||||
// the timer ticks live.
|
||||
//
|
||||
// Hidden messages (auto-continuation prompts from MCPActivate, etc.)
|
||||
// are skipped — they're system-internal turns the user didn't see
|
||||
// and shouldn't be billed for.
|
||||
// Wall-clock turn duration (user prompt to last assistant/system msg); not thinking time. Extrapolates to now while running.
|
||||
const visible = messages.filter((m) => !m.hidden);
|
||||
let totalMs = 0;
|
||||
let lastMs = 0;
|
||||
@@ -130,8 +128,6 @@ function getAgentWorkTime(
|
||||
const msg = visible[i];
|
||||
if (msg.role !== 'user') continue;
|
||||
|
||||
// Find the bounds of this turn: from this user message to just
|
||||
// before the next user message (or end of array).
|
||||
let nextUserIdx = visible.length;
|
||||
for (let k = i + 1; k < visible.length; k++) {
|
||||
if (visible[k].role === 'user') {
|
||||
@@ -140,8 +136,6 @@ function getAgentWorkTime(
|
||||
}
|
||||
}
|
||||
|
||||
// Last assistant/system message before the next user message =
|
||||
// turn end. Walk backwards from nextUserIdx to find it.
|
||||
let turnEndMs: number | null = null;
|
||||
for (let k = nextUserIdx - 1; k > i; k--) {
|
||||
const r = visible[k].role;
|
||||
@@ -152,9 +146,7 @@ function getAgentWorkTime(
|
||||
}
|
||||
|
||||
if (turnEndMs == null) {
|
||||
// No assistant reply yet for this turn. If the session is
|
||||
// actively working, extrapolate to now so the header ticks.
|
||||
// Otherwise (terminal session, no reply): contribute 0.
|
||||
// No reply yet; extrapolate to now while running so the header ticks. Terminal sessions contribute 0.
|
||||
if (status === 'running' || status === 'waiting_approval') {
|
||||
turnEndMs = Date.now();
|
||||
} else {
|
||||
@@ -221,10 +213,6 @@ function getToolDisplayName(toolName: string): string {
|
||||
return toolName;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resize handle definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
|
||||
|
||||
const EDGE_THICKNESS = 6;
|
||||
@@ -252,19 +240,10 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
|
||||
{ dir: 'se', sx: { bottom: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface OuterProps {
|
||||
sessionId: string;
|
||||
expanded: boolean;
|
||||
// Stable getter — cards read pan/zoom on demand (drag math) instead of
|
||||
// receiving them as props. Without this, every wheel/pan tick on the
|
||||
// canvas re-rendered every card, even though the canvas root's CSS
|
||||
// transform is what actually moves them visually. Cards only need the
|
||||
// values inside drag callbacks; making it a ref-backed getter keeps
|
||||
// pan/zoom out of memo equality entirely.
|
||||
// Ref-backed getter so pan/zoom stay out of memo equality; props would re-render every card on every pan tick.
|
||||
getCanvasState: () => { panX: number; panY: number; zoom: number };
|
||||
spawnFrom?: { x: number; y: number; type?: 'branch' };
|
||||
exitTarget?: { x: number; y: number };
|
||||
@@ -316,7 +295,8 @@ const AgentCard: React.FC<Props> = ({
|
||||
const isDashboardActive = useDashboardActive();
|
||||
const hasApiKey = !!useAppSelector((s) => s.settings.data.anthropic_api_key);
|
||||
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
|
||||
// Stored value → curated picker label, with a tidy fallback for unknowns.
|
||||
const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds);
|
||||
// Curated picker label with a tidy fallback for unknowns.
|
||||
const friendlyModelLabel = useMemo(() => {
|
||||
const value = session.model;
|
||||
if (!value) return '';
|
||||
@@ -333,27 +313,18 @@ const AgentCard: React.FC<Props> = ({
|
||||
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
|
||||
|
||||
const cardBoxRef = useRef<HTMLDivElement>(null);
|
||||
// Capture isDashboardActive in a ref so the ResizeObserver callback always
|
||||
// sees the latest value without forcing the observer to re-attach when the
|
||||
// active state flips.
|
||||
// Ref so ResizeObserver sees latest value without re-attaching when active flips.
|
||||
const isDashboardActiveRef = useRef(isDashboardActive);
|
||||
useEffect(() => { isDashboardActiveRef.current = isDashboardActive; }, [isDashboardActive]);
|
||||
useEffect(() => {
|
||||
const el = cardBoxRef.current;
|
||||
if (!el || !onMeasuredHeight) return;
|
||||
// Remember the most recent height seen during a suppressed window
|
||||
// (pan/drag/zoom in progress). When the interaction ends, fire it
|
||||
// through so the layout reconciles to the truth right then.
|
||||
// Stash height during pan/drag/zoom; flush on gesture end so layout reconciles.
|
||||
let suppressedHeight: number | null = null;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
// Short-circuit when dashboard is hidden — observer stays attached so
|
||||
// the next resize after returning to the dashboard fires correctly.
|
||||
// Short-circuit when hidden; observer stays attached so the next resize on return fires correctly.
|
||||
if (!isDashboardActiveRef.current) return;
|
||||
// Short-circuit during active canvas interaction (pan/drag/wheel).
|
||||
// During those gestures we don't care about millimeter-precise card
|
||||
// heights; re-measuring on every streamed character was forcing
|
||||
// Dashboard re-renders mid-pan via setMeasuredHeightsTick. Stash
|
||||
// the latest height instead and flush on gesture end.
|
||||
// Re-measuring per streamed character mid-pan was forcing Dashboard re-renders via setMeasuredHeightsTick.
|
||||
if (isCanvasInteractionActive()) {
|
||||
for (const entry of entries) suppressedHeight = entry.contentRect.height;
|
||||
return;
|
||||
@@ -372,7 +343,6 @@ const AgentCard: React.FC<Props> = ({
|
||||
return () => { ro.disconnect(); unsub(); };
|
||||
}, [session.id, onMeasuredHeight]);
|
||||
|
||||
// ---- Glow state (for branched cards) ----
|
||||
const glowEntry = useAppSelector((s) => s.dashboardLayout.glowingAgentCards[session.id]);
|
||||
const isGlowingRedux = !!glowEntry;
|
||||
const glowFading = glowEntry?.fading ?? false;
|
||||
@@ -404,7 +374,6 @@ const AgentCard: React.FC<Props> = ({
|
||||
|
||||
const isDraft = session.status === 'draft';
|
||||
|
||||
// ---- Drag via header (pointer events) ----
|
||||
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);
|
||||
@@ -426,7 +395,6 @@ const AgentCard: React.FC<Props> = ({
|
||||
onDragStart?.(session.id, 'agent');
|
||||
}, [cardX, cardY, onDragStart, session.id, getCanvasState]);
|
||||
|
||||
// Recompute localDragPos from latest pointer + pan (shared by move handler and pan-change event)
|
||||
const recomputeDragPos = useCallback(() => {
|
||||
const ds = dragState.current;
|
||||
if (!ds || !didDrag.current) return;
|
||||
@@ -443,9 +411,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
onDragMove?.(dx, dy, clientX, clientY);
|
||||
}, [onDragMove, getCanvasState]);
|
||||
|
||||
// When pan changes during an active drag (edge-pan or wheel-zoom-while-
|
||||
// dragging), Dashboard dispatches `openswarm:canvas-pan-changed`. Only
|
||||
// active during a drag so non-dragging cards stay subscribed-to-nothing.
|
||||
// Dashboard dispatches openswarm:canvas-pan-changed during edge-pan/wheel-zoom; only subscribed while dragging.
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const onPanChange = () => {
|
||||
@@ -482,7 +448,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
dispatch(setCardSize({ sessionId: session.id, width: snapColumn.width, height: cardHeight }));
|
||||
}
|
||||
|
||||
// Snap to 24px grid (hold Shift to bypass)
|
||||
// Snap to 24px grid (Shift bypasses).
|
||||
if (!e.shiftKey) {
|
||||
finalX = Math.round(finalX / 24) * 24;
|
||||
finalY = Math.round(finalY / 24) * 24;
|
||||
@@ -500,7 +466,6 @@ const AgentCard: React.FC<Props> = ({
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}, [dispatch, session.id, onDragEnd, snapColumn, cardHeight, getCanvasState]);
|
||||
|
||||
// ---- Unified edge / corner resize ----
|
||||
const resizeRef = useRef<{
|
||||
dir: ResizeDir;
|
||||
startX: number;
|
||||
@@ -594,14 +559,10 @@ const AgentCard: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
|
||||
// Elapsed-time display owns its own 1Hz tick via <ElapsedTimer/> below;
|
||||
// we don't force-re-render the whole 1000+ line AgentCard every second
|
||||
// anymore (each card running × 1Hz = wasted reconciliation budget).
|
||||
// ElapsedTimer owns its own 1Hz tick so AgentCard doesn't re-render every second.
|
||||
|
||||
const lastMessage = session.messages[session.messages.length - 1];
|
||||
// Subscribe to this card's own streaming entry from the streaming
|
||||
// slice. Per-character mutations no longer churn the sessions dict,
|
||||
// so other cards stay stable while this one streams.
|
||||
// Subscribe to this card's own streaming entry so per-character mutations don't churn other cards.
|
||||
const streamingMessage = useStreamingMessage(session.id);
|
||||
const isStreaming = !!streamingMessage;
|
||||
const previewContent = isStreaming
|
||||
@@ -665,14 +626,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
data-select-type="agent-card"
|
||||
data-select-id={session.id}
|
||||
data-select-meta={JSON.stringify({ name: session.name || session.id, status: session.status, model: session.model, mode: session.mode })}
|
||||
// Onboarding tiebreaker: when the user has multiple agent cards open
|
||||
// (e.g. step 5 leaves the YouTube-summary agent on canvas while
|
||||
// step 6 spawns a new orchestrator), per-agent selectors like
|
||||
// chat-input need a way to identify the NEWEST card. Object.values
|
||||
// iteration order in Dashboard.tsx is keyed by session.id and not
|
||||
// monotonic by creation time, so DOM order can't be trusted.
|
||||
// ISO date parses cleanly to ms; missing values fall through to the
|
||||
// last-DOM-node fallback in resolveSelector.
|
||||
// Onboarding tiebreaker: ISO-date sorts the newest card for per-agent selectors; DOM order isn't creation order.
|
||||
data-onboarding-spawn-ms={
|
||||
session.created_at
|
||||
? new Date(session.created_at).getTime() || undefined
|
||||
@@ -688,20 +642,9 @@ const AgentCard: React.FC<Props> = ({
|
||||
}}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
// contain: streaming chat updates inside don't reflow the dashboard.
|
||||
// Skipping `paint` here because the highlighted/selected/glow
|
||||
// boxShadows legitimately extend past the card border — `paint`
|
||||
// containment would clip those visuals.
|
||||
// contain: layout style; skipping `paint` because glow boxShadows extend past card border.
|
||||
contain: 'layout style',
|
||||
// Promote each card to its own compositor layer so paint
|
||||
// invalidations (hover effects, streaming content updates,
|
||||
// highlight pulses) stay contained to that one card's layer
|
||||
// instead of forcing the canvas's GPU-promoted root layer to
|
||||
// re-paint. The performance trace showed pointer hover events
|
||||
// costing 100-200ms of pure presentation time before this,
|
||||
// because every hover-cross re-painted the entire canvas
|
||||
// composite. Costs ~card_area*4 bytes of GPU memory per card;
|
||||
// trivial on modern hardware for the dashboard's card counts.
|
||||
// Each card gets its own compositor layer; hover-cross used to cost 100-200ms PRESENTATION by re-painting the whole canvas.
|
||||
willChange: 'transform',
|
||||
width: localResize ? activeW : Math.max(cardWidth, MIN_W),
|
||||
height: localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto'),
|
||||
@@ -796,18 +739,13 @@ const AgentCard: React.FC<Props> = ({
|
||||
},
|
||||
}),
|
||||
...(!isHighlighted && !(isGlowingRedux && !glowFading) && !expanded && !isDragging && !isSelected && {
|
||||
// Hover: borderColor only. Was previously also bumping boxShadow
|
||||
// from .sm to .md, but the trace data showed pointer hover events
|
||||
// costing 120-207ms PRESENTATION because every shadow change
|
||||
// forced a full GPU re-blur of every card on the transformed
|
||||
// canvas layer. Border color is layout-free and ~free to paint.
|
||||
// Hover changes borderColor only; boxShadow changes used to cost 120-207ms PRESENTATION via GPU re-blur.
|
||||
'&:hover': {
|
||||
borderColor: hasPending ? c.status.warning : c.border.strong,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* Glow overlays for branched cards */}
|
||||
{isGlowingRedux && (
|
||||
<Box
|
||||
className="agent-card-glow-overlays"
|
||||
@@ -821,7 +759,6 @@ const AgentCard: React.FC<Props> = ({
|
||||
transition: `opacity ${GLOW_FADE_MS}ms ease-out`,
|
||||
}}
|
||||
>
|
||||
{/* Rotating conic gradient border */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
@@ -845,7 +782,6 @@ const AgentCard: React.FC<Props> = ({
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{/* Top edge shimmer */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
@@ -862,7 +798,6 @@ const AgentCard: React.FC<Props> = ({
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{/* Inner shadow overlay */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
@@ -883,7 +818,6 @@ const AgentCard: React.FC<Props> = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Resize handles: 4 edges + 4 corners */}
|
||||
{HANDLE_DEFS.map(({ dir, sx }) => (
|
||||
<Box
|
||||
key={dir}
|
||||
@@ -902,7 +836,6 @@ const AgentCard: React.FC<Props> = ({
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Selection overlay – blocks click interaction while selected, enabling drag from anywhere */}
|
||||
{isSelected && (
|
||||
<Box
|
||||
ref={scrollOverlayRef}
|
||||
@@ -923,7 +856,6 @@ const AgentCard: React.FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drag zone: header + metadata – entire region above separator is draggable */}
|
||||
<Box
|
||||
onPointerDown={handleDragPointerDown}
|
||||
onPointerMove={handleDragPointerMove}
|
||||
@@ -992,6 +924,55 @@ const AgentCard: React.FC<Props> = ({
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0, ml: 0.5 }}
|
||||
>
|
||||
{(session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2 && (
|
||||
<Tooltip title="Turn this chat into a reusable, schedulable workflow">
|
||||
<Box
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const steps = extractStepsFromSession(session);
|
||||
if (steps.length === 0) return;
|
||||
const draft: Partial<Workflow> = {
|
||||
title: session.name || 'New workflow',
|
||||
description: '',
|
||||
steps,
|
||||
source_session_id: session.id,
|
||||
dashboard_id: session.dashboard_id || null,
|
||||
model: session.model,
|
||||
mode: session.mode,
|
||||
provider: session.provider,
|
||||
};
|
||||
const tempId = `draft-${session.id}`;
|
||||
dispatch(addWorkflowCard({
|
||||
workflowId: tempId,
|
||||
sourceSessionId: session.id,
|
||||
expandedSessionIds,
|
||||
}));
|
||||
dispatch(openWorkflowCard({
|
||||
workflowId: tempId,
|
||||
sourceSessionId: session.id,
|
||||
view: 'preview',
|
||||
draft,
|
||||
}));
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
color: c.accent.primary,
|
||||
bgcolor: c.accent.primary + '12',
|
||||
border: `1px solid ${c.accent.primary}40`,
|
||||
fontSize: '0.78rem', fontWeight: 600,
|
||||
px: 1, py: 0.45,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: c.accent.primary + '22' },
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeIcon sx={{ fontSize: 14 }} />
|
||||
Make workflow
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={isDraft ? 'Remove' : 'Close chat'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -1009,7 +990,6 @@ const AgentCard: React.FC<Props> = ({
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Metadata row */}
|
||||
<Box sx={{
|
||||
display: isDraft && !expanded ? 'none' : 'flex',
|
||||
gap: 1.5,
|
||||
@@ -1033,7 +1013,6 @@ const AgentCard: React.FC<Props> = ({
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Expanded: inline chat fills remaining space */}
|
||||
{expanded && (
|
||||
<Box
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -1061,7 +1040,6 @@ const AgentCard: React.FC<Props> = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Collapsed: preview + approval */}
|
||||
{!expanded && (
|
||||
<>
|
||||
{previewContent && (
|
||||
@@ -1250,10 +1228,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
|
||||
const MemoAgentCard = React.memo(AgentCard);
|
||||
|
||||
// Self-subscribing outer: this is what Dashboard renders. Each card reads
|
||||
// only its own session + card position from Redux, so a streamDelta to
|
||||
// session A no longer disturbs B's props. Dashboard's iteration just hands
|
||||
// down sessionId + cross-card UI state (selection, drag, glow).
|
||||
/** Self-subscribing wrapper; each card reads only its own session+position so streaming to A doesn't disturb B. */
|
||||
const AgentCardOuter: React.FC<OuterProps> = (props) => {
|
||||
const session = useAppSelector((s) => s.agents.sessions[props.sessionId]);
|
||||
const cardEntry = useAppSelector((s) => s.dashboardLayout.cards[props.sessionId]);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,9 @@ import SearchIcon from '@mui/icons-material/Search';
|
||||
import { motion } from 'framer-motion';
|
||||
import ChatInput from '@/app/pages/AgentChat/ChatInput';
|
||||
import type { ContextPath } from '@/app/components/DirectoryBrowser';
|
||||
import SchedulePopover from '@/app/pages/Workflows/SchedulePopover';
|
||||
import { openWorkflowCard } from '@/shared/state/workflowsSlice';
|
||||
import { addWorkflowCard, openWorkflowsHub } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { useElementSelection } from '@/app/components/ElementSelectionContext';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
@@ -103,11 +106,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
const [mode, setMode] = useState(defaultMode || 'agent');
|
||||
const [model, setModel] = useState(defaultModel || 'sonnet');
|
||||
const [thinkingLevel, setThinkingLevel] = useState<'off' | 'low' | 'medium' | 'high' | 'auto'>(defaultThinkingLevel || 'auto');
|
||||
// Snap to the persisted Settings defaults as soon as they arrive from the
|
||||
// backend. Without the settingsLoaded guard, the effect fires against the
|
||||
// Redux initialState ('sonnet') before the real default has loaded, and
|
||||
// the settingsApplied flag then locks out the real default for the rest
|
||||
// of the session — so new chats spawn under the stale value.
|
||||
// Without settingsLoaded guard, effect fires against Redux initial 'sonnet' before real default loads, locking out the real default for the session.
|
||||
const settingsApplied = useRef(false);
|
||||
useEffect(() => {
|
||||
if (settingsLoaded && !settingsApplied.current) {
|
||||
@@ -117,9 +116,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
settingsApplied.current = true;
|
||||
}
|
||||
}, [settingsLoaded, defaultMode, defaultModel, defaultThinkingLevel]);
|
||||
// Reset to the current Settings defaults each time the toolbar reopens
|
||||
// for a new compose session, so the user's in-session model/mode picks
|
||||
// don't leak into the next new-chat draft.
|
||||
// Reset defaults on each new compose session so in-session picks don't leak into the next new-chat draft.
|
||||
const prevInputOpen = useRef(false);
|
||||
useEffect(() => {
|
||||
if (settingsLoaded && inputOpen && !prevInputOpen.current) {
|
||||
@@ -130,10 +127,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
prevInputOpen.current = inputOpen;
|
||||
}, [inputOpen, settingsLoaded, defaultMode, defaultModel, defaultThinkingLevel]);
|
||||
|
||||
// Picking a model/mode/thinking-level in the toolbar writes through to
|
||||
// the global default. Without this, the reopen-reset effect above
|
||||
// would snap back to the old default the next time the user opens the
|
||||
// toolbar, ignoring what they last picked.
|
||||
// Writes toolbar picks through to global default; otherwise the reopen-reset effect would snap back next open.
|
||||
const promoteToDefault = useCallback(<K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
|
||||
const current = store.getState().settings;
|
||||
if (!current.loaded) return;
|
||||
@@ -159,6 +153,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
const [viewSearch, setViewSearch] = useState('');
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [historyQuery, setHistoryQuery] = useState('');
|
||||
const [popoverMode, setPopoverMode] = useState<'search' | 'schedule'>('search');
|
||||
const shortcut = useAppSelector((s) => s.settings.data.new_agent_shortcut);
|
||||
const outputs = useAppSelector((s) => s.outputs.items);
|
||||
const historySearch = useAppSelector((s) => s.agents.historySearch);
|
||||
@@ -390,6 +385,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
const placeholderItems: Array<{ icon: typeof StickyNote2OutlinedIcon; label: string; sub: string }> = [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<MotionBox
|
||||
ref={containerRef}
|
||||
layout
|
||||
@@ -397,23 +393,20 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
// Drop toolbar card chrome when popover is open so we don't double-card; popover supplies its own surface.
|
||||
background: historyOpen ? 'transparent' : c.bg.surface,
|
||||
border: historyOpen ? '1px solid transparent' : `1px solid ${c.border.subtle}`,
|
||||
borderRadius: `${c.radius.xl}px`,
|
||||
boxShadow: c.shadow.lg,
|
||||
boxShadow: historyOpen ? 'none' : c.shadow.lg,
|
||||
padding: isExpanded ? '6px' : '5px',
|
||||
userSelect: 'none' as const,
|
||||
overflow: inputOpen || newAgentBounce ? 'visible' : 'hidden',
|
||||
width: viewPickerOpen ? 580 : isExpanded ? 540 : undefined,
|
||||
overflow: inputOpen || newAgentBounce || historyOpen ? 'visible' : 'hidden',
|
||||
// historyOpen: width owned by SchedulePopover; leave undefined so framer-motion measures intrinsic size.
|
||||
width: viewPickerOpen ? 580 : historyOpen ? undefined : isExpanded ? 540 : undefined,
|
||||
}}
|
||||
>
|
||||
{inputOpen ? (
|
||||
// data-onboarding-scope="dock" lets the AC's per-agent-selector
|
||||
// resolver prefer this chat input (the new-agent dock that
|
||||
// appears after clicking +) over any existing agent-card's
|
||||
// chat input. Without this, AC would route to the most
|
||||
// recently-spawned agent-card, which is usually the wrong
|
||||
// target on step 5/6 (where the "new agent" is the dock draft).
|
||||
// data-onboarding-scope="dock" makes AC's per-agent resolver prefer this dock chat input over existing agent cards.
|
||||
<div
|
||||
data-onboarding-scope="dock"
|
||||
style={{ width: '100%', minHeight: 56, paddingBottom: 0, marginBottom: -4 }}
|
||||
@@ -433,97 +426,31 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
</div>
|
||||
) : historyOpen ? (
|
||||
<div style={{ width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1 }}>
|
||||
<SearchIcon sx={{ fontSize: 18, color: c.text.muted }} />
|
||||
<InputBase
|
||||
inputRef={historyInputRef}
|
||||
value={historyQuery}
|
||||
onChange={(e) => setHistoryQuery(e.target.value)}
|
||||
placeholder="Search past chats..."
|
||||
sx={{
|
||||
flex: 1,
|
||||
fontSize: '0.85rem',
|
||||
color: c.text.primary,
|
||||
fontFamily: c.font.sans,
|
||||
'& input::placeholder': { color: c.text.ghost, opacity: 1 },
|
||||
}}
|
||||
/>
|
||||
{historySearch.loading && historySearch.results.length === 0 && (
|
||||
<CircularProgress size={16} sx={{ color: c.text.muted }} />
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
ref={historyListRef}
|
||||
onScroll={handleHistoryScroll}
|
||||
sx={{
|
||||
maxHeight: 320,
|
||||
overflow: 'auto',
|
||||
borderTop: `1px solid ${c.border.subtle}`,
|
||||
'&::-webkit-scrollbar': { width: 4 },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 2 },
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${c.border.medium} transparent`,
|
||||
<SchedulePopover
|
||||
mode={popoverMode}
|
||||
onModeChange={setPopoverMode}
|
||||
historyResults={historySearch.results.map((e) => ({ id: e.id, name: e.name, closed_at: e.closed_at }))}
|
||||
historyLoading={historySearch.loading}
|
||||
historyQuery={historyQuery}
|
||||
onHistoryQueryChange={setHistoryQuery}
|
||||
onHistorySelect={handleHistorySelect}
|
||||
onNewChat={() => { handleCloseHistory(); onNewAgent(); }}
|
||||
onWorkflowSelect={(wid) => {
|
||||
dispatch(addWorkflowCard({ workflowId: wid }));
|
||||
dispatch(openWorkflowCard({
|
||||
workflowId: wid,
|
||||
view: 'saved',
|
||||
}));
|
||||
handleCloseHistory();
|
||||
}}
|
||||
>
|
||||
{historySearch.results.length === 0 && !historySearch.loading ? (
|
||||
<Box sx={{ px: 2, py: 3, textAlign: 'center' }}>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.text.muted }}>
|
||||
{historyQuery ? 'No matching chats' : 'No chat history yet'}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{historySearch.results.map((entry) => (
|
||||
<Box
|
||||
key={entry.id}
|
||||
onClick={() => handleHistorySelect(entry.id)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1.5,
|
||||
px: 1.5,
|
||||
py: 0.9,
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.1s',
|
||||
'&:hover': { bgcolor: c.bg.elevated },
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: 500,
|
||||
color: c.text.primary,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{entry.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.7rem',
|
||||
color: c.text.ghost,
|
||||
flexShrink: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{formatRelativeTime(entry.closed_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{historySearch.loading && historySearch.results.length > 0 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1.5 }}>
|
||||
<CircularProgress size={16} sx={{ color: c.text.muted }} />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
onExpand={() => {
|
||||
// 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%' }}>
|
||||
@@ -859,6 +786,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
</div>
|
||||
)}
|
||||
</MotionBox>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useRef, useEffect, RefObject } from 'react';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition, WorkflowCardPosition } from '@/shared/state/dashboardLayoutSlice';
|
||||
|
||||
export type CardType = 'agent' | 'view' | 'browser' | 'note';
|
||||
export type CardType = 'agent' | 'view' | 'browser' | 'note' | 'workflow';
|
||||
|
||||
export interface SelectedCard {
|
||||
id: string;
|
||||
@@ -42,6 +42,7 @@ export function useDashboardSelection(
|
||||
viewCards: Record<string, ViewCardPosition>,
|
||||
browserCards: Record<string, BrowserCardPosition> = {},
|
||||
notes: Record<string, NotePosition> = {},
|
||||
workflowCards: Record<string, WorkflowCardPosition> = {},
|
||||
) {
|
||||
const [selectedIds, setSelectedIds] = useState<Map<string, CardType>>(new Map());
|
||||
const [marquee, setMarquee] = useState<MarqueeRect | null>(null);
|
||||
@@ -149,6 +150,19 @@ export function useDashboardSelection(
|
||||
}
|
||||
}
|
||||
|
||||
for (const wc of Object.values(workflowCards)) {
|
||||
if (
|
||||
rectsIntersect(rect, {
|
||||
x: wc.x,
|
||||
y: wc.y,
|
||||
width: wc.width,
|
||||
height: wc.height,
|
||||
})
|
||||
) {
|
||||
intersecting.set(wc.workflow_id, 'workflow');
|
||||
}
|
||||
}
|
||||
|
||||
if (shiftKey) {
|
||||
const base = selectionBeforeMarqueeRef.current;
|
||||
const next = new Map(base);
|
||||
@@ -164,7 +178,7 @@ export function useDashboardSelection(
|
||||
|
||||
return intersecting;
|
||||
},
|
||||
[cards, viewCards, browserCards, notes],
|
||||
[cards, viewCards, browserCards, notes, workflowCards],
|
||||
);
|
||||
|
||||
const handleCanvasMouseDown = useCallback(
|
||||
@@ -191,8 +205,7 @@ export function useDashboardSelection(
|
||||
if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
|
||||
isDraggingMarqueeRef.current = true;
|
||||
document.body.style.userSelect = 'none';
|
||||
// Disable pointer events on browser webviews/iframes for the
|
||||
// duration of the drag so the cursor passes through them.
|
||||
// Disable pointer events on webviews/iframes during drag so the cursor passes through.
|
||||
document.body.classList.add('dashboard-marquee-active');
|
||||
}
|
||||
|
||||
@@ -242,14 +255,7 @@ export function useDashboardSelection(
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [deselectAll]);
|
||||
|
||||
// Inject (once) a global CSS rule that makes browser webviews and iframes
|
||||
// transparent to mouse events while a marquee drag is active. Without this,
|
||||
// the Electron <webview> hit-tests the cursor at the OS level — when the
|
||||
// cursor lands on an interactable element inside the browser (button,
|
||||
// link, text), the webview steals the cursor and the marquee drag visually
|
||||
// freezes until the cursor escapes. Setting `pointer-events: none` makes
|
||||
// the cursor pass straight through, so the dashboard's mousemove handler
|
||||
// continues to fire and the marquee keeps growing smoothly.
|
||||
// One-time CSS: pointer-events:none on webviews/iframes during marquee, so Electron's OS hit-test doesn't steal the cursor mid-drag.
|
||||
useEffect(() => {
|
||||
const id = 'dashboard-marquee-style';
|
||||
if (document.getElementById(id)) 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,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,429 @@
|
||||
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 }));
|
||||
dispatch(openWorkflowCard({ workflowId: ctxMenu.workflow.id, view: 'edit', editFacet: 'Schedule' }));
|
||||
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,231 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export default function SchedulePopover({
|
||||
mode, onModeChange, historyResults, historyLoading, historyQuery, onHistoryQueryChange,
|
||||
onHistorySelect, onNewChat, onWorkflowSelect, onExpand, historyScrollRef, onHistoryScroll,
|
||||
}: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const [calendarView, setCalendarView] = useState<'Week' | 'Month' | 'List'>('Week');
|
||||
const [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 OUTSIDE the content card (Figma image #30) */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 0.5 }}>
|
||||
<ModeChip label="Search" icon={<SearchIcon sx={{ fontSize: 14 }} />} active={mode === 'search'} onClick={() => onModeChange('search')} />
|
||||
<ModeChip label="Schedule" icon={<CalendarMonthIcon sx={{ fontSize: 14 }} />} active={mode === 'schedule'} onClick={() => onModeChange('schedule')} />
|
||||
</Box>
|
||||
|
||||
{/* Content card — separately bordered/rounded, like image #30.
|
||||
Inner content crossfades on tab switch so search↔schedule isn't
|
||||
a jarring jump. Outer card stays fixed-size (W×H) so the toolbar
|
||||
doesn't reflow. */}
|
||||
<Box sx={{
|
||||
width: '100%',
|
||||
height: CONTENT_H,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
}}>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={mode}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12, ease: 'easeOut' }}
|
||||
style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{mode === 'search' && (
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, flexShrink: 0 }}>
|
||||
<SearchIcon sx={{ fontSize: 18, color: c.text.muted }} />
|
||||
<InputBase
|
||||
value={historyQuery}
|
||||
onChange={(e) => onHistoryQueryChange(e.target.value)}
|
||||
placeholder="Search past chats..."
|
||||
sx={{ flex: 1, fontSize: '0.85rem', color: c.text.primary, '& input::placeholder': { color: c.text.ghost, opacity: 1 } }}
|
||||
/>
|
||||
<Box onClick={onNewChat} role="button" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: '0.78rem', fontWeight: 500, color: c.text.secondary, px: 1, py: 0.45, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated } }}>
|
||||
<AddIcon sx={{ fontSize: 12 }} />
|
||||
New
|
||||
</Box>
|
||||
</Box>
|
||||
<Box ref={historyScrollRef} onScroll={onHistoryScroll} sx={{ flex: 1, overflowY: 'auto', borderTop: `1px solid ${c.border.subtle}` }}>
|
||||
{historyResults.length === 0 && !historyLoading && (
|
||||
<Typography sx={{ px: 1.5, py: 2.5, fontSize: '0.82rem', color: c.text.muted, textAlign: 'center' }}>{historyQuery ? 'No matching chats' : 'No chat history yet'}</Typography>
|
||||
)}
|
||||
{historyResults.map((entry) => {
|
||||
const 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,210 @@
|
||||
// Vertical step list with connector + optional live-fill during a run +
|
||||
// optional auto-icon per step + optional duration estimate per step.
|
||||
// Used by both the Preview (draft) view and the Saved view so the two
|
||||
// stay visually consistent.
|
||||
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import TextareaAutosize from '@mui/material/TextareaAutosize';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import type { Workflow, WorkflowRun } from '@/shared/state/workflowsSlice';
|
||||
import { stepIconFor, estimateStepDuration } from './workflowVisuals';
|
||||
|
||||
interface Props {
|
||||
workflow?: Workflow | null;
|
||||
steps: Workflow['steps'];
|
||||
runs?: WorkflowRun[];
|
||||
// Pass the active run id to fill the connector progressively as the
|
||||
// workflow streams. Currently estimated by elapsed/expected; once
|
||||
// per-step telemetry ships, swap to a real step-index signal.
|
||||
activeRunId?: string | null;
|
||||
// Subtle frame around each step (used by Preview's edit-mode look). The
|
||||
// Saved view turns this off for a quieter read.
|
||||
framed?: boolean;
|
||||
// Callback when a step row is edited inline; only useful in Preview.
|
||||
onChangeStep?: (idx: number, text: string) => void;
|
||||
// Callback when the trash icon next to a step is clicked. Pairs with
|
||||
// onAddStep on the parent. Provide both when editing; omit for read-only.
|
||||
onDeleteStep?: (idx: number) => void;
|
||||
onAddStep?: () => void;
|
||||
}
|
||||
|
||||
const CIRCLE_SIZE = 24;
|
||||
// Vertical connector lives on the inner edge of the circle column; its
|
||||
// x-offset matches CIRCLE_SIZE/2 so it bisects the numbered circles.
|
||||
const CONNECTOR_X = CIRCLE_SIZE / 2;
|
||||
|
||||
export default function StepList({ workflow, steps, runs, activeRunId, framed, onChangeStep }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const hasSteps = steps && steps.length > 0;
|
||||
if (!hasSteps) return null;
|
||||
|
||||
// Determine "current step" for live-fill. We don't have per-step
|
||||
// telemetry yet, so estimate via elapsed/expected ratio if a run is
|
||||
// active, otherwise leave it null (no fill).
|
||||
const activeStepIdx = useActiveStepIdx(steps.length, runs, activeRunId);
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative', pl: 0, mt: 0.25 }}>
|
||||
{/* Connector spine. SVG so the live-fill segment can clip cleanly. */}
|
||||
{steps.length > 1 && (
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: CONNECTOR_X - 0.5,
|
||||
top: CIRCLE_SIZE * 0.5,
|
||||
bottom: CIRCLE_SIZE * 0.5,
|
||||
width: 1,
|
||||
bgcolor: c.border.medium,
|
||||
opacity: 0.65,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{steps.length > 1 && activeStepIdx !== null && (
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: CONNECTOR_X - 1,
|
||||
top: CIRCLE_SIZE * 0.5,
|
||||
// Progress = (active+1)/total, capped at total-1 so the fill
|
||||
// never overshoots the bottom circle.
|
||||
height: `calc((100% - ${CIRCLE_SIZE}px) * ${Math.min(steps.length - 1, activeStepIdx) / (steps.length - 1)})`,
|
||||
width: 2,
|
||||
bgcolor: c.accent.primary,
|
||||
transition: 'height 0.4s ease-out',
|
||||
boxShadow: `0 0 6px ${c.accent.primary}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.85 }}>
|
||||
{steps.map((s, idx) => {
|
||||
const Icon = stepIconFor(s.text || '');
|
||||
const duration = workflow ? estimateStepDuration(workflow, runs, idx) : null;
|
||||
const isActive = activeStepIdx === idx;
|
||||
const isPast = activeStepIdx !== null && idx < activeStepIdx;
|
||||
// Target #54: step 1 always gets the framed-box treatment so
|
||||
// the eye lands on it (it reads as the "entry point" of the
|
||||
// workflow), steps 2+ stay plain text. The disc fill follows
|
||||
// the live run: active step gets the solid accent disc; past
|
||||
// steps a tinted disc; the rest a quiet outlined circle. When
|
||||
// no run is in flight, nothing is "active" so all discs stay
|
||||
// outlined, including step 1.
|
||||
const firstStep = idx === 0;
|
||||
// All steps look identical when framed; the orange disc on
|
||||
// step 1 already does the "entry point" signaling. Singling
|
||||
// out step 1 made 2+ read as static text.
|
||||
const frameThis = framed;
|
||||
const primary = (framed && firstStep) || isActive;
|
||||
return (
|
||||
<Box key={s.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25, position: 'relative' }}>
|
||||
<Box sx={{
|
||||
width: CIRCLE_SIZE, height: CIRCLE_SIZE, borderRadius: '50%',
|
||||
border: `1px solid ${primary || isPast ? c.accent.primary : c.border.medium}`,
|
||||
bgcolor: primary ? c.accent.primary : isPast ? c.accent.primary + '22' : c.bg.surface,
|
||||
color: primary ? '#fff' : isPast ? c.accent.primary : c.text.muted,
|
||||
fontSize: '0.74rem', fontWeight: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
position: 'relative', zIndex: 1,
|
||||
lineHeight: 1,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
transition: 'background 0.25s ease, color 0.25s ease',
|
||||
}}>
|
||||
{Icon ? <Icon sx={{ fontSize: 13 }} /> : (idx + 1)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
// Hover + focus give 2+ steps a visible edge so the user
|
||||
// discovers they're editable. Step 1 already shows a
|
||||
// permanent frame; this just makes the rest discoverable.
|
||||
'& textarea:hover': {
|
||||
borderColor: `${c.border.medium} !important`,
|
||||
background: `${c.bg.surface} !important`,
|
||||
},
|
||||
'& textarea:focus': {
|
||||
borderColor: `${c.accent.primary} !important`,
|
||||
background: `${c.bg.surface} !important`,
|
||||
},
|
||||
}}>
|
||||
{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: frameThis ? `1px solid ${c.border.medium}` : '1px solid transparent',
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
background: frameThis ? 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={{
|
||||
fontSize: '0.92rem', color: c.text.primary,
|
||||
border: frameThis ? `1px solid ${c.border.medium}` : 'none',
|
||||
borderRadius: frameThis ? `${c.radius.md}px` : 0,
|
||||
bgcolor: frameThis ? c.bg.surface : 'transparent',
|
||||
px: frameThis ? 1.25 : 0, py: frameThis ? 0.75 : 0.1,
|
||||
lineHeight: 1.45,
|
||||
}}>
|
||||
{s.text}
|
||||
</Box>
|
||||
)}
|
||||
{duration && (
|
||||
<Tooltip title="Estimated from recent successful runs (whole-run duration divided by step count).">
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, mt: 0.25, ml: framed ? 1.25 : 0.5 }}>
|
||||
~{duration}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Synthesize an "active step" index from the active run's elapsed time
|
||||
// vs the historical average run duration. Doesn't pretend to be exact;
|
||||
// good enough for the user to see the progress bar advance during a
|
||||
// long workflow. Returns null when no live run.
|
||||
function useActiveStepIdx(stepCount: number, runs: WorkflowRun[] | undefined, activeRunId: string | null | undefined): number | null {
|
||||
const [tick, setTick] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
if (!activeRunId) return;
|
||||
const id = window.setInterval(() => setTick((t) => (t + 1) % 1000000), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [activeRunId]);
|
||||
void tick;
|
||||
if (!activeRunId || !runs) return null;
|
||||
const active = runs.find((r) => r.id === activeRunId && r.status === 'running');
|
||||
if (!active) return null;
|
||||
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) {
|
||||
// No history: jump to the middle step so the bar advances visibly.
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import EditIcon from '@mui/icons-material/EditOutlined';
|
||||
import HistoryIcon from '@mui/icons-material/HistoryRounded';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrowRounded';
|
||||
import ScheduleIcon from '@mui/icons-material/ScheduleRounded';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import 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 { StatusDot, RunSparkline, LastFiredHint, isStaleSinceLastRun } from './workflowVisuals';
|
||||
import { store } from '@/shared/state/store';
|
||||
|
||||
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';
|
||||
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;
|
||||
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
|
||||
data-select-type="workflow-card"
|
||||
data-select-id={workflowId}
|
||||
data-select-meta={JSON.stringify({ name: title })}
|
||||
onPointerDownCapture={() => onBringToFront?.(workflowId, 'workflow')}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (justDraggedRef.current) return;
|
||||
onCardSelect?.(workflowId, 'workflow', e.shiftKey);
|
||||
}}
|
||||
onDoubleClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDoubleClick?.(workflowId, 'workflow');
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
contain: 'layout style',
|
||||
willChange: 'transform',
|
||||
left: displayX,
|
||||
top: displayY,
|
||||
width: displayW,
|
||||
height: displayH,
|
||||
borderRadius: '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: 700, fontSize: '1rem', color: c.text.primary,
|
||||
letterSpacing: '-0.005em',
|
||||
'& input::placeholder': { color: c.text.muted, opacity: 1 },
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Typography sx={{ flex: 1, fontWeight: 700, fontSize: '1rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', letterSpacing: '-0.005em' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
{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 =====
|
||||
Target #54 puts Run / Edit / History flush left and "Schedule
|
||||
this task" flush right on the SAME row. We use justifyContent
|
||||
+ a flex spacer instead of wrap, so narrow widths shrink the
|
||||
action group rather than dropping Schedule onto a second line.
|
||||
Run is the only accent-colored button (it's the verb users
|
||||
actually do) but its border weight matches the siblings. */}
|
||||
{isDraft && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 2, pb: 1.25, pt: 0, flexShrink: 0, opacity: 0.45, pointerEvents: 'none' }}>
|
||||
<TabBtn label="Run" icon={<PlayArrowIcon sx={{ fontSize: 16 }} />} active={false} accent onClick={() => {}} />
|
||||
<TabBtn label="Edit" icon={<EditIcon sx={{ fontSize: 16 }} />} active={false} onClick={() => {}} />
|
||||
<TabBtn label="History" icon={<HistoryIcon sx={{ fontSize: 16 }} />} active={false} onClick={() => {}} />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: '0.82rem', fontWeight: 500, color: c.text.secondary }}>
|
||||
<ScheduleIcon sx={{ fontSize: 14 }} />
|
||||
Schedule this task
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{!isDraft && workflow && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 2, pb: 1.25, pt: 0, flexShrink: 0 }}>
|
||||
<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);
|
||||
dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }));
|
||||
try {
|
||||
const result = await dispatch(runWorkflowNow(workflow.id));
|
||||
await dispatch(fetchRuns(workflow.id));
|
||||
// Detect skipped manual runs so the user gets a real
|
||||
// explanation instead of a silent button-flicker. The
|
||||
// most common skip today is the monthly cost cap.
|
||||
if (runWorkflowNow.fulfilled.match(result)) {
|
||||
const payload = result.payload;
|
||||
if (payload.status === 'skipped' && payload.error) {
|
||||
setRunToast(`Run skipped: ${payload.error}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Hold the "Starting…" label briefly so the user sees the
|
||||
// state change even on fast runs. Without this the button
|
||||
// flickers and feels like nothing happened.
|
||||
setTimeout(() => setRunStarting(false), 600);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TabBtn
|
||||
label="Edit"
|
||||
icon={<EditIcon sx={{ fontSize: 16 }} />}
|
||||
active={card.view === 'edit'}
|
||||
dot={editDirty}
|
||||
dotTooltip="You have unsaved changes in this tab."
|
||||
onClick={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'edit', editFacet: card.editFacet || 'General' } }))}
|
||||
/>
|
||||
<TabBtn
|
||||
label="History"
|
||||
icon={<HistoryIcon sx={{ fontSize: 16 }} />}
|
||||
active={card.view === 'history' || card.view === 'history_detail'}
|
||||
onClick={() => dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history' } }))}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{!workflow.schedule.enabled && (
|
||||
<Box
|
||||
role="button"
|
||||
data-no-drag
|
||||
onClick={() => {
|
||||
const sched = workflow.schedule;
|
||||
const next = {
|
||||
...sched,
|
||||
enabled: true,
|
||||
repeat_unit: sched.repeat_unit || 'day',
|
||||
repeat_every: sched.repeat_every || 1,
|
||||
hour: sched.hour || 9,
|
||||
minute: sched.minute || 0,
|
||||
};
|
||||
dispatch(updateWorkflow({
|
||||
id: workflow.id,
|
||||
patch: { schedule: next as any },
|
||||
ifMatch: workflow.updated_at || null,
|
||||
}));
|
||||
dispatch(updateWorkflowCard({ workflowId, patch: { view: 'edit', editFacet: 'Schedule' } }));
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.4,
|
||||
fontSize: '0.82rem', fontWeight: 500,
|
||||
color: c.text.secondary,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { color: c.accent.primary },
|
||||
}}>
|
||||
<ScheduleIcon sx={{ fontSize: 14 }} />
|
||||
Schedule this task
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ===== Body — view-specific subview =====
|
||||
Crossfades between Run/Edit/History tabs so the swap doesn't
|
||||
read as a "jump". Outer box is the scrollable viewport; the
|
||||
animated child changes per `card.view`. */}
|
||||
<Box ref={bodyScrollRef} data-no-drag sx={{ flex: 1, p: 2, overflowY: 'auto', minHeight: 0, position: 'relative', overscrollBehavior: 'contain', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* 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' } }))}
|
||||
/>
|
||||
)}
|
||||
</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 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,625 @@
|
||||
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 { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import {
|
||||
closeWorkflowCard,
|
||||
createWorkflow,
|
||||
updateWorkflow,
|
||||
updateWorkflowCard,
|
||||
type Workflow,
|
||||
type WorkflowRun,
|
||||
} from '@/shared/state/workflowsSlice';
|
||||
import { removeWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
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) || '';
|
||||
// Track step text edits locally so the textarea stays uncontrolled-ish
|
||||
// (no remote round-trip on every keystroke). On Save we pass the
|
||||
// edited values through.
|
||||
const [editedSteps, setEditedSteps] = useState<Workflow['steps'] | null>(null);
|
||||
const liveSteps = editedSteps || steps;
|
||||
|
||||
const onSave = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await dispatch(createWorkflow({
|
||||
title,
|
||||
description,
|
||||
steps: liveSteps.map((s) => ({ id: s.id, text: s.text })),
|
||||
source_session_id: sourceSessionId,
|
||||
use_synced_prompt: true,
|
||||
} as Partial<Workflow>));
|
||||
const wf = (result as unknown as { payload: Workflow }).payload;
|
||||
if (wf?.id) onSaved(wf);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, dispatch, title, description, liveSteps, sourceSessionId, onSaved]);
|
||||
|
||||
const onDiscard = useCallback(() => {
|
||||
dispatch(closeWorkflowCard(workflowId));
|
||||
dispatch(removeWorkflowCard(workflowId));
|
||||
}, [dispatch, workflowId]);
|
||||
|
||||
const onChangeDescription = useCallback((value: string) => {
|
||||
dispatch(updateWorkflowCard({ workflowId, patch: { draft: { ...liveDraft, description: value } } }));
|
||||
}, [dispatch, workflowId, liveDraft]);
|
||||
|
||||
const onChangeStep = useCallback((idx: number, value: string) => {
|
||||
const next = (liveSteps || []).slice();
|
||||
if (!next[idx]) return;
|
||||
next[idx] = { ...next[idx], text: value };
|
||||
setEditedSteps(next);
|
||||
}, [liveSteps]);
|
||||
|
||||
return (
|
||||
// PreviewView visually matches SavedView (target image #107): same
|
||||
// Scheduled / Permissions prose, same framed step boxes. Title +
|
||||
// description come from the AI gen at save time; the user doesn't
|
||||
// type a description here. Discard/Save sits in the bottom-right.
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
|
||||
<Typography sx={{ fontSize: '0.88rem', fontWeight: 700, color: c.text.primary }}>Scheduled:</Typography>
|
||||
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary }}>Not scheduled</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
|
||||
<Typography sx={{ fontSize: '0.88rem', fontWeight: 700, color: c.text.primary }}>Permissions:</Typography>
|
||||
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary }}>Notify me in Open Swarm</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{description && (
|
||||
<Typography sx={{ fontSize: '0.92rem', color: c.text.secondary, lineHeight: 1.55, mt: 0.5 }}>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
<StepList steps={liveSteps} framed onChangeStep={onChangeStep} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 1, mt: 'auto' }}>
|
||||
<ActionBtn label="Discard" tone="danger" icon="trash" onClick={onDiscard} />
|
||||
<ActionBtn label="Save" tone="success" icon="check" onClick={onSave} disabled={busy} />
|
||||
</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 ? `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}`;
|
||||
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) {
|
||||
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
return `Every ${labels[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();
|
||||
const connectionMode = useAppSelector((s) => (s as { settings?: { data?: { connection_mode?: string } } }).settings?.data?.connection_mode);
|
||||
void c; void connectionMode;
|
||||
|
||||
// All steps editable inline. Each keystroke updates a local override
|
||||
// map; Discard/Save surface as soon as any step diverges from the
|
||||
// saved value. On Save we PATCH the full steps array, preserving ids.
|
||||
const [localSteps, setLocalSteps] = useState<Record<number, string>>({});
|
||||
const [savingFirst, setSavingFirst] = useState(false);
|
||||
const firstStepDirty = useMemo(() => {
|
||||
for (const k of Object.keys(localSteps)) {
|
||||
const idx = Number(k);
|
||||
const saved = steps[idx]?.text ?? '';
|
||||
if (localSteps[idx] !== saved) return true;
|
||||
}
|
||||
return false;
|
||||
}, [localSteps, steps]);
|
||||
const editableSteps = useMemo(() => {
|
||||
if (!firstStepDirty) return steps;
|
||||
return steps.map((s, idx) => (idx in localSteps ? { ...s, text: localSteps[idx] } : s));
|
||||
}, [firstStepDirty, steps, localSteps]);
|
||||
const onChangeFirstStep = useCallback((idx: number, text: string) => {
|
||||
setLocalSteps((prev) => ({ ...prev, [idx]: text }));
|
||||
}, []);
|
||||
const onSaveFirstStep = useCallback(async () => {
|
||||
if (!firstStepDirty || savingFirst) return;
|
||||
setSavingFirst(true);
|
||||
try {
|
||||
const nextSteps = steps.map((s, idx) => (idx in localSteps ? { ...s, text: localSteps[idx] } : s));
|
||||
await dispatch(updateWorkflow({
|
||||
id: workflow.id,
|
||||
patch: { steps: nextSteps },
|
||||
ifMatch: workflow.updated_at || null,
|
||||
}));
|
||||
setLocalSteps({});
|
||||
} finally {
|
||||
setSavingFirst(false);
|
||||
}
|
||||
}, [firstStepDirty, savingFirst, steps, localSteps, dispatch, workflow.id, workflow.updated_at]);
|
||||
const onDiscardFirstStep = useCallback(() => setLocalSteps({}), []);
|
||||
// Habit suggestion: 3+ manual runs in the last 7 days on a workflow
|
||||
// that isn't scheduled → quietly offer to schedule it. One click flips
|
||||
// the schedule on at the most common time. Auto-disappears once the
|
||||
// user enables a schedule.
|
||||
const habitSuggestion = useMemo(() => {
|
||||
if (workflow.schedule.enabled) return null;
|
||||
if (!runs || runs.length < 3) return null;
|
||||
const cutoff = Date.now() - 7 * 86400000;
|
||||
const recent = runs.filter((r) => r.triggered_by === 'manual' && new Date(r.started_at).getTime() >= cutoff);
|
||||
if (recent.length < 3) return null;
|
||||
// Pick the most common hour-of-day as the seed.
|
||||
const hourCounts: Record<number, number> = {};
|
||||
for (const r of recent) {
|
||||
const h = new Date(r.started_at).getHours();
|
||||
hourCounts[h] = (hourCounts[h] || 0) + 1;
|
||||
}
|
||||
const sorted = Object.entries(hourCounts).sort((a, b) => b[1] - a[1]);
|
||||
const topHour = Number(sorted[0][0]);
|
||||
const formatted = topHour < 12 ? `${topHour === 0 ? 12 : topHour}am` : `${topHour === 12 ? 12 : topHour - 12}pm`;
|
||||
return { hour: topHour, label: `daily ${formatted}`, count: recent.length };
|
||||
}, [workflow.schedule.enabled, runs]);
|
||||
const enableHabit = useCallback(() => {
|
||||
if (!habitSuggestion) return;
|
||||
dispatch(updateWorkflow({
|
||||
id: workflow.id,
|
||||
patch: { schedule: { ...workflow.schedule, enabled: true, repeat_unit: 'day', repeat_every: 1, hour: habitSuggestion.hour, minute: 0 } as any },
|
||||
ifMatch: workflow.updated_at || null,
|
||||
}));
|
||||
}, [habitSuggestion, dispatch, workflow.id, workflow.schedule, workflow.updated_at]);
|
||||
// Audit trigger lazy-loads the edit log; only show it when the
|
||||
// workflow has actually been edited. Skips the noisy "0 edits" link
|
||||
// on freshly created cards. We trigger the fetch on mount once so the
|
||||
// "edits"/no-edits decision is honest by the time the user reads.
|
||||
// minHeight: 100% lets the bottom-right cluster pin to the bottom of
|
||||
// the card body via mt:auto below.
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
{/* Prose lines per target #54: "Scheduled:" + "Permissions:".
|
||||
Reads like a sentence the user can skim instead of a pill row
|
||||
that needs hovering to decode. Cost stays as a small inline
|
||||
chip on the right when there's anything to say. */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
<Typography sx={{ fontSize: '0.88rem', fontWeight: 700, color: c.text.primary }}>Scheduled:</Typography>
|
||||
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary }}>{describeSchedule(workflow)}</Typography>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{workflow.cost_estimate && workflow.cost_estimate.fires_per_month > 0 && (
|
||||
<CostChip workflow={workflow} connectionMode={connectionMode} />
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
|
||||
<Typography sx={{ fontSize: '0.88rem', fontWeight: 700, color: c.text.primary }}>Permissions:</Typography>
|
||||
<Typography sx={{ fontSize: '0.88rem', color: c.text.secondary }}>{describePermissions(workflow)}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<StreakBadgeRow runs={runs} />
|
||||
{habitSuggestion && (
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 0.75,
|
||||
px: 1, py: 0.5,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: c.accent.primary + '14',
|
||||
border: `1px solid ${c.accent.primary}40`,
|
||||
}}>
|
||||
<Typography sx={{ flex: 1, fontSize: '0.78rem', color: c.text.primary }}>
|
||||
You've run this {habitSuggestion.count}× this week. Schedule it {habitSuggestion.label}?
|
||||
</Typography>
|
||||
<Box onClick={enableHabit} role="button" sx={{ fontSize: '0.74rem', fontWeight: 700, color: c.accent.primary, cursor: 'pointer', px: 0.5, '&:hover': { textDecoration: 'underline' } }}>
|
||||
Yes
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{workflow.description && (
|
||||
<Typography sx={{ fontSize: '0.92rem', color: c.text.secondary, lineHeight: 1.55, mt: 0.5 }}>
|
||||
{workflow.description}
|
||||
</Typography>
|
||||
)}
|
||||
<StepList
|
||||
workflow={workflow}
|
||||
steps={editableSteps}
|
||||
runs={runs}
|
||||
activeRunId={activeRunId}
|
||||
framed
|
||||
onChangeStep={onChangeFirstStep}
|
||||
/>
|
||||
{/* Bottom-right cluster matching target image #63. Discard + Save
|
||||
only surface when the user has actually edited the first step
|
||||
inline; otherwise we don't crowd the card with idle buttons. */}
|
||||
{firstStepDirty ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 1, mt: 'auto' }}>
|
||||
<ActionBtn label="Discard" tone="danger" icon="trash" onClick={onDiscardFirstStep} />
|
||||
<ActionBtn label={savingFirst ? 'Saving…' : 'Save'} tone="success" icon="check" disabled={savingFirst} onClick={onSaveFirstStep} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 'auto' }}>
|
||||
<AuditTraceLink workflowId={workflow.id} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 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,149 @@
|
||||
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 { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { updateWorkflow, 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 }}>
|
||||
<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', editFacet: 'Schedule' }));
|
||||
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;
|
||||
}
|
||||
@@ -10,20 +10,31 @@ export function useKeyboardShortcuts() {
|
||||
|
||||
const handler = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const isInput =
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable;
|
||||
const target = e.target as HTMLElement | null;
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
// Double-guard: e.target AND document.activeElement. A bare-letter
|
||||
// shortcut would otherwise fire if focus is on a wrapper Box and the
|
||||
// child input never received it, kicking the user out mid-type.
|
||||
const isInputLike = (el: HTMLElement | null) =>
|
||||
!!el && (
|
||||
el.tagName === 'INPUT' ||
|
||||
el.tagName === 'TEXTAREA' ||
|
||||
el.isContentEditable ||
|
||||
!!el.closest('input, textarea, [contenteditable="true"]')
|
||||
);
|
||||
if (isInputLike(target) || isInputLike(active)) return;
|
||||
|
||||
if (isInput) return;
|
||||
|
||||
if (e.key === 'd' && !e.metaKey && !e.ctrlKey) {
|
||||
// Mod-gated shortcuts only. Bare letters were footguns: typing the
|
||||
// letter "d" anywhere outside a tagged input field used to navigate
|
||||
// home, which surprised users typing workflow titles/descriptions.
|
||||
if (e.key.toLowerCase() === 'd' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'A' && e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
||||
if (e.key === 'A' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
for (const session of Object.values(sessions)) {
|
||||
for (const req of session.pending_approvals) {
|
||||
dispatch(handleApproval({ requestId: req.id, behavior: 'allow' }));
|
||||
@@ -32,7 +43,8 @@ export function useKeyboardShortcuts() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'D' && e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
||||
if (e.key === 'D' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
for (const session of Object.values(sessions)) {
|
||||
for (const req of session.pending_approvals) {
|
||||
dispatch(handleApproval({ requestId: req.id, behavior: 'deny' }));
|
||||
@@ -41,7 +53,8 @@ export function useKeyboardShortcuts() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key >= '1' && e.key <= '9' && !e.metaKey && !e.ctrlKey) {
|
||||
if (e.key >= '1' && e.key <= '9' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const idx = parseInt(e.key) - 1;
|
||||
const sessionList = Object.values(sessions).sort(
|
||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
|
||||
@@ -2,16 +2,14 @@ import { createSlice, createAsyncThunk, PayloadAction, createAction } from '@red
|
||||
import { launchAndSendFirstMessage } from './agentsSlice';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
// Cross-slice listener: when agentsSlice's fetchSession thunk rejects
|
||||
// with a 404/410, the session is gone server-side. We strip the card
|
||||
// from layout here so AgentChat doesn't keep re-mounting + re-fetching
|
||||
// the same dead id in a loop (the visible "404 spam" in dev logs).
|
||||
// Matching the rejected-thunk action type literally avoids a circular
|
||||
// import on the thunk's reject metadata.
|
||||
// fetchSession 404/410 strips the layout card to stop AgentChat remount-loop. Matched by string to avoid circular import.
|
||||
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;
|
||||
@@ -20,6 +18,10 @@ export const DEFAULT_VIEW_CARD_W = 1280;
|
||||
export const DEFAULT_VIEW_CARD_H = 800;
|
||||
export const DEFAULT_BROWSER_CARD_W = 1280;
|
||||
export const DEFAULT_BROWSER_CARD_H = 800;
|
||||
export const DEFAULT_WORKFLOW_CARD_W = 440;
|
||||
export const DEFAULT_WORKFLOW_CARD_H = 520;
|
||||
export const DEFAULT_WORKFLOWS_HUB_W = 1200;
|
||||
export const DEFAULT_WORKFLOWS_HUB_H = 640;
|
||||
export const EXPANDED_CARD_MIN_H = 620;
|
||||
export const GRID_GAP = 24;
|
||||
const GRID_ORIGIN = { x: 40, y: 100 };
|
||||
@@ -60,12 +62,29 @@ export interface BrowserCardPosition {
|
||||
width: number;
|
||||
height: number;
|
||||
zOrder: number;
|
||||
// Agent session id that spawned this browser. null/undefined for
|
||||
// user-created. Used to auto-remove the browser when its owner agent
|
||||
// reaches a terminal completed/error state.
|
||||
/** Agent session that spawned this browser; auto-removed when its owner reaches terminal state. */
|
||||
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 {
|
||||
@@ -82,10 +101,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 }>;
|
||||
@@ -94,18 +124,21 @@ export interface DashboardLayoutState {
|
||||
nextZOrder: number;
|
||||
loading: boolean;
|
||||
initialized: boolean;
|
||||
// Transient signal: when a new browser card is created via addBrowserCard
|
||||
// (link click, "+ Browser" button, pending URL flow), the reducer sets this
|
||||
// to the new card's id. Dashboard.tsx watches it and pans/zooms the canvas
|
||||
// to center on the new card, then dispatches clearPendingFocusBrowserId.
|
||||
/** 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: {},
|
||||
@@ -116,12 +149,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[];
|
||||
}
|
||||
@@ -154,6 +192,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;
|
||||
@@ -175,6 +216,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,
|
||||
},
|
||||
@@ -211,6 +255,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 });
|
||||
}
|
||||
@@ -241,18 +291,7 @@ export function findOpenGridCell(
|
||||
}
|
||||
}
|
||||
|
||||
// Like findOpenGridCell but biased to stay near a proposed (x,y) anchor.
|
||||
// Used when the backend hands us a card with a position that's already
|
||||
// occupied (sub-agent or sub-browser spawning on top of its parent or a
|
||||
// sibling). Spirals outward from the anchor on a grid, snapping to
|
||||
// cell-aligned positions so the result still looks intentional, not
|
||||
// dropped from orbit. Caps the spiral search at ~1000 cells to avoid
|
||||
// pathological work in adversarial layouts — falls back to
|
||||
// findOpenGridCell after that.
|
||||
//
|
||||
// Cost: O(rects × cells_scanned). Spawn events are rare (not per-frame),
|
||||
// so this only runs when a new card appears. Typical scan resolves in
|
||||
// <10 cells, well below the cap. No perf impact on steady-state UI.
|
||||
/** findOpenGridCell variant biased toward an (x,y) anchor; spiral search capped at ring=32. */
|
||||
export function findOpenSpotNear(
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
@@ -262,7 +301,7 @@ export function findOpenSpotNear(
|
||||
): { x: number; y: number } {
|
||||
const cellW = DEFAULT_CARD_W + GRID_GAP;
|
||||
const cellH = DEFAULT_CARD_H + GRID_GAP;
|
||||
// Snap the anchor to the nearest grid cell so all cards align cleanly.
|
||||
// Snap the anchor to the nearest grid cell so cards align.
|
||||
const baseCol = Math.round((anchorX - GRID_ORIGIN.x) / cellW);
|
||||
const baseRow = Math.round((anchorY - GRID_ORIGIN.y) / cellH);
|
||||
|
||||
@@ -273,7 +312,6 @@ export function findOpenSpotNear(
|
||||
return !occupiedRects.some((r) => rectsOverlap(candidate, r));
|
||||
};
|
||||
|
||||
// Try the anchor itself first.
|
||||
if (cellFree(baseCol, baseRow)) {
|
||||
return {
|
||||
x: GRID_ORIGIN.x + baseCol * cellW,
|
||||
@@ -281,18 +319,14 @@ export function findOpenSpotNear(
|
||||
};
|
||||
}
|
||||
|
||||
// Spiral search: expand rings around the anchor. Each ring r covers
|
||||
// the perimeter of a (2r+1)×(2r+1) square. First free cell wins,
|
||||
// preferring right/down (read order) within each ring for stability.
|
||||
// Spiral by ring perimeter; right/down preference for stability.
|
||||
const MAX_RING = 32;
|
||||
for (let r = 1; r <= MAX_RING; r++) {
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
// Only perimeter of this ring (interior was scanned in r-1).
|
||||
if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue;
|
||||
const col = baseCol + dx;
|
||||
const row = baseRow + dy;
|
||||
// Don't place above the grid origin.
|
||||
if (col < 0 || row < 0) continue;
|
||||
if (cellFree(col, row)) {
|
||||
return {
|
||||
@@ -304,8 +338,6 @@ export function findOpenSpotNear(
|
||||
}
|
||||
}
|
||||
|
||||
// Pathological — full canvas occupied near anchor. Fall back to the
|
||||
// global first-empty scan so we never return an overlap.
|
||||
return findOpenGridCell(occupiedRects, newW, newH);
|
||||
}
|
||||
|
||||
@@ -345,14 +377,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
// Optional: which existing sessions are currently expanded
|
||||
// (showing their full chat history). Without this, the collision
|
||||
// check uses each card's STORED height — which is the collapsed
|
||||
// value — even when the card is currently rendering at the
|
||||
// expanded ~620px. Result: new sub-agent cards spawn into the
|
||||
// collapsed footprint but overlap the visually expanded one.
|
||||
// Caller (Dashboard.tsx) passes the current expanded set so
|
||||
// the collision math matches what the user actually sees.
|
||||
/** Currently-expanded sessions; collision math uses rendered (not stored) heights. */
|
||||
expandedSessionIds?: string[];
|
||||
}>
|
||||
) {
|
||||
@@ -371,9 +396,33 @@ 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
|
||||
// short-circuit when the target is already on top. Without this
|
||||
// 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, 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.
|
||||
|
||||
const z = state.nextZOrder++;
|
||||
if (type === 'agent') {
|
||||
const card = state.cards[id];
|
||||
@@ -384,6 +433,11 @@ const dashboardLayoutSlice = createSlice({
|
||||
} else if (type === 'note') {
|
||||
const note = state.notes[id];
|
||||
if (note) note.zOrder = z;
|
||||
} else if (type === 'workflow') {
|
||||
const card = state.workflowCards[id];
|
||||
if (card) card.zOrder = z;
|
||||
} else if (type === 'workflows-hub') {
|
||||
if (state.workflowsHub) state.workflowsHub.zOrder = z;
|
||||
} else {
|
||||
const card = state.browserCards[id];
|
||||
if (card) card.zOrder = z;
|
||||
@@ -439,13 +493,15 @@ const dashboardLayoutSlice = createSlice({
|
||||
const agentCards = Object.values(state.cards);
|
||||
const viewCards = Object.values(state.viewCards);
|
||||
const bCards = Object.values(state.browserCards);
|
||||
const total = agentCards.length + viewCards.length + bCards.length;
|
||||
const wCards = Object.values(state.workflowCards);
|
||||
const total = agentCards.length + viewCards.length + bCards.length + wCards.length;
|
||||
if (total === 0) return;
|
||||
|
||||
const allItems = [
|
||||
...agentCards.map((c) => ({ kind: 'agent' as const, id: c.session_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
|
||||
...viewCards.map((c) => ({ kind: 'view' as const, id: c.output_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
|
||||
...bCards.map((c) => ({ kind: 'browser' as const, id: c.browser_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
|
||||
...wCards.map((c) => ({ kind: 'workflow' as const, id: c.workflow_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
|
||||
];
|
||||
allItems.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
|
||||
@@ -470,6 +526,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; }
|
||||
@@ -544,7 +603,6 @@ const dashboardLayoutSlice = createSlice({
|
||||
height: DEFAULT_BROWSER_CARD_H,
|
||||
zOrder: state.nextZOrder++,
|
||||
};
|
||||
// Signal Dashboard.tsx to pan/zoom and highlight this new card.
|
||||
state.pendingFocusBrowserId = id;
|
||||
},
|
||||
|
||||
@@ -557,13 +615,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
if (state.browserCards[card.browser_id]) return;
|
||||
const w = card.width || DEFAULT_BROWSER_CARD_W;
|
||||
const h = card.height || DEFAULT_BROWSER_CARD_H;
|
||||
// Collision-resolve the backend-proposed position. Backend agents
|
||||
// often spawn sub-browsers at the parent's coordinates or at a
|
||||
// default (0,0) — without this guard, the new card lands on top
|
||||
// of an existing one and the user sees a single card with
|
||||
// multiple titles fighting for the z-index. Bias toward the
|
||||
// proposed position so the spawn still LOOKS related to wherever
|
||||
// the agent intended.
|
||||
// Resolve collisions while biasing toward the proposed position so the spawn looks related.
|
||||
const rects = collectOccupiedRects(state);
|
||||
const pos = findOpenSpotNear(card.x, card.y, rects, w, h);
|
||||
state.browserCards[card.browser_id] = {
|
||||
@@ -601,6 +653,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<{
|
||||
@@ -749,7 +982,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
moveCards(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
items: Array<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' }>;
|
||||
items: Array<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' | 'workflow' }>;
|
||||
dx: number;
|
||||
dy: number;
|
||||
}>,
|
||||
@@ -774,6 +1007,12 @@ const dashboardLayoutSlice = createSlice({
|
||||
note.x += dx;
|
||||
note.y += dy;
|
||||
}
|
||||
} else if (item.type === 'workflow') {
|
||||
const card = state.workflowCards[item.id];
|
||||
if (card) {
|
||||
card.x += dx;
|
||||
card.y += dy;
|
||||
}
|
||||
} else {
|
||||
const card = state.browserCards[item.id];
|
||||
if (card) {
|
||||
@@ -901,6 +1140,9 @@ const dashboardLayoutSlice = createSlice({
|
||||
state.cards = {};
|
||||
state.viewCards = {};
|
||||
state.browserCards = {};
|
||||
state.workflowCards = {};
|
||||
state.configurePanels = {};
|
||||
state.workflowsHub = null;
|
||||
state.notes = {};
|
||||
state.closedCardPositions = {};
|
||||
state.glowingBrowserCards = {};
|
||||
@@ -909,6 +1151,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
state.nextZOrder = 1;
|
||||
state.initialized = false;
|
||||
state.pendingFocusNoteId = null;
|
||||
state.pendingFocusWorkflowId = null;
|
||||
},
|
||||
|
||||
},
|
||||
@@ -923,10 +1166,12 @@ 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;
|
||||
|
||||
// Ensure all cards have a zOrder and compute nextZOrder from persisted data
|
||||
let maxZ = 0;
|
||||
for (const c of Object.values(state.cards)) {
|
||||
if (!c.zOrder) c.zOrder = 0;
|
||||
@@ -940,6 +1185,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;
|
||||
@@ -951,11 +1200,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
state.initialized = true;
|
||||
})
|
||||
.addCase(fetchSessionRejectedAction, (state, action) => {
|
||||
// 404/410 means the session is permanently gone from the
|
||||
// backend; remove its card so AgentChat doesn't keep remounting
|
||||
// and re-fetching it in a loop. Same id, same dead path. Other
|
||||
// failure modes (network blip, 500) leave the card in place
|
||||
// because the next fetch may succeed.
|
||||
// 404/410 means permanent; strip the card. Other failure modes leave it (next fetch may succeed).
|
||||
const payload = action.payload;
|
||||
if (!payload?.sessionId) return;
|
||||
if (payload.status !== 404 && payload.status !== 410) return;
|
||||
@@ -963,6 +1208,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 +1260,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 '@/app/components/Onboarding/OnboardingProgressSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
@@ -35,6 +36,7 @@ export const store = configureStore({
|
||||
models: modelsReducer,
|
||||
interaction: interactionReducer,
|
||||
subscriptions: subscriptionsReducer,
|
||||
workflows: workflowsReducer,
|
||||
onboardingProgress: onboardingProgressReducer,
|
||||
},
|
||||
// Disable Redux Toolkit's dev-mode invariant middleware (serializable +
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
/** 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';
|
||||
editFacet?: 'General' | 'Actions' | 'Schedule';
|
||||
historyRunId?: string | null;
|
||||
}
|
||||
|
||||
interface State {
|
||||
items: Record<string, Workflow>;
|
||||
runs: Record<string, WorkflowRun[]>;
|
||||
openCards: Record<string, OpenCard>;
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
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);
|
||||
if (idx >= 0) arr[idx] = r; else arr.unshift(r);
|
||||
state.runs[r.workflow_id] = arr.slice(0, 100);
|
||||
const wf = state.items[r.workflow_id];
|
||||
if (wf) {
|
||||
wf.last_run_at = r.finished_at || r.started_at;
|
||||
wf.last_run_status = r.status === 'skipped' ? wf.last_run_status : (r.status as Workflow['last_run_status']);
|
||||
wf.last_run_id = r.id;
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchWorkflows.pending, (state) => { state.loading = true; })
|
||||
.addCase(fetchWorkflows.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.loaded = true;
|
||||
state.items = {};
|
||||
for (const w of action.payload) state.items[w.id] = w;
|
||||
})
|
||||
.addCase(fetchWorkflows.rejected, (state) => { state.loading = false; state.loaded = true; })
|
||||
.addCase(createWorkflow.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
|
||||
.addCase(updateWorkflow.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
|
||||
.addCase(deleteWorkflow.fulfilled, (state, action) => {
|
||||
delete state.items[action.payload];
|
||||
delete state.runs[action.payload];
|
||||
})
|
||||
.addCase(fetchRuns.fulfilled, (state, action) => {
|
||||
state.runs[action.payload.id] = action.payload.runs;
|
||||
})
|
||||
.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 } = 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';
|
||||
|
||||
@@ -701,6 +702,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));
|
||||
@@ -798,6 +858,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