From 2c554f46181287a04fe47377e6862d929e76b8fe Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 21 May 2026 22:29:29 -0700 Subject: [PATCH] [eric] workflows: port frontend slice + Workflows pages + canvas card mounting from dev --- .../src/app/pages/Dashboard/AgentCard.tsx | 207 +++-- .../src/app/pages/Dashboard/Dashboard.tsx | 507 +++++++----- .../app/pages/Dashboard/DashboardToolbar.tsx | 154 +--- .../pages/Dashboard/useDashboardSelection.ts | 32 +- .../src/app/pages/Workflows/ActionsFacet.tsx | 80 ++ .../pages/Workflows/ConfigurePanelCard.tsx | 158 ++++ .../src/app/pages/Workflows/GeneralFacet.tsx | 132 +++ .../app/pages/Workflows/ScheduleCalendar.tsx | 429 ++++++++++ .../src/app/pages/Workflows/ScheduleFacet.tsx | 476 +++++++++++ .../app/pages/Workflows/SchedulePopover.tsx | 231 ++++++ .../pages/Workflows/ScheduleThisPopover.tsx | 240 ++++++ frontend/src/app/pages/Workflows/StepList.tsx | 210 +++++ .../src/app/pages/Workflows/WorkflowCard.tsx | 771 ++++++++++++++++++ .../pages/Workflows/WorkflowCardSubviews.tsx | 625 ++++++++++++++ .../app/pages/Workflows/WorkflowEditViews.tsx | 149 ++++ .../app/pages/Workflows/WorkflowsHubCard.tsx | 631 ++++++++++++++ .../app/pages/Workflows/permissionsUtils.ts | 31 + .../src/app/pages/Workflows/scheduleDetect.ts | 103 +++ .../src/app/pages/Workflows/scheduleUtils.ts | 171 ++++ .../pages/Workflows/workflowEditCommon.tsx | 52 ++ .../app/pages/Workflows/workflowVisuals.tsx | 503 ++++++++++++ .../src/shared/hooks/useKeyboardShortcuts.ts | 35 +- .../src/shared/state/dashboardLayoutSlice.ts | 383 +++++++-- frontend/src/shared/state/store.ts | 2 + frontend/src/shared/state/workflowsSlice.ts | 296 +++++++ frontend/src/shared/ws/WebSocketManager.ts | 87 +- 26 files changed, 6197 insertions(+), 498 deletions(-) create mode 100644 frontend/src/app/pages/Workflows/ActionsFacet.tsx create mode 100644 frontend/src/app/pages/Workflows/ConfigurePanelCard.tsx create mode 100644 frontend/src/app/pages/Workflows/GeneralFacet.tsx create mode 100644 frontend/src/app/pages/Workflows/ScheduleCalendar.tsx create mode 100644 frontend/src/app/pages/Workflows/ScheduleFacet.tsx create mode 100644 frontend/src/app/pages/Workflows/SchedulePopover.tsx create mode 100644 frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx create mode 100644 frontend/src/app/pages/Workflows/StepList.tsx create mode 100644 frontend/src/app/pages/Workflows/WorkflowCard.tsx create mode 100644 frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx create mode 100644 frontend/src/app/pages/Workflows/WorkflowEditViews.tsx create mode 100644 frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx create mode 100644 frontend/src/app/pages/Workflows/permissionsUtils.ts create mode 100644 frontend/src/app/pages/Workflows/scheduleDetect.ts create mode 100644 frontend/src/app/pages/Workflows/scheduleUtils.ts create mode 100644 frontend/src/app/pages/Workflows/workflowEditCommon.tsx create mode 100644 frontend/src/app/pages/Workflows/workflowVisuals.tsx create mode 100644 frontend/src/shared/state/workflowsSlice.ts diff --git a/frontend/src/app/pages/Dashboard/AgentCard.tsx b/frontend/src/app/pages/Dashboard/AgentCard.tsx index 3fdca4c6..2832034a 100644 --- a/frontend/src/app/pages/Dashboard/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/AgentCard.tsx @@ -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 }[] = [ { 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 = ({ 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 = ({ const scrollOverlayRef = useOverlayScrollPassthrough(isSelected); const cardBoxRef = useRef(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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ (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 = ({ }; - // Elapsed-time display owns its own 1Hz tick via 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 = ({ 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 = ({ }} 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 = ({ }, }), ...(!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 && ( = ({ transition: `opacity ${GLOW_FADE_MS}ms ease-out`, }} > - {/* Rotating conic gradient border */} = ({ }, }} /> - {/* Top edge shimmer */} = ({ }, }} /> - {/* Inner shadow overlay */} = ({ )} - {/* Resize handles: 4 edges + 4 corners */} {HANDLE_DEFS.map(({ dir, sx }) => ( = ({ /> ))} - {/* Selection overlay – blocks click interaction while selected, enabling drag from anywhere */} {isSelected && ( = ({ /> )} - {/* Drag zone: header + metadata – entire region above separator is draggable */} = ({ 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 && ( + + { + e.stopPropagation(); + const steps = extractStepsFromSession(session); + if (steps.length === 0) return; + const draft: Partial = { + 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' }, + }} + > + + Make workflow + + + )} = ({ - {/* Metadata row */} = ({ - {/* Expanded: inline chat fills remaining space */} {expanded && ( e.stopPropagation()} @@ -1061,7 +1040,6 @@ const AgentCard: React.FC = ({ )} - {/* Collapsed: preview + approval */} {!expanded && ( <> {previewContent && ( @@ -1250,10 +1228,7 @@ const AgentCard: React.FC = ({ 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 = (props) => { const session = useAppSelector((s) => s.agents.sessions[props.sessionId]); const cardEntry = useAppSelector((s) => s.dashboardLayout.cards[props.sessionId]); diff --git a/frontend/src/app/pages/Dashboard/Dashboard.tsx b/frontend/src/app/pages/Dashboard/Dashboard.tsx index 86320c9f..cfe9cee9 100644 --- a/frontend/src/app/pages/Dashboard/Dashboard.tsx +++ b/frontend/src/app/pages/Dashboard/Dashboard.tsx @@ -32,6 +32,7 @@ import { setGlowingBrowserCards, removeViewCard, removeBrowserCard, + removeWorkflowCard, pasteBrowserCard, placeCard, setCardPosition, @@ -40,6 +41,8 @@ import { setGlowingAgentCard, clearGlowingAgentCard, clearPendingFocusBrowserId, + clearPendingFocusWorkflowId, + clearPendingFocusWorkflowsHub, addNote, removeNote, clearPendingFocusNoteId, @@ -49,6 +52,7 @@ import { GRID_GAP, } from '@/shared/state/dashboardLayoutSlice'; import { fetchOutputs } from '@/shared/state/outputsSlice'; +import { fetchWorkflows, closeWorkflowCard } from '@/shared/state/workflowsSlice'; import { generateDashboardName, updateDashboardThumbnail } from '@/shared/state/dashboardsSlice'; import { dashboardWs } from '@/shared/ws/WebSocketManager'; import { initBrowserCommandHandler } from '@/shared/browserCommandHandler'; @@ -60,10 +64,10 @@ import NoteCard from './NoteCard'; import CanvasControls from './CanvasControls'; import CardSearchPalette from './CardSearchPalette'; import DirectionHints from './DirectionHints'; -// OnboardingWalkthrough was retired in v2 — the new OnboardingRoot/Panel -// (mounted in Main.tsx) replaces it. Keeping this banner to prevent stale -// imports from sneaking back in via auto-completion. import DashboardToolbar from './DashboardToolbar'; +import WorkflowCard from '@/app/pages/Workflows/WorkflowCard'; +import WorkflowsHubCard from '@/app/pages/Workflows/WorkflowsHubCard'; +import ConfigurePanelCard from '@/app/pages/Workflows/ConfigurePanelCard'; import { captureDashboardThumbnail } from './captureDashboardThumbnail'; import { useCanvasControls } from './useCanvasControls'; import { useDashboardSelection } from './useDashboardSelection'; @@ -110,6 +114,11 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true const cards = useAppSelector((state) => state.dashboardLayout.cards); const viewCards = useAppSelector((state) => state.dashboardLayout.viewCards); const browserCards = useAppSelector((state) => state.dashboardLayout.browserCards); + const workflowCards = useAppSelector((state) => state.dashboardLayout.workflowCards); + const workflowItems = useAppSelector((state) => state.workflows.items); + const workflowOpenCards = useAppSelector((state) => state.workflows.openCards); + const configurePanels = useAppSelector((state) => state.dashboardLayout.configurePanels); + const workflowsHub = useAppSelector((state) => state.dashboardLayout.workflowsHub); const notes = useAppSelector((state) => state.dashboardLayout.notes); const pendingFocusNoteId = useAppSelector((state) => state.dashboardLayout.pendingFocusNoteId); const layoutInitialized = useAppSelector((state) => state.dashboardLayout.initialized); @@ -123,9 +132,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true const outputsLoaded = useAppSelector((state) => state.outputs.loaded); const glowingAgentCards = useAppSelector((state) => state.dashboardLayout.glowingAgentCards); const glowingBrowserCards = useAppSelector((state) => state.dashboardLayout.glowingBrowserCards); - // sessions is the top-level dict; useMemo on its identity so sessionList - // is stable when sessions hasn't actually changed (RTK only swaps the dict - // ref when one of its values changes, so this is the right granularity). + // Memo on sessions identity; RTK only swaps the dict ref when a value changes, so sessionList stays stable. const sessionList = useMemo(() => Object.values(sessions), [sessions]); const contentBounds = useMemo(() => { @@ -133,6 +140,8 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true ...Object.values(cards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })), ...Object.values(viewCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })), ...Object.values(browserCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })), + ...Object.values(workflowCards).map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })), + ...(workflowsHub ? [{ x: workflowsHub.x, y: workflowsHub.y, w: workflowsHub.width, h: workflowsHub.height }] : []), ]; if (allRects.length === 0) return undefined; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; @@ -143,7 +152,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true maxY = Math.max(maxY, r.y + r.h); } return { minX, minY, maxX, maxY }; - }, [cards, viewCards, browserCards]); + }, [cards, viewCards, browserCards, workflowCards, workflowsHub]); const canvas = useCanvasControls(zoomSensitivity, contentBounds, isActive); const selection = useDashboardSelection( @@ -152,6 +161,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true viewCards, browserCards, notes, + workflowCards, ); const toolbarRef = useRef(null); @@ -163,8 +173,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true const [pendingSelectSessionId, setPendingSelectSessionId] = useState(null); const [focusedCardId, setFocusedCardId] = useState(null); const [newAgentBounce, setNewAgentBounce] = useState(false); - // Cleanup any leftover walkthrough localStorage from v1 — the v2 panel - // ignores it but it would otherwise hang around forever. + // Wipe leftover v1 walkthrough localStorage; v2 ignores it but it would persist forever. useEffect(() => { try { localStorage.removeItem('openswarm_walkthrough_pending'); @@ -213,23 +222,18 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true const restoredExpandedRef = useRef(false); const canvasStateRef = useRef({ panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom }); canvasStateRef.current = { panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom }; - // Stable getter — AgentCards read pan/zoom on demand during drag math. + // Stable getter so AgentCards read pan/zoom on demand during drag math. const getCanvasState = useCallback(() => canvasStateRef.current, []); - // Notify the currently dragging card (if any) that pan/zoom changed so - // it can re-pin to the cursor. useEffect rather than render-body - // dispatchEvent: side effects during render are a React anti-pattern - // and can fire twice in strict mode. Effect runs after commit, so - // exactly once per real pan/zoom delta. + // Effect (not render-body) fires the pan-changed event so dragging cards re-pin to cursor; safe in strict mode. useEffect(() => { window.dispatchEvent(new Event('openswarm:canvas-pan-changed')); }, [canvas.panX, canvas.panY, canvas.zoom]); - // ---- Edge panning during card drag ---- const EDGE_ZONE = 60; const EDGE_MAX_SPEED = 8; const edgePanFrameRef = useRef(null); const lastMousePosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); - // Track pan at drag start so cards can compensate for edge-pan offset + // Track pan at drag start so cards can compensate for edge-pan offset. const dragStartPanRef = useRef<{ panX: number; panY: number }>({ panX: 0, panY: 0 }); const stopEdgePan = useCallback(() => { @@ -270,7 +274,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true edgePanFrameRef.current = requestAnimationFrame(tickEdgePan); }, [canvas.viewportRef, canvas.actions]); - // ---- Multi-drag coordination ---- const [multiDragDelta, setMultiDragDelta] = useState<{ dx: number; dy: number } | null>(null); const [liveDragInfo, setLiveDragInfo] = useState<{ cardId: string; dx: number; dy: number } | null>(null); const activeDragCardRef = useRef(null); @@ -293,7 +296,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true if (mouseX !== undefined && mouseY !== undefined) { lastMousePosRef.current = { x: mouseX, y: mouseY }; } - // Start edge panning only once actual dragging begins if (!edgePanStartedRef.current) { edgePanStartedRef.current = true; edgePanFrameRef.current = requestAnimationFrame(tickEdgePan); @@ -322,7 +324,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true setLiveDragInfo(null); }, [selection, dispatch, stopEdgePan]); - // Helper: get a card's rect from Redux state (uses collapsed height for zoom calculation) const getCardRect = useCallback((id: string, type: CardType) => { const layoutState = store.getState().dashboardLayout; if (type === 'agent') { @@ -341,11 +342,15 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true const n = layoutState.notes[id]; if (!n) return undefined; return { x: n.x, y: n.y, width: n.width, height: n.height }; + } else if (type === 'workflow') { + const wc = layoutState.workflowCards[id]; + if (!wc) return undefined; + return { x: wc.x, y: wc.y, width: wc.width, height: wc.height }; } return undefined; }, []); - // Delay single-click collapse so double-click can override + // Delay single-click collapse so double-click can override. const clickTimerRef = useRef | null>(null); const handleCardSelect = useCallback((id: string, type: CardType, shiftKey: boolean) => { @@ -361,8 +366,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true const alreadyExpanded = type === 'agent' && expandedSessionIds.includes(id); if (alreadyExpanded) { - // Delay single-click collapse so double-click can override. - // Double-click handler (handleCardDoubleClick) clears clickTimerRef. + // Double-click handler clears this timer to override the single-click collapse. clickTimerRef.current = setTimeout(() => { clickTimerRef.current = null; dispatch(collapseSession(id)); @@ -370,7 +374,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true return; } - // Expand (if not already) + center + zoom + bring to front if (type === 'agent') { dispatch(expandSession(id)); } @@ -378,7 +381,16 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true setTimeout(() => { const rect = getCardRect(id, type); if (rect) canvas.actions.fitToCards([rect], 1.15, true, type === 'browser' ? 0.8 : undefined); - setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150); + setTimeout(() => { + // Don't blur if the focused element is an input/textarea/ + // contentEditable inside the just-clicked card; the user is + // typing there and this blur kills the cursor mid-keystroke. + const active = document.activeElement as HTMLElement | null; + if (!active) return; + const tag = active.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || active.isContentEditable) return; + active.blur?.(); + }, 150); }, 100); }, [selection, getCardRect, canvas.actions, dispatch, expandedSessionIds]); @@ -386,7 +398,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true dispatch(bringToFront({ id, type })); }, [dispatch]); - // ---- Viewport event handlers (compose pan + marquee) ---- const handleViewportMouseDown = useCallback((e: React.MouseEvent) => { if (e.button === 1) { canvas.handlers.onMouseDown(e); @@ -402,8 +413,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true if (e.button !== 0) return; if (isCardTarget(e.target, e.currentTarget)) return; - // Canvas click — drop any lingering input focus so arrow-key nav - // works immediately without the user having to press Escape first. + // Drop lingering input focus so arrow-key nav works immediately. const active = document.activeElement as HTMLElement | null; const activeTag = active?.tagName; if (activeTag === 'INPUT' || activeTag === 'TEXTAREA' || (active as any)?.isContentEditable) { @@ -435,7 +445,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true selection.handleCanvasMouseUp(e.nativeEvent); }, [canvas.handlers, selection]); - // Double-click empty canvas → fit all cards const handleViewportDoubleClick = useCallback((e: React.MouseEvent) => { if (e.button !== 0) return; if (isCardTarget(e.target, e.currentTarget)) return; @@ -443,7 +452,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true canvas.actions.fitToView(); }, [canvas.actions]); - // Double-click a card → always expand + center + zoom (cancels pending collapse from single-click) const handleCardDoubleClick = useCallback((id: string, type: CardType) => { report('dashboard', 'card_double_clicked', { card_type: type }); if (clickTimerRef.current) { @@ -458,11 +466,19 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true setTimeout(() => { const rect = getCardRect(id, type); if (rect) canvas.actions.fitToCards([rect], 1.15, true); - setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150); + setTimeout(() => { + // Don't blur if the focused element is an input/textarea/ + // contentEditable inside the just-clicked card; the user is + // typing there and this blur kills the cursor mid-keystroke. + const active = document.activeElement as HTMLElement | null; + if (!active) return; + const tag = active.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || active.isContentEditable) return; + active.blur?.(); + }, 150); }, 100); }, [getCardRect, canvas.actions, dispatch]); - // Track dashboard engagement time useEffect(() => { if (!dashboardId) return; const startTime = Date.now(); @@ -480,35 +496,26 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true hasFittedRef.current = false; restoredExpandedRef.current = false; dispatch(resetLayout()); - // CRITICAL path: these populate the cards the user expects to see - // on first paint. Don't defer. + // First-paint critical: do not defer. dispatch(fetchSessions({ dashboardId })); dispatch(fetchLayout(dashboardId)); const cleanupBrowserHandler = initBrowserCommandHandler(); - // DEFERRABLE: history list (for the search palette) and outputs - // (for the apps panel) aren't on the first-paint path. Same for the - // dashboard WS connection (it carries cross-session events; opens - // ~100ms later costs nothing). Pushing these into the post-paint - // window measurably improves LCP because the initial render - // pipeline isn't competing with their thunks/network setup. + // Deferred: history, outputs, and dashboard WS are off the first-paint path; post-paint scheduling improves LCP. const idleHandle = (typeof window !== 'undefined' && (window as any).requestIdleCallback) ? (window as any).requestIdleCallback(() => { dispatch(fetchHistory({ dashboardId })); dispatch(fetchOutputs()); + dispatch(fetchWorkflows(dashboardId)); dashboardWs.connect(); }, { timeout: 2000 }) : window.setTimeout(() => { dispatch(fetchHistory({ dashboardId })); dispatch(fetchOutputs()); + dispatch(fetchWorkflows(dashboardId)); dashboardWs.connect(); }, 200); - // Pre-warm Anthropic's prompt cache for sessions on this dashboard - // ~250ms after mount (debounced; AbortController cancels on - // dashboard switch). Fires a max_tokens=1 ping per session so the - // user's first real message hits a warm cache instead of paying - // cold-start TTFT. Cheap (~$0.0001/session) and non-blocking. Skips - // for non-Anthropic sessions server-side. + // Pre-warm Anthropic prompt cache ~250ms after mount so first message hits a warm cache; backend skips non-Anthropic sessions. const warmAbort = new AbortController(); const warmTimer = setTimeout(async () => { try { @@ -522,8 +529,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true ); for (const s of dashSessions) { if (warmAbort.signal.aborted) break; - // Fire-and-forget — the endpoint always 200s and the side - // effect is invisible cache population. + // Fire-and-forget; endpoint always 200s and effect is invisible cache population. fetch(`${API_BASE}/agents/sessions/${s.id}/warm-cache`, { method: 'POST', signal: warmAbort.signal, @@ -539,8 +545,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true warmAbort.abort(); cleanupBrowserHandler(); dashboardWs.disconnect(); - // Cancel any not-yet-fired idle work; the cleanup handler can't - // run partially if the dashboard switches before idle fired. + // Cancel any unfired idle work so the cleanup handler can't run partially after dashboard switch. if (typeof window !== 'undefined') { const cancelIdle = (window as any).cancelIdleCallback; if (cancelIdle && typeof idleHandle === 'number') cancelIdle(idleHandle); @@ -552,6 +557,8 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true const pendingBrowserUrl = useAppSelector((state) => state.tempState.pendingBrowserUrl); const pendingFocusAgentId = useAppSelector((state) => state.tempState.pendingFocusAgentId); const pendingFocusBrowserId = useAppSelector((state) => state.dashboardLayout.pendingFocusBrowserId); + const pendingFocusWorkflowId = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowId); + const pendingFocusWorkflowsHub = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowsHub); useEffect(() => { if (!dashboardId) return; @@ -564,10 +571,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true dispatch(clearPendingBrowserUrl()); }, [pendingBrowserUrl, layoutInitialized, dispatch, expandedSessionIds]); - // Capture a thumbnail screenshot of the dashboard. - // Uses Electron's native capturePage for pixel-perfect results. - // Captures current viewport as-is (no DOM mutation) to avoid visual flashes. - // Re-captures when layout is saved (piggybacking on the save debounce). + // Native capturePage thumbnail; captures viewport as-is and piggybacks on the layout save debounce. const pendingThumbnailRef = useRef(null); const captureTimerRef = useRef | null>(null); const captureNow = useCallback(() => { @@ -584,9 +588,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true || Object.keys(allCards.viewCards).length > 0 || Object.keys(allCards.browserCards).length > 0; if (!hasCards) { - // Empty dashboard — queue a thumbnail clear (sent on exit alongside - // the existing capture-update path). Backend treats '' as "set to - // empty"; null in PUT body means "don't update". + // Empty dashboard: queue a thumbnail clear (backend treats '' as "set empty", null as "no change"). pendingThumbnailRef.current = ''; return; } @@ -603,7 +605,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true return () => { if (captureTimerRef.current) clearTimeout(captureTimerRef.current); }; }, [isActive, dashboardId, layoutInitialized, captureNow]); - // On exit, save the captured thumbnail to the backend useEffect(() => { if (!dashboardId) return; const exitingId = dashboardId; @@ -641,18 +642,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true }, 350); }, [isActive, pendingFocusAgentId, layoutInitialized, dispatch, canvas.actions, handleHighlightCard]); - // Auto-focus a newly created browser card. The reducer that handles - // addBrowserCard sets pendingFocusBrowserId to the new card's id; this - // effect picks it up, pans/zooms the canvas to center on it, briefly - // highlights it, then clears the signal. Mirrors the pendingFocusAgentId - // pattern above so link clicks (intercepted in AppShell) get the same - // auto-focus behavior as the "+ Browser" toolbar button. - // - // Uses zoom=0.8 (the same value handleCardClick uses for browser cards - // at line ~344) instead of letting fitToCards auto-derive a zoom from - // padding. Browser cards are large (1280x800), so the auto-derived zoom - // would land around ~58% which feels too far back; 0.8 matches the - // "click on a browser to focus" experience the user expects. + // Auto-focus newly created browser card; zoom=0.8 matches the click-to-focus experience for 1280x800 cards. useEffect(() => { if (!isActive) return; if (!pendingFocusBrowserId || !layoutInitialized) return; @@ -673,6 +663,45 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true }, 200); }, [isActive, pendingFocusBrowserId, layoutInitialized, dispatch, canvas.actions, handleHighlightCard]); + // Same pan/highlight choreography for newly-spawned workflow cards. + useEffect(() => { + if (!isActive) return; + if (!pendingFocusWorkflowId || !layoutInitialized) return; + const workflowId = pendingFocusWorkflowId; + dispatch(clearPendingFocusWorkflowId()); + setTimeout(() => { + const card = store.getState().dashboardLayout.workflowCards[workflowId]; + if (card) { + canvas.actions.fitToCards( + [{ x: card.x, y: card.y, width: card.width, height: card.height }], + 1.15, + true, + ); + handleHighlightCard(workflowId); + } + }, 200); + }, [isActive, pendingFocusWorkflowId, layoutInitialized, dispatch, canvas.actions, handleHighlightCard]); + + // Pan/zoom to Workflows Hub on Expand; chained rAFs ensure fit runs after the hub div lands at its new coords. + useEffect(() => { + if (!isActive) return; + if (!pendingFocusWorkflowsHub || !layoutInitialized) return; + dispatch(clearPendingFocusWorkflowsHub()); + const fit = () => { + const hub = store.getState().dashboardLayout.workflowsHub; + if (!hub) return; + canvas.actions.fitToCards( + [{ x: hub.x, y: hub.y, width: hub.width, height: hub.height }], + 1.1, + true, + ); + }; + // Two rAFs (state to render, layout to settle) + fallback timeout for slow boots. + requestAnimationFrame(() => requestAnimationFrame(fit)); + const fallback = setTimeout(fit, 300); + return () => clearTimeout(fallback); + }, [isActive, pendingFocusWorkflowsHub, layoutInitialized, dispatch, canvas.actions]); + useEffect(() => { if (!layoutInitialized || restoredExpandedRef.current) return; restoredExpandedRef.current = true; @@ -692,11 +721,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true dispatch(reconcileSessions({ sessionIds: dashboardSessionIds, expandedSessionIds })); }, [sessions, layoutInitialized, dispatch, dashboardId, expandedSessionIds]); - // Prune orphan view cards whose underlying output was deleted (e.g. via - // the Views page). Without this, the layout entry persists in the - // minimap and contentBounds even though DashboardViewCard renders - // nothing. Gated on outputsLoaded so we don't wipe valid cards during - // the brief window between fetchLayout returning and outputs finishing. + // Prune orphan view cards whose underlying output was deleted; gated on outputsLoaded to avoid wiping valid cards during fetch race. useEffect(() => { if (!layoutInitialized || !outputsLoaded) return; for (const outputId of Object.keys(viewCards)) { @@ -704,20 +729,18 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true } }, [layoutInitialized, outputsLoaded, viewCards, outputs, dispatch]); - // ---- Auto-reveal / collapse / unreveal sub-agent cards ---- const autoRevealedRef = useRef(new Set()); const prevSubStatusRef = useRef>({}); const prevParentStatusRef = useRef>({}); useEffect(() => { - if (!isActive) return; // Heavy logic — pause when dashboard is hidden + if (!isActive) return; // Heavy logic; pause when dashboard is hidden. if (!layoutInitialized || !autoRevealSubAgents) return; const subSessions = Object.values(sessions).filter( (s) => (s.mode === 'sub-agent' || s.mode === 'invoked-agent') && s.parent_session_id, ); - // 1) Auto-reveal newly spawned sub-agents (skip already-terminal ones on load) for (const sub of subSessions) { if (autoRevealedRef.current.has(sub.id)) continue; if (cards[sub.id]) { @@ -756,11 +779,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true y: targetY, width: DEFAULT_CARD_W, height: DEFAULT_CARD_H, - // Pass the current expanded-session set so placeCard's - // collision check uses real visual heights (expanded cards - // render ~620px tall instead of their stored collapsed - // height). Without this, sub-agents spawn into space the - // parent card visually occupies. + // Pass expandedSessionIds so placeCard's collision check uses real visual heights (~620px expanded). expandedSessionIds, })); dispatch(expandSession(sub.id)); @@ -773,7 +792,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true } } - // 2) Auto-collapse sub-agents when they complete const TERMINAL = new Set(['completed', 'error', 'stopped']); for (const sub of subSessions) { const prev = prevSubStatusRef.current[sub.id]; @@ -785,7 +803,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true for (const sub of subSessions) { newSubStatuses[sub.id] = sub.status; } prevSubStatusRef.current = newSubStatuses; - // 3) Unreveal all sub-agent cards when parent finishes output const parentIds = new Set(subSessions.map((s) => s.parent_session_id!)); for (const pid of parentIds) { const parent = sessions[pid]; @@ -816,13 +833,13 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true const pendingSaveRef = useRef[0] | null>(null); useEffect(() => { - if (!isActive) return; // Don't persist layout while dashboard is hidden — save buffers in pendingSaveRef and flushes on resume + if (!isActive) return; // Don't persist while hidden; pendingSaveRef buffers and flushes on resume. if (!layoutInitialized || !dashboardId) return; if (skipInitialSave.current) { skipInitialSave.current = false; return; } - const payload = { dashboardId, cards, viewCards, browserCards, notes, expandedSessionIds }; + const payload = { dashboardId, cards, viewCards, browserCards, workflowCards, configurePanels, workflowsHub, notes, expandedSessionIds }; pendingSaveRef.current = payload; if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { @@ -831,7 +848,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true saveTimerRef.current = null; captureNow(); }, 500); - }, [isActive, cards, viewCards, browserCards, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]); + }, [isActive, cards, viewCards, browserCards, workflowCards, configurePanels, workflowsHub, notes, expandedSessionIds, layoutInitialized, dashboardId, dispatch, captureNow]); useEffect(() => { return () => { @@ -901,6 +918,9 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true dispatch(removeBrowserCard(id)); } else if (type === 'note') { dispatch(removeNote(id)); + } else if (type === 'workflow') { + dispatch(removeWorkflowCard(id)); + dispatch(closeWorkflowCard(id)); } } selection.deselectAll(); @@ -909,7 +929,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true return () => window.removeEventListener('keydown', handleDelete); }, [selection, dispatch]); - // Cmd+F to open card search palette useEffect(() => { const handleSearch = (e: KeyboardEvent) => { if (!isActive) return; // Don't fire shortcuts when dashboard is hidden @@ -1036,7 +1055,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true return () => window.removeEventListener('keydown', handlePaste); }, [dispatch, dashboardId, expandedSessionIds, selection]); - // ---- Arrow key card navigation (when zoomed in on a card) ---- const findNearestCard = useCallback(( currentId: string, direction: 'left' | 'right' | 'up' | 'down', @@ -1051,6 +1069,9 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true for (const bc of Object.values(browserCards)) { allCardEntries.push({ id: bc.browser_id, type: 'browser', cx: bc.x + bc.width / 2, cy: bc.y + bc.height / 2 }); } + for (const wc of Object.values(workflowCards)) { + allCardEntries.push({ id: wc.workflow_id, type: 'workflow', cx: wc.x + wc.width / 2, cy: wc.y + wc.height / 2 }); + } const current = allCardEntries.find((c) => c.id === currentId); if (!current) return null; @@ -1063,7 +1084,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true const dx = card.cx - current.cx; const dy = card.cy - current.cy; - // Filter to the correct half-plane let inDirection = false; let primary = 0; let secondary = 0; @@ -1083,13 +1103,10 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true } return best ? { id: best.id, type: best.type } : null; - }, [cards, viewCards, browserCards]); + }, [cards, viewCards, browserCards, workflowCards]); - // Compute which directions have neighbors from the focused card const neighborDirections = useMemo(() => { - // Lowered the zoom floor from 0.9 to 0.4 so arrow nav still works - // when users zoom out to see the whole canvas. Below 0.4 the cards - // are too small to be a useful navigation target. + // Below zoom 0.4 cards are too small to be useful nav targets. if (!focusedCardId || canvas.zoom < 0.4) return { left: false, right: false, up: false, down: false }; return { left: !!findNearestCard(focusedCardId, 'left'), @@ -1099,30 +1116,23 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true }; }, [focusedCardId, canvas.zoom, findNearestCard]); - // Shake animation state: direction + timer const [shakeDirection, setShakeDirection] = useState<'left' | 'right' | 'up' | 'down' | null>(null); const shakeTimerRef = useRef | null>(null); - // Use refs for values read inside the keydown handler to avoid stale closures + // Refs avoid stale closures inside the keydown handler. const focusedCardIdRef = useRef(focusedCardId); focusedCardIdRef.current = focusedCardId; const canvasZoomRef = useRef(canvas.zoom); canvasZoomRef.current = canvas.zoom; useEffect(() => { - // Helper: is the currently-focused element a text-entry field the - // user is actively editing? We only want to suppress dashboard - // navigation when the user is genuinely typing, not just because an - // input somewhere happens to have focus from a click long ago. + // True only when the input has content; empty inputs don't need arrows so we repurpose them for nav. const isActivelyEditing = (target: EventTarget | null): boolean => { const el = (target as HTMLElement) || (document.activeElement as HTMLElement | null); if (!el) return false; const tag = el.tagName; const editable = (el as any).isContentEditable; if (tag !== 'INPUT' && tag !== 'TEXTAREA' && !editable) return false; - // Only suppress when the input actually has content to navigate - // within. An empty input doesn't need arrow keys for cursor - // movement, so we can safely repurpose arrows for dashboard nav. const val = (el as HTMLInputElement | HTMLTextAreaElement).value; if (typeof val === 'string' && val.length === 0) return false; if (editable && (el.textContent ?? '').length === 0) return false; @@ -1132,8 +1142,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true const handleKey = (e: KeyboardEvent) => { if (!isActive) return; // Don't fire shortcuts when dashboard is hidden - // Escape blurs any active input and restores focus to the canvas — - // so you can quickly "unstick" keyboard focus and start navigating. + // Escape blurs active input so users can unstick focus and start navigating. if (e.key === 'Escape') { const active = document.activeElement as HTMLElement | null; const tag = active?.tagName; @@ -1152,14 +1161,9 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true default: return; } - // Don't hijack arrows when the user is actually typing if (isActivelyEditing(e.target)) return; - - // Lowered zoom floor from 0.9 → 0.4 so nav still works zoomed out if (canvasZoomRef.current < 0.4) return; - // If no card is focused, pick the front-most one as a fallback so - // nav works after the user clicked on empty canvas. let currentFocused = focusedCardIdRef.current; if (!currentFocused) { const anyCardId = Object.keys(cards)[0] || Object.keys(viewCards)[0] || Object.keys(browserCards)[0]; @@ -1172,7 +1176,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true const target = findNearestCard(currentFocused, direction); if (!target) { - // No card in that direction — shake if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current); setShakeDirection(direction); shakeTimerRef.current = setTimeout(() => { @@ -1182,7 +1185,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true return; } - // Expand + navigate to target + bring to front report('dashboard', 'arrow_navigated', { direction, from_card: currentFocused, to_card: target.id }); if (target.type === 'agent') { dispatch(expandSession(target.id)); @@ -1193,13 +1195,20 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true setTimeout(() => { const rect = getCardRect(target.id, target.type); if (rect) canvas.actions.fitToCards([rect], 1.15, true); - setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150); + setTimeout(() => { + // Don't blur if the focused element is an input/textarea/ + // contentEditable inside the just-clicked card; the user is + // typing there and this blur kills the cursor mid-keystroke. + const active = document.activeElement as HTMLElement | null; + if (!active) return; + const tag = active.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || active.isContentEditable) return; + active.blur?.(); + }, 150); }, 100); }; - // Capture phase so we beat MUI Menus/Selects that also listen for - // arrows. We still bail early on isActivelyEditing, so this doesn't - // interfere with typing. + // Capture phase beats MUI Menus/Selects; isActivelyEditing early-return prevents interference with typing. window.addEventListener('keydown', handleKey, true); return () => window.removeEventListener('keydown', handleKey, true); }, [findNearestCard, getCardRect, canvas.actions, dispatch, isActive, cards, viewCards, browserCards]); @@ -1309,14 +1318,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true if (selectedBrowserIds.length === 1) { const bc = store.getState().dashboardLayout.browserCards[selectedBrowserIds[0]]; if (bc) { - // Use placeCard (collision-aware) instead of - // setCardPosition (blind setter). The "left of the - // browser" anchor is the IDEAL spot — but if it's - // already taken by an existing chat (e.g. step 3's - // YouTube agent that's still on canvas when step 5 - // creates a new chat for the same browser), placeCard - // cascades to the nearest free cell instead of - // stacking on top. + // placeCard cascades to a free cell if the ideal "left of browser" spot is taken. dispatch(placeCard({ sessionId: realId, x: bc.x - DEFAULT_CARD_W - GRID_GAP * 12, @@ -1410,7 +1412,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true }, 200); }, [dispatch, expandedSessionIds, canvas.actions, handleHighlightCard]); - // Auto-clear pendingFocusNoteId after the note has had a chance to mount + autofocus. useEffect(() => { if (!pendingFocusNoteId) return; const t = setTimeout(() => dispatch(clearPendingFocusNoteId()), 800); @@ -1433,7 +1434,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true }); }, [dispatch, canvas.actions, handleHighlightCard, setAutoFocusSessionId]); - // Context-aware fit: if a card is selected, zoom to it; otherwise fit all const handleFitToView = useCallback(() => { report('dashboard', 'fit_to_view', { has_selection: selection.selectedIds.size > 0 }); if (selection.selectedIds.size === 1) { @@ -1466,10 +1466,9 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true }, [dispatch, canvas.actions]); useEffect(() => { - if (!isActive) return; // Heavy geometry recalculation — pause when dashboard is hidden + if (!isActive) return; // Heavy geometry recalculation; pause when dashboard is hidden. const DRIFT_THRESHOLD = 60; - // Group tethered sub-agent cards by source, only including those still in the spawn column const sourceToSiblings = new Map(); for (const [id, glow] of Object.entries(glowingAgentCards)) { const card = cards[id]; @@ -1501,13 +1500,12 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true cursor += h + GRID_GAP * 2; } } - // measuredHeightsTick in deps ensures we re-run once ResizeObserver reports - // the new height after a collapse (avoids stale-height no-ops) + // measuredHeightsTick in deps: re-run after ResizeObserver reports the post-collapse height. // eslint-disable-next-line react-hooks/exhaustive-deps }, [isActive, expandedSessionIds, glowingAgentCards, cards, dispatch, measuredHeightsTick]); useEffect(() => { - if (!isActive) return; // Heavy geometry recalculation — pause when dashboard is hidden + if (!isActive) return; // Heavy geometry recalculation; pause when dashboard is hidden. const DRIFT_THRESHOLD = 60; const sourceToSiblings = new Map(); @@ -1603,16 +1601,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true }; }).filter(Boolean) as Array<{ key: string; path: string; labelX: number; labelY: number; label: string; fading: boolean }>; - // Build browser tethers from TWO sources and merge: - // 1. glowingBrowserCards — the short-lived "flash" when a browser is first assigned - // 2. Active browser-agent sessions — persistent as long as the agent runs - // - // Source #2 is the fix for tethers disappearing when the parent session - // completes a turn (which clears glowingBrowserCards even though the - // browser agent is still working). Source #1 covers the initial moment - // before the browser-agent session is fully created. Together they - // ensure the arrow is always visible when it should be. - + // Browser tethers merge from two sources: glowingBrowserCards (initial flash) and active browser-agent sessions (persistent). type Anchor = { x: number; y: number; side: 'left' | 'right' | 'top' | 'bottom' }; function browserTether( @@ -1706,14 +1695,12 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true }; } - // Source 1: glow-based (covers the initial flash before browser-agent session exists) const glowTethers = new Map>(); for (const [browserId, { sourceId, fading, label }] of Object.entries(glowingBrowserCards)) { const t = browserTether(browserId, sourceId, fading, label || ''); if (t) glowTethers.set(browserId, t); } - // Source 2: active browser-agent sessions (persistent — survives parent turn completion) for (const s of sessionList) { if (s.mode !== 'browser-agent') continue; if (s.status !== 'running' && s.status !== 'waiting_approval') continue; @@ -1725,9 +1712,150 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true const browserTethers = Array.from(glowTethers.values()).filter(Boolean) as Array<{ key: string; path: string; labelX: number; labelY: number; label: string; fading: boolean }>; - return [...agentTethers, ...browserTethers]; + // Workflow tethers reuse the browser-tether anchor/elbow math; skip deleted workflows to avoid dangling arrows. + const workflowTethers: Array<{ key: string; path: string; labelX: number; labelY: number; label: string; fading: boolean }> = []; + for (const wc of Object.values(workflowCards)) { + const sourceId = wc.source_session_id; + if (!sourceId) continue; + const src = cards[sourceId]; + if (!src) continue; + // Defense-in-depth: layout entry can outlive its workflow when deleted from the hub. + const hasReal = wc.workflow_id in workflowItems; + const hasDraft = wc.workflow_id in workflowOpenCards; + if (!hasReal && !hasDraft) continue; + // The "Make workflow" tether is a draft-time affordance: it shows + // the user which chat the new workflow card came out of. Once the + // workflow is saved (openCard transitions to 'saved' view), the + // user has committed and the visual link can retire. Per user + // feedback on image #70. + const openCard = workflowOpenCards[wc.workflow_id]; + if (openCard && openCard.view !== 'preview') continue; + + let srcX = src.x, srcY = src.y; + let dstX = wc.x, dstY = wc.y; + if (liveDragInfo) { + if (liveDragInfo.cardId === sourceId) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; } + if (liveDragInfo.cardId === wc.workflow_id) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; } + } + + const srcMeasured = measuredHeightsRef.current[sourceId]; + const srcH = srcMeasured ?? (expandedSessionIds.includes(sourceId) + ? Math.max(EXPANDED_CARD_MIN_H, src.height) + : src.height); + + const srcCx = srcX + src.width / 2; + const dstCx = dstX + wc.width / 2; + const srcAnchors: Anchor[] = [ + { x: srcX + src.width, y: srcY + srcH * 0.54, side: 'right' }, + { x: srcX, y: srcY + srcH * 0.54, side: 'left' }, + { x: srcCx, y: srcY, side: 'top' }, + { x: srcCx, y: srcY + srcH, side: 'bottom' }, + ]; + const dstAnchors: Anchor[] = [ + { x: dstX, y: dstY + wc.height * 0.54, side: 'left' }, + { x: dstX + wc.width, y: dstY + wc.height * 0.54, side: 'right' }, + { x: dstCx, y: dstY, side: 'top' }, + { x: dstCx, y: dstY + wc.height, side: 'bottom' }, + ]; + let bestSrc = srcAnchors[0], bestDst = dstAnchors[0]; + let bestDist = Infinity; + for (const sa of srcAnchors) { + for (const da of dstAnchors) { + const d = Math.hypot(sa.x - da.x, sa.y - da.y); + if (d < bestDist) { bestDist = d; bestSrc = sa; bestDst = da; } + } + } + const x1 = bestSrc.x, y1 = bestSrc.y; + const x2 = bestDst.x, y2 = bestDst.y; + const isVertical = (bestSrc.side === 'top' || bestSrc.side === 'bottom') + && (bestDst.side === 'top' || bestDst.side === 'bottom'); + let pathD: string; + if (isVertical) { + const dx = x2 - x1; + const dy = y2 - y1; + const midY = y1 + dy / 2; + const r = (Math.abs(dx) < 1 || Math.abs(dy) < ELBOW_RADIUS * 2) + ? 0 + : Math.min(ELBOW_RADIUS, Math.abs(dx) / 2, Math.abs(dy) / 4); + const sx = dx >= 0 ? 1 : -1; + const sy = dy >= 0 ? 1 : -1; + pathD = [ + `M ${x1},${y1}`, + `V ${midY - sy * r}`, + `Q ${x1},${midY} ${x1 + sx * r},${midY}`, + `H ${x2 - sx * r}`, + `Q ${x2},${midY} ${x2},${midY + sy * r}`, + `V ${y2}`, + ].join(' '); + } else { + pathD = elbowPath(x1, y1, x2, y2); + } + const midX = x1 + (x2 - x1) / 2; + const midY = y1 + (y2 - y1) / 2; + const labelX = isVertical ? midX : midX + (x2 - midX) * 0.15; + const labelY = isVertical ? midY + (y2 - midY) * 0.15 : y2; + workflowTethers.push({ + key: `workflow-${wc.workflow_id}`, + path: pathD, + labelX, + labelY, + label: 'Make workflow', + fading: false, + }); + } + + // Configure-panel tethers: each open configure panel is anchored to its + // workflow card so the user always sees which workflow's action surface + // they're editing, even after dragging things around. + const configureTethers: Array<{ key: string; path: string; labelX: number; labelY: number; label: string; fading: boolean }> = []; + for (const p of Object.values(configurePanels)) { + const wc = workflowCards[p.workflow_id]; + if (!wc) continue; + let srcX = wc.x, srcY = wc.y; + let dstX = p.x, dstY = p.y; + if (liveDragInfo) { + if (liveDragInfo.cardId === p.workflow_id) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; } + } + const srcCx = srcX + wc.width / 2; + const dstCx = dstX + p.width / 2; + const srcAnchors: Anchor[] = [ + { x: srcX + wc.width, y: srcY + wc.height * 0.5, side: 'right' }, + { x: srcX, y: srcY + wc.height * 0.5, side: 'left' }, + { x: srcCx, y: srcY, side: 'top' }, + { x: srcCx, y: srcY + wc.height, side: 'bottom' }, + ]; + const dstAnchors: Anchor[] = [ + { x: dstX, y: dstY + p.height * 0.5, side: 'left' }, + { x: dstX + p.width, y: dstY + p.height * 0.5, side: 'right' }, + { x: dstCx, y: dstY, side: 'top' }, + { x: dstCx, y: dstY + p.height, side: 'bottom' }, + ]; + let bestSrc = srcAnchors[0], bestDst = dstAnchors[0]; + let bestDist = Infinity; + for (const sa of srcAnchors) { + for (const da of dstAnchors) { + const d = Math.hypot(sa.x - da.x, sa.y - da.y); + if (d < bestDist) { bestDist = d; bestSrc = sa; bestDst = da; } + } + } + const x1 = bestSrc.x, y1 = bestSrc.y; + const x2 = bestDst.x, y2 = bestDst.y; + const pathD = elbowPath(x1, y1, x2, y2); + const midX = x1 + (x2 - x1) / 2; + const midY = y1 + (y2 - y1) / 2; + configureTethers.push({ + key: `configure-${p.workflow_id}`, + path: pathD, + labelX: midX, + labelY: midY, + label: 'Configure', + fading: false, + }); + } + + return [...agentTethers, ...browserTethers, ...workflowTethers, ...configureTethers]; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [glowingAgentCards, glowingBrowserCards, cards, browserCards, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]); + }, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, configurePanels, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]); const dotSize = Math.max(1, 1.5 * canvas.zoom); const dotSpacing = 24 * canvas.zoom; @@ -1736,7 +1864,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true <> - {/* Floating header overlay */} = ({ dashboardId, isActive = true right: 0, zIndex: 10, pointerEvents: 'none', - // p: 3 (24px) was leaving a chunky air gap between the sidebar - // edge and the dashboard header that read as "two disconnected - // panels" rather than one continuous surface. 0.75 (6px) - // tightens the inset so the header floats just inside the - // content area without losing its breathing room from the - // top-most pixel. + // 0.75 (6px) keeps the header tight against the sidebar; 24px read as two disconnected panels. p: 0.75, pb: 0, background: `linear-gradient(to bottom, ${c.bg.page} 60%, transparent)`, @@ -1771,7 +1893,6 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true - {/* Canvas viewport */} = ({ dashboardId, isActive = true : 'default', }} > - {/* Dot grid background */} = ({ dashboardId, isActive = true }} /> - {sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 ? ( + {sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 && Object.keys(workflowCards).length === 0 && !workflowsHub ? ( = ({ dashboardId, isActive = true position: 'relative', }} > - {/* Tether lines between branched cards */} {tethers.length > 0 && ( = ({ dashboardId, isActive = true markerHeight="10" orient="auto" > - + - {tethers.map((t) => ( = ({ dashboardId, isActive = true strokeWidth={8} strokeLinecap="round" strokeLinejoin="round" - opacity={0.2} + opacity={0.15} filter="url(#tether-glow-f)" /> = ({ dashboardId, isActive = true strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" - opacity={0.65} markerEnd="url(#tether-arrow)" - style={{ animation: 'tether-pulse 2s ease-in-out infinite' }} - /> - {t.label && ( @@ -2016,9 +2118,7 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true exitTarget={exitTarget} isSelected={isSel} isHighlighted={highlightedCardId === sid} - // Only selected cards need the live drag delta; passing - // it to everyone broke memo equality for unselected - // cards on every mouse-move during multi-drag. + // Only selected cards get live drag delta; passing to all broke memo equality during multi-drag. multiDragDelta={isSel ? multiDragDelta : null} onCardSelect={handleCardSelect} onDragStart={handleCardDragStart} @@ -2089,6 +2189,48 @@ const DashboardInner: React.FC = ({ dashboardId, isActive = true onBringToFront={handleBringToFront} /> ))} + {workflowsHub && ( + + )} + {Object.values(workflowCards).map((wc) => ( + + ))} + {Object.values(configurePanels).map((p) => ( + + ))} {Object.values(notes).map((n) => ( = ({ dashboardId, isActive = true onBringToFront={handleBringToFront} /> ))} - {/* Marquee selection rectangle */} {selection.marquee && (
= ({ dashboardId, isActive = true )} - {/* Floating bottom toolbar */} = ({ dashboardId, isActive = true /> - {/* Arrow navigation hints when zoomed in on a card */} {focusedCardId && canvas.zoom >= 0.4 && ( = ({ dashboardId, isActive = true /> )} - {/* Floating zoom controls + minimap */} = ({ dashboardId, isActive = true - {/* Card search palette (Cmd+F) */} setSearchPaletteOpen(false)} diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index 84808933..099a6605 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -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( 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( 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( 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((key: K, value: AppSettings[K]) => { const current = store.getState().settings; if (!current.loaded) return; @@ -159,6 +153,7 @@ const DashboardToolbar = React.forwardRef( 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( const placeholderItems: Array<{ icon: typeof StickyNote2OutlinedIcon; label: string; sub: string }> = []; return ( + <> ( 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.
(
) : historyOpen ? (
- - - 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 && ( - - )} - - ({ 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 ? ( - - - {historyQuery ? 'No matching chats' : 'No chat history yet'} - - - ) : ( - <> - {historySearch.results.map((entry) => ( - 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 }, - }} - > - - {entry.name} - - - {formatRelativeTime(entry.closed_at)} - - - ))} - {historySearch.loading && historySearch.results.length > 0 && ( - - - - )} - - )} - + onExpand={() => { + // Singleton per dashboard, second Expand brings the existing card forward. + dispatch(openWorkflowsHub({ expandedSessionIds: [] })); + handleCloseHistory(); + }} + historyScrollRef={historyListRef as React.RefObject} + onHistoryScroll={handleHistoryScroll} + />
) : viewPickerOpen ? (
@@ -859,6 +786,7 @@ const DashboardToolbar = React.forwardRef(
)}
+ ); }, ); diff --git a/frontend/src/app/pages/Dashboard/useDashboardSelection.ts b/frontend/src/app/pages/Dashboard/useDashboardSelection.ts index c89590a0..f4b7d111 100644 --- a/frontend/src/app/pages/Dashboard/useDashboardSelection.ts +++ b/frontend/src/app/pages/Dashboard/useDashboardSelection.ts @@ -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, browserCards: Record = {}, notes: Record = {}, + workflowCards: Record = {}, ) { const [selectedIds, setSelectedIds] = useState>(new Map()); const [marquee, setMarquee] = useState(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 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; diff --git a/frontend/src/app/pages/Workflows/ActionsFacet.tsx b/frontend/src/app/pages/Workflows/ActionsFacet.tsx new file mode 100644 index 00000000..63cc626f --- /dev/null +++ b/frontend/src/app/pages/Workflows/ActionsFacet.tsx @@ -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 ( + + + Do you want to prevent the agent from taking actions that weren't used in the original workflow? + + + + + + + Do you want to freeze the actions available to the Agent so this flow always works even if you change your settings? + + + + + + {/* 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 && ( + + + {configuring ? '⚙ Configuring…' : '⚙ Configure'} + + + )} + + ); +} diff --git a/frontend/src/app/pages/Workflows/ConfigurePanelCard.tsx b/frontend/src/app/pages/Workflows/ConfigurePanelCard.tsx new file mode 100644 index 00000000..2db4947f --- /dev/null +++ b/frontend/src/app/pages/Workflows/ConfigurePanelCard.tsx @@ -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 ( + + {/* Drag handle + close X strip across the top. Stays slim so the + full Action Library underneath gets the vertical space. */} + + + Action Library + 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 } }}> + + + + {/* Body: the real Action Library, exact same component as /actions. */} + + + + {/* SE resize handle. */} + + + ); +} diff --git a/frontend/src/app/pages/Workflows/GeneralFacet.tsx b/frontend/src/app/pages/Workflows/GeneralFacet.tsx new file mode 100644 index 00000000..72a4e038 --- /dev/null +++ b/frontend/src/app/pages/Workflows/GeneralFacet.tsx @@ -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 ( + + + 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 }} + /> + + + 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 }} + /> + + + + + {!draft.use_synced_prompt && ( + 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 }} + /> + )} + + Workflow + {sourceSessionId && ( + + + Edit + + )} + + + {draft.steps.map((s, idx) => ( + + {idx + 1} + { + 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 }} + /> + + ))} + + + ); +} diff --git a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx new file mode 100644 index 00000000..55aaa01f --- /dev/null +++ b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx @@ -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 = ( + + Run now + {ctxMenu?.workflow.schedule.enabled ? 'Pause schedule' : 'Resume schedule'} + Edit… + Delete + + ); + // 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(); + 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 ( + + {/* Day headers: muted weekday caps; today's date gets the filled circle */} + + + {!compact && ( + {TZ_LABEL} + )} + + {days.map((d) => { + const isToday = sameDay(d, today); + return ( + + + {WEEKDAY_LABEL_SHORT[d.getDay()]} + + {d.getDate()} + + ); + })} + + + {HOURS.map((hour, hourIdx) => ( + + {/* 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. */} + + {formatHourLabel(hour)} + + {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 ( + { 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' }}> + { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: wf }); }} + /> + + ); + })} + + ))} + + {ctxMenuEl} + + ); + } + + if (view === 'Month') { + const start = startOfMonthGrid(today); + const cells = Array.from({ length: 35 }, (_, i) => addDays(start, i)); + const accent = c.accent.primary; + return ( + + {/* 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. */} + + {WEEKDAY_LABEL_SHORT.map((l, i) => ( + {l} + ))} + + + {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 ( + + + {/* 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. */} + {d.getDate()} + + {evs.slice(0, compact ? 3 : 4).map((e, idx) => ( + 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 } }}> + + {formatTime(e.date.getHours(), e.date.getMinutes())} + {e.workflow.title} + + ))} + {evs.length > (compact ? 3 : 4) && ( + +{evs.length - (compact ? 3 : 4)} more + )} + + ); + })} + + {ctxMenuEl} + + ); + } + + // 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 ( + + {upcoming.length === 0 && ( + No scheduled workflows + )} + {upcoming.map(({ date, events, isToday }, rowIdx) => ( + + + + {date.getDate()} + + + + {date.toLocaleString('en', { month: 'short' })} + + {WEEKDAY_FULL[date.getDay()]} + + + + {events.length === 0 && ( + No events today + )} + {events.map((e, idx) => ( + } placement="right" arrow> + 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 }, + }}> + + + {e.workflow.title} + {formatTime(e.date.getHours(), e.date.getMinutes())} + + + + ))} + + + ))} + {ctxMenuEl} + + ); +} + +// 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(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 ( + <> + } placement="top" arrow> + { + 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' }, + }}> + {first.workflow.title} + {timeLabel} + + + {rest.length > 0 && ( + 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} + + )} + setAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }}> + + + {events.length} runs at this hour + + {events.map((e, idx) => ( + { 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 } }}> + + {e.workflow.title} + {formatTime(e.date.getHours(), e.date.getMinutes())} + + ))} + + + + ); +} + +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 ( + +
{wf.title}
+
{`Fires at ${formatTime(event.date.getHours(), event.date.getMinutes())}`}
+ {status &&
{`Last run: ${status}`}
} + {typeof cost === 'number' && cost > 0 &&
{`Last run cost: $${cost.toFixed(4)}`}
} + {typeof monthly === 'number' && monthly > 0 &&
{`Est. monthly: $${monthly.toFixed(2)}`}
} +
+ ); +} diff --git a/frontend/src/app/pages/Workflows/ScheduleFacet.tsx b/frontend/src/app/pages/Workflows/ScheduleFacet.tsx new file mode 100644 index 00000000..2bce61ff --- /dev/null +++ b/frontend/src/app/pages/Workflows/ScheduleFacet.tsx @@ -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 } { + const [info, setInfo] = useState({ 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) => { + 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) => { + 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 ( + + {/* Master on/off. */} + + setSched({ enabled: e.target.checked })} /> + + {s.enabled ? 'Schedule is on' : 'Schedule is off'} + + + + {s.enabled && ( + + )} + + {/* Section: When should this workflow run? */} + + + When should this workflow run? + + + Repeat every + 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 }} + /> + + + {s.repeat_unit === 'week' && ( + + ↳ on + {WEEKDAY_LABEL.map((label, idx) => { + const active = s.on_days.includes(idx); + return ( + 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} + ); + })} + + )} + + At + + : + + + {friendlyTzLabel(s.timezone)} + + {nextPreview && s.enabled && ( + + Next run: {formatNextRun(nextPreview)} + + )} + + Runs + + {endKind === 'on_date' && ( + { + 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' && ( + + 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 }} + /> + runs ({s.runs_count} so far) + + )} + + {(() => { + if (endKind === 'on_date' && s.ends_at) { + const ends = new Date(s.ends_at).getTime(); + if (!Number.isNaN(ends) && ends <= Date.now()) { + return ( + + This date is in the past. The schedule will turn itself off. + + ); + } + } + if (endKind === 'after_n' && s.max_runs != null && s.runs_count >= s.max_runs) { + return ( + + This workflow has already run {s.runs_count}× (limit {s.max_runs}). Raise the number or reset the counter to re-arm. + + ); + } + return null; + })()} + + If missed + + + + + {/* Section: What can the agent do? */} + + + What can the agent do? + + + + + {/* Section: How should the agent ask for your permission? */} + + + How should the agent ask for your permission? + + {(draft.permissions || []).map((tier, idx) => ( + setTier(idx, patch)} + onRemove={idx === 0 ? undefined : () => removeTier(idx)} + /> + ))} + {canAddBackup && ( + + Escalate if I don't respond + )} + + + ); +} + +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 ( + + + + {good ? 'Will run even if you close OpenSwarm.' : `OpenSwarm must be open at ${fmt} for this to run.`} + + {!good && ( + + Always-on + + )} + + ); +} + +function PermissionRow({ idx, tier, cloudSmsEnabled, onChange, onRemove }: { + idx: number; + tier: PermissionTier; + cloudSmsEnabled: boolean; + onChange: (p: Partial) => void; + onRemove?: () => void; +}) { + const c = useClaudeTokens(); + if (idx === 0) { + return ( + + ); + } + const unitLabel = tier.kind === 'call' ? 'hour' : 'minutes'; + return ( + + + after + 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 }} + /> + {unitLabel} + + + + at + 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 && ( + × + )} + + {!cloudSmsEnabled && ( + + Coming soon. Until cloud SMS ships, this tier falls back to an in-app notify with a "fallback" badge. + + )} + + ); +} diff --git a/frontend/src/app/pages/Workflows/SchedulePopover.tsx b/frontend/src/app/pages/Workflows/SchedulePopover.tsx new file mode 100644 index 00000000..560666c6 --- /dev/null +++ b/frontend/src/app/pages/Workflows/SchedulePopover.tsx @@ -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; + 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(() => 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 = {}; + 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 ( + + {/* Floating mode chips OUTSIDE the content card (Figma image #30) */} + + } active={mode === 'search'} onClick={() => onModeChange('search')} /> + } active={mode === 'schedule'} onClick={() => onModeChange('schedule')} /> + + + {/* 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. */} + + + + {mode === 'search' && ( + + + + 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 } }} + /> + + + New + + + + {historyResults.length === 0 && !historyLoading && ( + {historyQuery ? 'No matching chats' : 'No chat history yet'} + )} + {historyResults.map((entry) => { + const hasWorkflow = Boolean(workflowIconMap[entry.id]); + return ( + onHistorySelect(entry.id)} sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.9, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}> + {entry.name} + {/* 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 && ( + + + + + + )} + {relTime(entry.closed_at)} + + ); + })} + + + )} + + {mode === 'schedule' && ( + + + {(['Week', 'Month', 'List'] as const).map((v) => ( + 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} + ))} + + + + Expand + + + {/* Period nav: Today pill, prev/next chevrons, range label. + Apple Calendar pattern. Keeps the popover usable without + forcing a full Expand for date browsing. */} + + 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 + + + {periodLabel} + + + + + + )} + + + + + ); +} + +// 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 ( + + {icon} + {label} + + ); +} + +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`; +} diff --git a/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx b/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx new file mode 100644 index 00000000..d02fedde --- /dev/null +++ b/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx @@ -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; +}; + +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: