[aidan] feat/workflows: launch-time scheduling UX and workflow-card polish (#101)

* [aidan] feat/schedule-list: lazy-load list view via scroll sentinel

* [aidan] feat/missed-runs: launch toast with per-workflow counts and pan-to-card

* [aidan] fix/dashboard-tethers: keep watching line anchored on canvas zoom

* [aidan] feat/scheduled-tasks: review missed runs at launch instead of auto-firing on_missed

* [aidan] refactor/workflow-cards: use radius and status design tokens, polish card chrome

* [aidan] ux/agent-card: keep convert-to-workflow visible during runs with mid-turn toast

* [aidan] ux/mcp-bubble: drop redundant verb label when a workflow label is shown

* [aidan] chore/backend: remove stale explanatory comments

* [aidan] fix/workflows-hub: load workflows on hub mount so calendar fills at launch

* [aidan] feat/workflows: generate title, description, step labels at convert time

* [aidan] fix/tidy-layout: include workflows hub in tidy and fit-to-view

* [aidan] feat/schedule-list: window long list via measured-height virtualizer

* [aidan] ux/workflows-hub: remove time-saved badge from calendar header

* [aidan] fix/types: add missing semantic-type labels and drop stray fade arg
This commit is contained in:
Aidan
2026-06-19 20:10:34 -07:00
committed by GitHub
parent 7daa8d573b
commit 6229463ce4
36 changed files with 1463 additions and 331 deletions
@@ -98,6 +98,8 @@ export function appendSelectedElements(trimmed: string, selectedEls: SelectedEle
'tool-group': 'Tool Group',
'view-card': 'App Card',
'browser-card': 'Browser Card',
'workflow-card': 'Workflow Card',
'workflows-hub-card': 'Workflows Hub',
'dom-element': 'Element',
}[el.semanticType] || el.semanticType;
lines.push(`${i + 1}. [${typeLabel}] ${el.semanticLabel || ''}`);
@@ -58,6 +58,7 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
const inputSummary = mcpInfo.isMcp ? getMcpInputSummary(input, mcpInfo.action, mcpInfo.serverSlug) : '';
const visibleSummary = resultSummary || inputSummary;
const canToggleDetails = !!visibleSummary;
const hideVerbLabel = !!workflowLabel && !!visibleSummary;
const ServiceIcon = mcpInfo.isMcp && mcpInfo.service
? <GoogleServiceIcon service={mcpInfo.service} size={14} />
: null;
@@ -78,20 +79,22 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
}}
>
{ServiceIcon}
<Typography
sx={{
color: c.accent.primary,
fontSize: '0.78rem',
fontWeight: 600,
flexShrink: 0,
}}
>
{serviceLabel}
</Typography>
{!hideVerbLabel && (
<Typography
sx={{
color: c.accent.primary,
fontSize: '0.78rem',
fontWeight: 600,
flexShrink: 0,
}}
>
{serviceLabel}
</Typography>
)}
{visibleSummary && !isError && (
<Typography
sx={{
color: c.text.secondary,
color: hideVerbLabel ? c.text.primary : c.text.secondary,
fontSize: '0.74rem',
flex: 1,
minWidth: 0,
@@ -6,6 +6,7 @@ import BrowserCard from '../cards/BrowserCard';
import NoteCard from '../cards/NoteCard';
import WorkflowCard from '@/app/pages/Workflows/WorkflowCard';
import WorkflowsHubCard from '@/app/pages/Workflows/WorkflowsHubCard';
import MissedRunsCard from '@/app/pages/Workflows/MissedRunsCard';
import ConfigurePanelCard from '@/app/pages/Workflows/ConfigurePanelCard';
import {
EXPANDED_CARD_MIN_H,
@@ -19,6 +20,7 @@ import {
type WorkflowsHubPosition,
type ConfigurePanelPosition,
} from '@/shared/state/dashboardLayoutSlice';
import { useAppSelector } from '@/shared/hooks';
import type { Output } from '@/shared/state/outputsSlice';
import type { CardType, useDashboardSelection } from '../hooks/state/useDashboardSelection';
@@ -100,6 +102,9 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
onBranch,
onMeasuredHeight,
}) => {
// Ephemeral singleton, not part of the saved layout, so read it straight
// from the store rather than threading it through the selector chain.
const missedRunsCard = useAppSelector((s) => s.dashboardLayout.missedRunsCard);
return (
<>
<AnimatePresence>
@@ -286,6 +291,26 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
onBringToFront={onBringToFront}
/>
)}
{missedRunsCard && (
<MissedRunsCard
cardX={missedRunsCard.x}
cardY={missedRunsCard.y}
cardWidth={missedRunsCard.width}
cardHeight={missedRunsCard.height}
cardZOrder={missedRunsCard.zOrder ?? 0}
zoom={zoom}
panX={panX}
panY={panY}
isSelected={selection.isSelected('missed-runs')}
isHighlighted={highlightedCardId === 'missed-runs'}
multiDragDelta={selection.isSelected('missed-runs') ? multiDragDelta : null}
onCardSelect={onCardSelect}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onBringToFront={onBringToFront}
/>
)}
{Object.values(workflowCards).map((wc) => (
<WorkflowCard
key={`workflow-${wc.workflow_id}`}
@@ -6,6 +6,7 @@ import CanvasControls from '../controls/CanvasControls';
import CardSearchPalette from '../controls/CardSearchPalette';
import DirectionHints from '../controls/DirectionHints';
import WorkflowRunningToast from '@/app/pages/Workflows/WorkflowRunningToast';
import MissedRunsToast from '@/app/pages/Workflows/MissedRunsToast';
import type { AgentSession } from '@/shared/state/agentsSlice';
import type {
CardPosition,
@@ -151,6 +152,9 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
{/* Scheduled-run nudge: "your {workflow} is running now" + jump-to-canvas */}
<WorkflowRunningToast />
{/* Launch nudge when scheduled runs elapsed while the app was closed */}
<MissedRunsToast />
</>
);
};
@@ -43,7 +43,7 @@ import { useDashboardActive } from '@/shared/hooks/useDashboardActive';
import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough';
import { useStreamingMessage } from '@/shared/state/streamingSlice';
import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canvasInteractionState';
import { openWorkflowCard, setCardSidecar, type Workflow } from '@/shared/state/workflowsSlice';
import { openWorkflowCard, updateWorkflowCard, generateWorkflowMetadata, applyGeneratedMetadata, setCardSidecar, type Workflow } from '@/shared/state/workflowsSlice';
import { addWorkflowCard, setWorkflowCardPosition, setWorkflowCardSize } from '@/shared/state/dashboardLayoutSlice';
import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined';
import { getAgentWorkTime, fmtSeconds } from '@/shared/agentWorkTime';
@@ -432,8 +432,11 @@ const AgentCard: React.FC<Props> = ({
workflowId: draftId,
sourceSessionId: session.id,
view: 'preview',
metaLoading: true,
draft: {
title: session.name || 'New workflow',
// Empty so the header shows its calm "New workflow" placeholder while
// naming runs, instead of flashing the stale chat name.
title: '',
description: '',
steps,
source_session_id: session.id,
@@ -443,6 +446,15 @@ const AgentCard: React.FC<Props> = ({
suggested_cadence: workflowSuggestion?.cadence || undefined,
} as Partial<Workflow>,
}));
const genModel = defaultModel || session.model;
dispatch(generateWorkflowMetadata({ steps: steps.map((s) => ({ id: s.id, text: s.text })), model: genModel }))
.then((r) => {
if (generateWorkflowMetadata.fulfilled.match(r)) {
dispatch(applyGeneratedMetadata({ workflowId: draftId, meta: r.payload }));
} else {
dispatch(updateWorkflowCard({ workflowId: draftId, patch: { metaLoading: false } }));
}
});
}, [
cardHeight,
cardWidth,
@@ -494,7 +506,7 @@ const AgentCard: React.FC<Props> = ({
suggestionPulseRef.current = key;
}
setSuggestGlowCycle((n) => n + 1);
dispatch(fadeGlowingAgentCard(session.id, 3200));
dispatch(fadeGlowingAgentCard(session.id));
}, [workflowSuggestion, canConvertToWorkflow, dispatch, session.id]);
// When the agent schedules a workflow from this chat, pop its card open
@@ -74,35 +74,6 @@ function rectCenter(r: CanvasRect): { x: number; y: number } {
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
}
function selectCardElement(content: HTMLElement, type: 'agent-card' | 'workflow-card', id: string): HTMLElement | null {
const candidates = content.querySelectorAll<HTMLElement>(`[data-select-type="${type}"]`);
for (const el of Array.from(candidates)) {
if (el.dataset.selectId === id) return el;
}
return null;
}
function measuredCanvasRect(
contentRef: RefObject<HTMLElement>,
zoom: number,
type: 'agent-card' | 'workflow-card',
id: string,
): CanvasRect | null {
const content = contentRef.current;
if (!content) return null;
const el = selectCardElement(content, type, id);
if (!el) return null;
const contentRect = content.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const z = zoom || 1;
return {
x: (elRect.left - contentRect.left) / z,
y: (elRect.top - contentRect.top) / z,
width: elRect.width / z,
height: elRect.height / z,
};
}
interface UseTethersArgs {
glowingAgentCards: Record<string, GlowingAgentCard>;
glowingBrowserCards: Record<string, GlowingBrowserCard>;
@@ -116,8 +87,6 @@ interface UseTethersArgs {
liveDragInfo: LiveDragInfo | null;
measuredHeightsRef: RefObject<Record<string, number>>;
measuredHeightsTick: number;
contentRef: RefObject<HTMLElement>;
zoom: number;
sessionList: AgentSession[];
}
@@ -134,8 +103,6 @@ export function useTethers({
liveDragInfo,
measuredHeightsRef,
measuredHeightsTick,
contentRef,
zoom,
sessionList,
}: UseTethersArgs): Tether[] {
return useMemo(() => {
@@ -395,10 +362,8 @@ export function useTethers({
? Math.max(EXPANDED_CARD_MIN_H, sidecar.height)
: sidecar.height);
const wcH = wfHeight(wc);
const measuredWorkflow = measuredCanvasRect(contentRef, zoom, 'workflow-card', wc.workflow_id);
const measuredSidecar = measuredCanvasRect(contentRef, zoom, 'agent-card', sidecarId);
const workflowRect = measuredWorkflow ?? { x: srcX, y: srcY, width: wc.width, height: wcH };
const sidecarRect = measuredSidecar ?? { x: dstX, y: dstY, width: sidecar.width, height: dstH };
const workflowRect = { x: srcX, y: srcY, width: wc.width, height: wcH };
const sidecarRect = { x: dstX, y: dstY, width: sidecar.width, height: dstH };
const srcCenter = rectCenter(workflowRect);
const dstCenter = rectCenter(sidecarRect);
const a = borderPoint(workflowRect.x, workflowRect.y, workflowRect.width, workflowRect.height, dstCenter.x, dstCenter.y);
@@ -471,5 +436,5 @@ export function useTethers({
// measuredHeightsTick re-runs the memo once ResizeObserver reports a new
// height after a collapse (the ref read is invisible to the dep checker).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, configurePanels, expandedSessionIds, liveDragInfo, measuredHeightsTick, contentRef, zoom, sessionList]);
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, configurePanels, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList]);
}
@@ -123,7 +123,10 @@ export function useDashboardCardActions({
dispatch(tidyLayout({ expandedSessionIds: currentExpanded }));
const expandedSet = new Set(currentExpanded);
const { cards: tidied, viewCards: tidiedViews, browserCards: tidiedBrowsers } = store.getState().dashboardLayout;
const {
cards: tidied, viewCards: tidiedViews, browserCards: tidiedBrowsers,
workflowCards: tidiedWorkflows, workflowsHub: tidiedHub,
} = store.getState().dashboardLayout;
const allRects = [
...Object.values(tidied).map((c) => ({
x: c.x, y: c.y, width: c.width,
@@ -131,6 +134,8 @@ export function useDashboardCardActions({
})),
...Object.values(tidiedViews).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
...Object.values(tidiedBrowsers).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
...Object.values(tidiedWorkflows).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
...(tidiedHub ? [{ x: tidiedHub.x, y: tidiedHub.y, width: tidiedHub.width, height: tidiedHub.height }] : []),
];
canvasActions.fitToCards(allRects);
}, [dispatch, canvasActions]);
@@ -17,17 +17,23 @@ import {
clearPendingFocusBrowserId,
clearPendingFocusWorkflowId,
clearPendingFocusWorkflowsHub,
clearPendingFocusMissedRuns,
type ViewCardPosition,
} from '@/shared/state/dashboardLayoutSlice';
import { fetchOutputs, type Output } from '@/shared/state/outputsSlice';
import { generateDashboardName } from '@/shared/state/dashboardsSlice';
import { fetchWorkflows } from '@/shared/state/workflowsSlice';
import { fetchMissedRuns } from '@/shared/state/missedRunsSlice';
import { dashboardWs } from '@/shared/ws/WebSocketManager';
import { initBrowserCommandHandler } from '@/shared/browserCommandHandler';
import { clearPendingBrowserUrl, clearPendingFocusAgentId } from '@/shared/state/tempStateSlice';
import { API_BASE } from '@/shared/config';
import type { CanvasActions } from '../interaction/useCanvasControls';
// Module-level so the missed-runs review pops exactly once per app launch,
// not again on every dashboard switch.
let missedRunsCheckedThisSession = false;
interface UseDashboardLifecycleArgs {
isActive: boolean;
dashboardId: string;
@@ -64,8 +70,18 @@ export function useDashboardLifecycle({
const pendingFocusAgentId = useAppSelector((state) => state.tempState.pendingFocusAgentId);
const pendingFocusBrowserId = useAppSelector((state) => state.dashboardLayout.pendingFocusBrowserId);
const pendingFocusWorkflowId = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowId);
const pendingFocusMissedRuns = useAppSelector((state) => state.dashboardLayout.pendingFocusMissedRuns);
const pendingFocusWorkflowsHub = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowsHub);
// Once per app launch: if scheduled fires elapsed while we were closed, fetch
// them. The slice flips its toast flag on fulfilled, so a bottom-left nudge
// shows instead of a card popping unrequested; the user opens the card from it.
useEffect(() => {
if (!isActive || missedRunsCheckedThisSession) return;
missedRunsCheckedThisSession = true;
dispatch(fetchMissedRuns());
}, [isActive, dispatch]);
// Track dashboard engagement time
useEffect(() => {
if (!dashboardId) return;
@@ -248,6 +264,24 @@ export function useDashboardLifecycle({
}, 200);
}, [isActive, pendingFocusWorkflowId, layoutInitialized, dispatch, canvasActions, handleHighlightCard]);
// Same pan/highlight choreography when the missed-runs card opens from its toast.
useEffect(() => {
if (!isActive) return;
if (!pendingFocusMissedRuns || !layoutInitialized) return;
dispatch(clearPendingFocusMissedRuns());
setTimeout(() => {
const card = store.getState().dashboardLayout.missedRunsCard;
if (card) {
canvasActions.fitToCards(
[{ x: card.x, y: card.y, width: card.width, height: card.height }],
1.15,
true,
);
handleHighlightCard('missed-runs');
}
}, 200);
}, [isActive, pendingFocusMissedRuns, layoutInitialized, dispatch, canvasActions, handleHighlightCard]);
// Pan/zoom to Workflows Hub on Expand; chained rAFs ensure fit runs after the hub div lands at its new coords.
useEffect(() => {
if (!isActive) return;
@@ -288,8 +288,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
liveDragInfo,
measuredHeightsRef,
measuredHeightsTick,
contentRef: canvas.contentRef,
zoom: canvas.zoom,
sessionList,
});
@@ -213,7 +213,7 @@ export default function EditAgentView({ workflow, steps, isFixMode = false, onEd
title={canSave ? undefined : 'Add at least one step before saving'}
sx={{
fontSize: '0.8rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary,
px: 1.2, py: 0.35, borderRadius: 999, cursor: canSave ? 'pointer' : 'not-allowed',
px: 1.2, py: 0.35, borderRadius: c.radius.full, cursor: canSave ? 'pointer' : 'not-allowed',
opacity: canSave ? 1 : 0.45,
'&:hover': { filter: 'brightness(1.05)' },
}}>
@@ -0,0 +1,331 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Checkbox from '@mui/material/Checkbox';
import CloseIcon from '@mui/icons-material/Close';
import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
closeMissedRunsCard,
setMissedRunsCardPosition,
} from '@/shared/state/dashboardLayoutSlice';
import {
runMissedRuns,
dismissMissedRuns,
type MissedRunItem,
} from '@/shared/state/missedRunsSlice';
// Above this many selected, "Run" asks once before firing: each missed run is
// a real agent run, so a fat-fingered Run-all shouldn't quietly spend money.
const CONFIRM_THRESHOLD = 10;
interface Props {
cardX: number;
cardY: number;
cardWidth: number;
cardHeight: number;
cardZOrder?: number;
zoom?: number;
panX?: number;
panY?: number;
isSelected?: boolean;
isHighlighted?: boolean;
multiDragDelta?: { dx: number; dy: number } | null;
onCardSelect?: (id: string, type: 'missed_runs', shiftKey: boolean) => void;
onDragStart?: (id: string, type: 'missed_runs') => void;
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
onBringToFront?: (id: string, type: 'missed_runs') => void;
}
function formatWhen(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString(undefined, {
weekday: 'short', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit',
});
}
const MissedRunsCard: React.FC<Props> = ({
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
zoom = 1, panX = 0, panY = 0,
isSelected = false, isHighlighted = false, multiDragDelta = null,
onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const items = useAppSelector((s) => s.missedRuns.items);
// Unchecked ids; default is everything checked. Run acts on the checked set.
const [unchecked, setUnchecked] = useState<Set<string>>(new Set());
const [confirming, setConfirming] = useState(false);
const selectedIds = useMemo(
() => items.filter((m) => !unchecked.has(m.id)).map((m) => m.id),
[items, unchecked],
);
const groups = useMemo(() => {
const by = new Map<string, { title: string; runs: MissedRunItem[] }>();
for (const m of items) {
const g = by.get(m.workflow_id) || { title: m.workflow_title, runs: [] };
g.runs.push(m);
by.set(m.workflow_id, g);
}
return Array.from(by.values());
}, [items]);
// Once everything has been run or dismissed, the card has nothing left to say.
useEffect(() => {
if (items.length === 0) dispatch(closeMissedRunsCard());
}, [items.length, dispatch]);
const toggle = useCallback((id: string) => {
setConfirming(false);
setUnchecked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const runSelected = useCallback(() => {
if (selectedIds.length === 0) return;
if (selectedIds.length > CONFIRM_THRESHOLD && !confirming) {
setConfirming(true);
return;
}
setConfirming(false);
dispatch(runMissedRuns(selectedIds));
}, [dispatch, selectedIds, confirming]);
// Closing means "I'm done": drop whatever's still listed, logged as skipped.
const closeAndDismissRest = useCallback(() => {
const rest = items.map((m) => m.id);
if (rest.length) dispatch(dismissMissedRuns(rest));
dispatch(closeMissedRunsCard());
}, [dispatch, items]);
// ---- Card drag via header (mirrors WorkflowsHubCard) ----
const DRAG_THRESHOLD = 3;
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null);
const didDrag = useRef(false);
const justDraggedRef = useRef(false);
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const onHeaderPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('[data-no-drag], button, [role="button"], input')) return;
e.preventDefault();
e.stopPropagation();
dragState.current = {
startX: e.clientX, startY: e.clientY,
origX: cardX, origY: cardY,
startPanX: panRef.current.panX, startPanY: panRef.current.panY,
};
didDrag.current = false;
setIsDragging(true);
onDragStart?.('missed-runs', 'missed_runs');
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, [cardX, cardY, onDragStart]);
const onHeaderPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const rawDx = e.clientX - dragState.current.startX;
const rawDy = e.clientY - dragState.current.startY;
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
didDrag.current = true;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const dx = rawDx / z - panDx;
const dy = rawDy / z - panDy;
setLocalDragPos({ x: dragState.current.origX + dx, y: dragState.current.origY + dy });
onDragMove?.(dx, dy, e.clientX, e.clientY);
}, [onDragMove]);
const onHeaderPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
justDraggedRef.current = true;
setTimeout(() => { justDraggedRef.current = false; }, 0);
let finalX = dragState.current.origX + dx;
let finalY = dragState.current.origY + dy;
if (!e.shiftKey) {
finalX = Math.round(finalX / 24) * 24;
finalY = Math.round(finalY / 24) * 24;
}
dispatch(setMissedRunsCardPosition({ x: finalX, y: finalY }));
}
onDragEnd?.(dx, dy, didDrag.current);
dragState.current = null;
didDrag.current = false;
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, onDragEnd]);
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0;
const dx = (localDragPos?.x ?? cardX) + mdDx;
const dy = (localDragPos?.y ?? cardY) + mdDy;
const border = isHighlighted
? `2px solid ${c.accent.primary}`
: isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.strong}`;
const shadow = isDragging ? c.shadow.lg : isSelected ? `0 0 0 1px #3b82f6, ${c.shadow.md}` : c.shadow.sm;
const runLabel = confirming
? `Run ${selectedIds.length} now?`
: selectedIds.length === items.length
? `Run all ${items.length}`
: `Run ${selectedIds.length} selected`;
return (
<Box
data-select-type="missed-runs-card"
data-select-id="missed-runs"
onPointerDownCapture={(e: React.PointerEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-no-drag]')) return;
onBringToFront?.('missed-runs', 'missed_runs');
}}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
const target = e.target as HTMLElement;
if (target.closest('[data-no-drag]')) return;
onCardSelect?.('missed-runs', 'missed_runs', e.shiftKey);
}}
sx={{
position: 'absolute',
contain: 'layout style',
willChange: 'transform',
left: dx,
top: dy,
width: cardWidth,
height: cardHeight,
bgcolor: c.bg.surface,
border,
borderRadius: 3,
boxShadow: shadow,
display: 'flex',
flexDirection: 'column',
zIndex: isDragging ? 999999 : cardZOrder,
transition: isDragging ? 'none' : 'box-shadow 0.3s ease, border-color 0.2s ease',
}}
>
{/* Title strip (drag handle) */}
<Box
onPointerDown={onHeaderPointerDown}
onPointerMove={onHeaderPointerMove}
onPointerUp={onHeaderPointerUp}
sx={{
display: 'flex', alignItems: 'center', gap: 0.6,
px: 1.5, py: 0.7,
borderBottom: `1px solid ${c.border.subtle}`,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none', userSelect: 'none', flexShrink: 0,
}}
>
<HistoryRoundedIcon sx={{ fontSize: 17, color: c.accent.primary }} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontWeight: 600, fontSize: '0.95rem', color: c.text.primary }}>Missed while you were away</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>
{items.length} run{items.length === 1 ? '' : 's'} didn&apos;t fire. Run the ones you still want.
</Typography>
</Box>
<IconButton
size="small"
data-no-drag
onClick={(e) => { e.stopPropagation(); closeAndDismissRest(); }}
onPointerDown={(e) => e.stopPropagation()}
sx={{ p: 0.5, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
{/* Scrollable list grouped by workflow */}
<Box sx={{ flex: 1, overflowY: 'auto', px: 1, py: 0.5 }}>
{groups.map((g) => (
<Box key={g.title + g.runs[0].workflow_id} sx={{ mb: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, px: 0.75, py: 0.4 }}>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 700, color: c.accent.primary, flexShrink: 0 }}>{g.runs.length}</Typography>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.secondary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{g.title}</Typography>
</Box>
{g.runs.map((m) => (
<Box
key={m.id}
data-no-drag
onClick={() => toggle(m.id)}
sx={{
display: 'flex', alignItems: 'center', gap: 0.4,
pl: 1, pr: 0.75, py: 0.15, ml: 1.5,
borderRadius: `${c.radius.sm}px`, cursor: 'pointer',
'&:hover': { bgcolor: c.bg.elevated },
}}
>
<Checkbox
size="small"
checked={!unchecked.has(m.id)}
onChange={() => toggle(m.id)}
onClick={(e) => e.stopPropagation()}
sx={{ p: 0.25, color: c.text.muted, '&.Mui-checked': { color: c.accent.primary } }}
/>
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary }}>{formatWhen(m.scheduled_for)}</Typography>
</Box>
))}
</Box>
))}
</Box>
{/* Footer actions */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, px: 1.25, py: 0.85, borderTop: `1px solid ${c.border.subtle}`, flexShrink: 0 }}>
{confirming && (
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, flex: 1 }}>
That&apos;s {selectedIds.length} real runs.
</Typography>
)}
{!confirming && <Box sx={{ flex: 1 }} />}
<Box
data-no-drag
role="button"
onClick={confirming ? () => setConfirming(false) : closeAndDismissRest}
sx={{ fontSize: '0.78rem', color: c.text.muted, cursor: 'pointer', px: 1, py: 0.5, '&:hover': { color: c.text.primary } }}
>
{confirming ? 'Cancel' : 'Skip the rest'}
</Box>
<Box
data-no-drag
role="button"
onClick={runSelected}
sx={{
fontSize: '0.78rem', fontWeight: 600,
color: selectedIds.length === 0 ? c.text.ghost : '#fff',
bgcolor: selectedIds.length === 0 ? c.bg.secondary : c.accent.primary,
cursor: selectedIds.length === 0 ? 'default' : 'pointer',
px: 1.25, py: 0.5, borderRadius: `${c.radius.md}px`,
'&:hover': { bgcolor: selectedIds.length === 0 ? c.bg.secondary : c.accent.hover },
}}
>
{runLabel}
</Box>
</Box>
</Box>
);
};
export default MissedRunsCard;
@@ -0,0 +1,63 @@
// Bottom-left nudge shown on launch when scheduled runs elapsed while the app
// was closed. It stays put until the user acts (no auto-hide): Review opens and
// pans the canvas to the missed-runs card; clicking away or the X dismisses it.
import React from 'react';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { hideMissedRunsToast } from '@/shared/state/missedRunsSlice';
import { openMissedRunsCard } from '@/shared/state/dashboardLayoutSlice';
export default function MissedRunsToast() {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const open = useAppSelector((s) => s.missedRuns.toastOpen);
const count = useAppSelector((s) => s.missedRuns.items.length);
const onReview = React.useCallback(() => {
dispatch(openMissedRunsCard(undefined));
dispatch(hideMissedRunsToast());
}, [dispatch]);
return (
<Snackbar
open={open && count > 0}
autoHideDuration={null}
onClose={() => dispatch(hideMissedRunsToast())}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Alert
icon={false}
severity="info"
sx={{
bgcolor: c.bg.surface,
color: c.text.primary,
border: `1px solid ${c.border.medium}`,
'& .MuiAlert-action': { alignItems: 'center', pt: 0 },
}}
action={
<>
<Button size="small" onClick={onReview} sx={{ color: c.accent.primary, fontWeight: 700 }}>
Review
</Button>
<IconButton
size="small"
aria-label="Dismiss"
onClick={() => dispatch(hideMissedRunsToast())}
sx={{ color: c.text.muted, ml: 0.25, '&:hover': { color: c.text.primary } }}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</>
}
>
{`${count} scheduled run${count === 1 ? '' : 's'} ${count === 1 ? 'was' : 'were'} missed while you were away`}
</Alert>
</Snackbar>
);
}
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
@@ -12,6 +12,7 @@ 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, formatTime, formatHourLabel, stepsSignature } from './scheduleUtils';
import { useWindowedList } from '@/shared/hooks/useWindowedList';
interface Props {
view: 'Week' | 'Month' | 'List';
@@ -25,11 +26,24 @@ interface Props {
// starting hour. The scroll container caps the visible window.
const HOURS_24 = Array.from({ length: 24 }, (_, i) => i);
// At/above this many list rows (day headers + event rows), window the list so
// only near-viewport rows stay mounted. Below it, render whole; spacers aren't
// worth the churn on a short list.
const LIST_WINDOW_MIN_ROWS = 60;
interface CalendarEvent {
workflow_id: string;
fire_at: string;
}
// One flattened list row. Windowing unmounts at this granularity, so a dense
// single day no longer mounts all ~96 of its rows just for being near the
// viewport: only the rows actually in view (plus buffer) stay in the DOM.
type ListRow =
| { kind: 'header'; id: string; date: Date; isToday: boolean }
| { kind: 'event'; id: string; ev: { workflow: Workflow; date: Date } }
| { kind: 'empty'; id: string };
export default function ScheduleCalendar({ view, density, onSelectWorkflow, refDate }: Props) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
@@ -106,18 +120,32 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
const rangeEndExclusive = useMemo(() => addDays(rangeStart, range), [rangeStart, range]);
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
const [calendarFetchKey, setCalendarFetchKey] = useState('');
// Key off only the fields that change which occurrences exist. Deliberately
// NOT updated_at: the scheduler bumps it every tick (recomputing next_run_at)
// and pushes a workflow:updated over the socket, which would churn this key
// and blank the calendar (the eventsByDay gate) until the next fetch lands.
const workflowScheduleKey = workflows
.map((w) => `${w.id}:${w.updated_at}:${w.schedule.enabled}:${w.schedule.timezone}:${w.schedule.repeat_unit}:${w.schedule.repeat_every}:${w.schedule.hour}:${w.schedule.minute}:${w.schedule.on_days.join(',')}:${w.schedule.ends_at || ''}:${w.schedule.max_runs ?? ''}:${w.schedule.runs_count}`)
.map((w) => `${w.id}:${w.schedule.enabled}:${w.schedule.timezone}:${w.schedule.repeat_unit}:${w.schedule.repeat_every}:${w.schedule.hour}:${w.schedule.minute}:${w.schedule.on_days.join(',')}:${w.schedule.ends_at || ''}:${w.schedule.max_runs ?? ''}:${w.schedule.runs_count}`)
.sort()
.join('|');
const fromIso = rangeStart.toISOString();
const toIso = rangeEndExclusive.toISOString();
const calendarRequestKey = `${view}:${fromIso}:${toIso}:${workflowScheduleKey}`;
// The visible window alone decides whether shown events are even plausible.
// Gating on this (not the full request key) means a schedule edit refetches
// without blanking the calendar first: we keep the current events until the
// fresh ones land. Only a view/date change, where old events are for the
// wrong window, clears them.
const calendarWindowKey = `${view}:${fromIso}:${toIso}`;
useEffect(() => {
// No AbortController: the global fetch interceptor (shared/config) dedupes
// GETs by URL onto ONE underlying request, so aborting on cleanup (which
// fires when this effect re-runs as workflows hydrate) rejects the shared
// request and the re-fired fetch with it, leaving the calendar empty on
// first load. The `cancelled` guard already stops stale state writes.
let cancelled = false;
const ctrl = new AbortController();
fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}`, { signal: ctrl.signal })
fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}`)
.then((res) => {
if (!res.ok) throw new Error(`calendar failed ${res.status}`);
return res.json();
@@ -125,22 +153,20 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
.then((data) => {
if (cancelled) return;
setCalendarEvents((data.events || []) as CalendarEvent[]);
setCalendarFetchKey(calendarRequestKey);
setCalendarFetchKey(calendarWindowKey);
})
.catch(() => {
if (cancelled) return;
setCalendarEvents([]);
setCalendarFetchKey(calendarRequestKey);
setCalendarFetchKey(calendarWindowKey);
});
return () => {
cancelled = true;
ctrl.abort();
};
}, [fromIso, toIso, calendarRequestKey]);
const eventsByDay = useMemo(() => {
const map = new Map<string, { workflow: Workflow; date: Date }[]>();
if (calendarFetchKey !== calendarRequestKey) {
if (calendarFetchKey !== calendarWindowKey) {
return { map, start: rangeStart, end: rangeEndExclusive, key: calendarFetchKey };
}
const workflowById = new Map(workflows.map((wf) => [wf.id, wf]));
@@ -158,7 +184,51 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
arr.sort((a, b) => a.date.getTime() - b.date.getTime());
}
return { map, start: rangeStart, end: rangeEndExclusive, key: calendarFetchKey };
}, [calendarEvents, calendarFetchKey, calendarRequestKey, workflows, rangeStart, rangeEndExclusive]);
}, [calendarEvents, calendarFetchKey, calendarWindowKey, workflows, rangeStart, rangeEndExclusive]);
// List view can fan out to ~1300 rows for a dense schedule (every 15 min over
// 14 days). Flatten days into rows and window at the row level so off-screen
// rows unmount instead of weighing the whole app down. Computed up here (not
// in the List branch) so the windowing hook runs before the Week/Month early
// returns.
const upcoming = useMemo(() => {
const out: { 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) out.push({ date: day, events: arr, isToday });
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventsByDay, dayKey]);
const rows = useMemo<ListRow[]>(() => {
const out: ListRow[] = [];
for (const day of upcoming) {
const iso = day.date.toISOString();
out.push({ kind: 'header', id: `h:${iso}`, date: day.date, isToday: day.isToday });
if (day.events.length === 0) {
out.push({ kind: 'empty', id: `x:${iso}` });
} else {
for (const ev of day.events) {
out.push({ kind: 'event', id: `${iso}#${ev.workflow.id}#${ev.date.getTime()}`, ev });
}
}
}
return out;
}, [upcoming]);
const rowIds = useMemo(() => rows.map((r) => r.id), [rows]);
const estimateRowHeight = useCallback((index: number) => {
const r = rows[index];
if (!r) return 41;
return r.kind === 'header' ? 52 : r.kind === 'empty' ? 36 : 41;
}, [rows]);
const windowing = useWindowedList({
ids: rowIds,
estimateHeight: estimateRowHeight,
enabled: view === 'List' && rows.length >= LIST_WINDOW_MIN_ROWS,
});
const SLOT_H = compact ? 32 : 44;
const ROW_LABEL = compact ? '0.7rem' : '0.74rem';
@@ -337,69 +407,81 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
);
}
// 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, now);
if (arr.length || isToday) upcoming.push({ date: day, events: arr, isToday });
}
// Apple-Calendar-style list: each day is a stacked group with the date as a
// header and its events listed underneath, so a busy day stays readable top
// to bottom instead of crammed beside a date column. Today renders even with
// no events (shows a "No events today" placeholder)
// so the list doesn't feel empty for new users. Off-screen day groups
// unmount (useWindowedList) and leave a measured-height spacer behind, so a
// dense schedule stays light no matter how far down you scroll.
const accent = c.accent.primary;
const visibleRows = rows.slice(windowing.start, windowing.end);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.lg}px`, overflow: 'hidden', bgcolor: c.bg.surface }}>
{upcoming.length === 0 && (
<Box
ref={windowing.setScrollEl}
onScroll={windowing.onScroll}
sx={{ display: 'flex', flexDirection: 'column', maxHeight: '100%', overflow: 'auto', overflowAnchor: 'auto', bgcolor: c.bg.surface }}>
{rows.length === 0 && (
<Typography sx={{ fontSize: '0.85rem', color: c.text.muted, textAlign: 'center', py: 3 }}>No scheduled</Typography>
)}
{upcoming.map(({ date, events, isToday }, rowIdx) => (
<Box
key={date.toISOString()}
sx={{
display: 'flex', alignItems: 'stretch',
borderTop: rowIdx === 0 ? 'none' : `1px dashed ${c.border.subtle}`,
minHeight: 64,
}}>
<Box sx={{ width: 96, flexShrink: 0, display: 'flex', alignItems: 'center', gap: 0.75, pl: 2, pr: 1.25 }}>
<Typography sx={{ fontSize: '1.55rem', fontWeight: 600, color: isToday ? accent : c.text.primary, lineHeight: 1, letterSpacing: '-0.01em' }}>
{date.getDate()}
</Typography>
<Box>
<Typography sx={{ fontSize: '0.78rem', color: isToday ? accent : c.text.secondary, fontWeight: 500, lineHeight: 1.2 }}>
{date.toLocaleString('en', { month: 'short' })}
{windowing.topSpacer > 0 && (
<Box aria-hidden sx={{ height: windowing.topSpacer, flexShrink: 0, overflowAnchor: 'none' }} />
)}
{visibleRows.map((row, i) => {
const rowIdx = windowing.start + i;
if (row.kind === 'header') {
return (
<Box
key={row.id}
data-wl-id={row.id}
sx={{
display: 'flex', alignItems: 'baseline', gap: 0.75,
px: 2, pt: rowIdx === 0 ? 1.5 : 2, pb: 0.5,
borderTop: rowIdx === 0 ? 'none' : `1px dashed ${c.border.subtle}`,
}}>
<Typography sx={{ fontSize: '1.15rem', fontWeight: 700, color: row.isToday ? accent : c.text.primary, lineHeight: 1, letterSpacing: '-0.01em' }}>
{row.date.getDate()}
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.2 }}>{WEEKDAY_FULL[date.getDay()]}</Typography>
<Typography sx={{ fontSize: '0.85rem', fontWeight: 600, color: row.isToday ? accent : c.text.secondary, lineHeight: 1 }}>
{WEEKDAY_FULL[row.date.getDay()]}
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1 }}>
{row.date.toLocaleString('en', { month: 'short' })}
</Typography>
</Box>
);
}
if (row.kind === 'empty') {
return (
<Box key={row.id} data-wl-id={row.id} sx={{ px: 2, pb: 1 }}>
<Typography sx={{ fontSize: '0.85rem', color: c.text.ghost }}>No events today</Typography>
</Box>
);
}
const e = row.ev;
return (
<Box
key={row.id}
data-wl-id={row.id}
onClick={() => onSelectWorkflow?.(e.workflow.id)}
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
sx={{
display: 'flex', alignItems: 'center', gap: 1.25,
px: 2, py: 0.4,
color: c.text.secondary, cursor: 'pointer',
'&:hover .ev-title': { color: accent },
}}>
<Box sx={{ width: 3, alignSelf: 'stretch', minHeight: 22, bgcolor: accent, borderRadius: c.radius.sm, flexShrink: 0 }} />
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography className="ev-title" sx={{ fontSize: '0.9rem', fontWeight: 500, color: c.text.primary, lineHeight: 1.3 }}>{e.workflow.title}</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.3 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
</Box>
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', py: 1, pr: 2 }}>
{events.length === 0 && (
<Typography sx={{ fontSize: '0.85rem', color: c.text.ghost }}>No events today</Typography>
)}
{events.map((e, idx) => (
<Tooltip key={`${e.workflow.id}-${idx}`} title={<EventTooltipBody event={e} />} placement="right" arrow>
<Box
onClick={() => onSelectWorkflow?.(e.workflow.id)}
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
sx={{
display: 'flex', alignItems: 'center', gap: 1.25,
py: 0.4,
fontSize: '0.88rem', color: c.text.secondary, cursor: 'pointer',
'&:hover .ev-title': { color: accent },
}}>
<Box sx={{ width: 3, alignSelf: 'stretch', minHeight: 22, bgcolor: accent, borderRadius: c.radius.sm, flexShrink: 0 }} />
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography className="ev-title" sx={{ fontSize: '0.9rem', fontWeight: 500, color: c.text.primary, lineHeight: 1.3 }}>{e.workflow.title}</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.3 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
</Box>
</Box>
</Tooltip>
))}
</Box>
</Box>
))}
);
})}
{windowing.bottomSpacer > 0 && (
<Box aria-hidden sx={{ height: windowing.bottomSpacer, flexShrink: 0, overflowAnchor: 'none' }} />
)}
{ctxMenuEl}
</Box>
);
@@ -365,7 +365,7 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
const ends = new Date(s.ends_at).getTime();
if (!Number.isNaN(ends) && ends <= Date.now()) {
return (
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, pl: 12 }}>
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning, pl: 12 }}>
This date is in the past. The schedule will turn itself off.
</Typography>
);
@@ -373,24 +373,13 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se
}
if (endKind === 'after_n' && s.max_runs != null && s.runs_count >= s.max_runs) {
return (
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, pl: 12 }}>
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning, pl: 12 }}>
This workflow has already run {s.runs_count}× (limit {s.max_runs}). Raise the number or reset the counter to re-arm.
</Typography>
);
}
return null;
})()}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, minWidth: 96 }}>If missed</Typography>
<Select
size="small"
value={s.on_missed === 'run_all' ? 'run_once' : s.on_missed}
onChange={(e) => setSched({ on_missed: e.target.value as ScheduleConfig['on_missed'] })}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="skip">Skip the missed run</MenuItem>
<MenuItem value="run_once">Run once after I wake the app</MenuItem>
</Select>
</Box>
</Box>
{/* Section: What can the agent do? */}
@@ -445,11 +434,11 @@ function AppOpenStatusBadge({ info, hour, minute, frequent, onFix }: { info: App
return (
<Box sx={{
display: 'flex', alignItems: 'center', gap: 1, pl: 0.25,
bgcolor: good ? c.status.successBg : (c.status.warningBg || c.bg.elevated),
border: `1px solid ${good ? c.status.success + '60' : (c.status.warning || c.text.muted) + '60'}`,
bgcolor: good ? c.status.successBg : c.status.warningBg,
border: `1px solid ${good ? c.status.success + '60' : c.status.warning + '60'}`,
borderRadius: `${c.radius.md}px`, px: 1, py: 0.5,
}}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: good ? c.status.success : (c.status.warning || c.text.muted) }} />
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: good ? c.status.success : c.status.warning }} />
<Typography sx={{ flex: 1, fontSize: HINT_FS, color: c.text.primary }}>
{good ? 'Will run even if you close OpenSwarm.' : (frequent ? 'OpenSwarm must be open for this to run.' : `OpenSwarm must be open at ${fmt} for this to run.`)}
</Typography>
@@ -514,7 +503,7 @@ function PermissionRow({ idx, tier, cloudSmsEnabled, onChange, onRemove }: {
)}
</Box>
{!cloudSmsEnabled && (
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning || c.text.muted, fontStyle: 'italic' }}>
<Typography sx={{ fontSize: HINT_FS, color: c.status.warning, fontStyle: 'italic' }}>
Coming soon. Until cloud SMS ships, this tier falls back to an in-app notify with a "fallback" badge.
</Typography>
)}
@@ -139,7 +139,7 @@ export default function SchedulePopover({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.12, ease: 'easeOut' }}
transition={{ duration: 0.14, ease: 'easeOut' }}
style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column' }}>
{mode === 'search' && (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
@@ -179,8 +179,8 @@ export default function ScheduleThisPopover({ anchorEl, onClose, sessionId, sess
display: 'flex', flexDirection: 'column', gap: 0.4,
px: 1, py: 0.75, mb: 0.75,
borderRadius: `${c.radius.md}px`,
bgcolor: c.status.warningBg || c.bg.elevated,
border: `1px solid ${(c.status.warning || c.text.muted) + '60'}`,
bgcolor: c.status.warningBg,
border: `1px solid ${c.status.warning + '60'}`,
}}>
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.text.primary }}>
This chat is already scheduled.
@@ -390,7 +390,8 @@ const WorkflowCard: React.FC<Props> = ({
const result = await dispatch(createWorkflow({
title: (d.title as string) || 'New workflow',
description: (d.description as string) || '',
steps: draftSteps.map((s) => ({ id: s.id, text: s.text })),
steps: draftSteps.map((s) => ({ id: s.id, text: s.text, label: s.label })),
metadata_generated: card?.metaGenerated === true,
source_session_id: (d.source_session_id as string | undefined) || card?.sourceSessionId || null,
use_synced_prompt: true,
model: defaultModel || (d.model as string),
@@ -472,7 +473,7 @@ const WorkflowCard: React.FC<Props> = ({
? '2px solid #3b82f6'
: isRunning
? `1px solid ${c.accent.primary}80`
: `1px solid ${c.border.subtle}`;
: `1px solid ${c.border.strong}`;
const shadow = isHighlighted
? `0 0 0 3px ${c.accent.primary}50, 0 0 20px ${c.accent.primary}35, 0 0 40px ${c.accent.primary}15`
@@ -480,7 +481,7 @@ const WorkflowCard: React.FC<Props> = ({
? c.shadow.lg
: isSelected
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
: c.shadow.md;
: c.shadow.sm;
return (
<Box
@@ -514,7 +515,7 @@ const WorkflowCard: React.FC<Props> = ({
width: displayW,
height: autoHeight ? 'auto' : displayH,
maxHeight: autoHeight ? 'min(82vh, 760px)' : undefined,
borderRadius: '14px',
borderRadius: 3,
border,
bgcolor: c.bg.surface,
boxShadow: shadow,
@@ -541,7 +542,7 @@ const WorkflowCard: React.FC<Props> = ({
onPointerUp={handleDragPointerUp}
sx={{
display: 'flex', alignItems: 'center', gap: 1,
px: 2, py: 1.4,
px: 2, pt: 2, pb: 1.4,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none', userSelect: 'none',
flexShrink: 0,
@@ -549,7 +550,7 @@ const WorkflowCard: React.FC<Props> = ({
position: 'relative',
}}
>
<DragIndicatorIcon sx={{ fontSize: 18, color: c.text.muted }} />
<DragIndicatorIcon sx={{ fontSize: 16, color: c.text.ghost, mr: -0.5 }} />
{isDraft ? (
// Draft state: title is inline-editable. Patches the openCard's
// draft.title so PreviewView picks it up on Save. Saved cards
@@ -558,12 +559,14 @@ const WorkflowCard: React.FC<Props> = ({
data-no-drag
onPointerDown={(e) => e.stopPropagation()}
value={(card?.draft?.title as string) || ''}
placeholder="New workflow"
placeholder={card?.metaLoading ? 'Naming workflow' : 'New workflow'}
onChange={(e) => dispatch(updateWorkflowCard({ workflowId, patch: { draft: { ...(card?.draft || {}), title: e.target.value } } }))}
sx={{
flex: 1, fontWeight: 600, fontSize: '0.95rem', color: c.text.primary,
letterSpacing: '-0.005em',
'& input::placeholder': { color: c.text.muted, opacity: 1 },
animation: card?.metaLoading ? 'wfTitlePulse 1.2s ease-in-out infinite' : 'none',
'@keyframes wfTitlePulse': { '0%, 100%': { opacity: 0.55 }, '50%': { opacity: 1 } },
}}
/>
) : (
@@ -610,9 +613,9 @@ const WorkflowCard: React.FC<Props> = ({
data-no-drag
onClick={(e) => { e.stopPropagation(); onClose(); }}
onPointerDown={(e) => e.stopPropagation()}
sx={{ p: 0.5, color: c.text.secondary, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}
sx={{ p: 0.5, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}
>
<CloseIcon sx={{ fontSize: 17 }} />
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
@@ -633,7 +636,6 @@ const WorkflowCard: React.FC<Props> = ({
workflow={null}
runs={null}
fallbackModel={card?.draft?.model}
fallbackMode={card?.draft?.mode}
fallbackSourceSessionId={card?.draft?.source_session_id}
/>
<Box sx={{ flex: 1 }} />
@@ -842,7 +844,7 @@ const WorkflowCard: React.FC<Props> = ({
<Box sx={{
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
fontSize: '1.4rem', fontWeight: 700, color: c.accent.primary,
bgcolor: c.bg.surface, px: 1.2, py: 0.5, borderRadius: 999,
bgcolor: c.bg.surface, px: 1.2, py: 0.5, borderRadius: c.radius.full,
boxShadow: c.shadow.md,
animation: 'first-success-pop 1.4s ease-out forwards',
'@keyframes first-success-pop': {
@@ -944,11 +946,10 @@ function EditAgentSubtitle({ session }: { session: import('@/shared/state/agents
);
}
function SubtitleRow({ workflow, runs, fallbackModel, fallbackMode, fallbackSourceSessionId }: {
function SubtitleRow({ workflow, runs, fallbackModel, fallbackSourceSessionId }: {
workflow: Workflow | null;
runs: import('@/shared/state/workflowsSlice').WorkflowRun[] | null;
fallbackModel?: string;
fallbackMode?: string;
fallbackSourceSessionId?: string | null;
}) {
const c = useClaudeTokens();
@@ -957,8 +958,8 @@ function SubtitleRow({ workflow, runs, fallbackModel, fallbackMode, fallbackSour
// model/mode and use its work time for the "28s" so the subtitle reads the
// same as the source chat card did.
const sourceSession = useAppSelector((s) => fallbackSourceSessionId ? s.agents.sessions[fallbackSourceSessionId] : undefined);
// Match Image #34/#35/#36/#38/#40: "Claude Opus 4.6 agent 28s".
// Spaces between fields, all in muted text.
// Reads "Claude Opus 4.6 28s": model then work time, spaces between,
// all muted. Mode is omitted to match the chat card's subtitle.
const effModel = workflow?.model || fallbackModel || '';
const modelLabel = React.useMemo(() => {
if (!effModel) return '';
@@ -969,7 +970,6 @@ function SubtitleRow({ workflow, runs, fallbackModel, fallbackMode, fallbackSour
}
return effModel;
}, [effModel, modelsByProvider]);
const modeLabel = workflow?.mode || fallbackMode || '';
const duration = React.useMemo(() => {
const finished = (runs || []).find((r) => r.finished_at);
if (finished && finished.finished_at) {
@@ -989,7 +989,6 @@ function SubtitleRow({ workflow, runs, fallbackModel, fallbackMode, fallbackSour
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1.25, fontSize: '0.82rem', color: c.text.muted, minWidth: 0, overflow: 'hidden' }}>
{modelLabel && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{modelLabel}</Box>}
{modeLabel && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{modeLabel}</Box>}
{duration && <Box component="span" sx={{ whiteSpace: 'nowrap' }}>{duration}</Box>}
</Box>
);
@@ -1030,7 +1029,7 @@ function RunningHeader({ workflowId }: { workflowId: string }) {
onClick={controlsDisabled ? undefined : onStop}
role="button"
aria-disabled={controlsDisabled}
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35, fontSize: '0.82rem', fontWeight: 600, px: 1, py: 0.4, color: c.text.secondary, cursor: controlsDisabled ? 'default' : 'pointer', borderRadius: 999, opacity: controlsDisabled && !stopPending ? 0.55 : 1, '&:hover': controlsDisabled ? {} : { color: c.text.primary, bgcolor: c.bg.elevated } }}>
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35, fontSize: '0.82rem', fontWeight: 600, px: 1, py: 0.4, color: c.text.secondary, cursor: controlsDisabled ? 'default' : 'pointer', borderRadius: c.radius.full, opacity: controlsDisabled && !stopPending ? 0.55 : 1, '&:hover': controlsDisabled ? {} : { color: c.text.primary, bgcolor: c.bg.elevated } }}>
{stopPending ? <CircularProgress size={14} thickness={5} sx={{ color: c.text.secondary }} /> : <StopRounded sx={{ fontSize: 15 }} />}
Stop
</Box>
@@ -1042,7 +1041,7 @@ function RunningHeader({ workflowId }: { workflowId: string }) {
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.35,
fontSize: '0.82rem', fontWeight: 700,
px: 1.1, py: 0.4, borderRadius: 999,
px: 1.1, py: 0.4, borderRadius: c.radius.full,
bgcolor: c.accent.primary, color: '#fff', cursor: controlsDisabled ? 'default' : 'pointer',
opacity: controlsDisabled && !pausePending ? 0.55 : 1,
'&:hover': controlsDisabled ? {} : { filter: 'brightness(1.05)' },
@@ -85,7 +85,7 @@ export function ActionBtn({ label, tone, disabled, onClick, icon }: { label: str
display: 'inline-flex', alignItems: 'center', gap: 0.4,
fontSize: '0.78rem', fontWeight: 600,
px: 1, py: 0.35,
borderRadius: 999,
borderRadius: c.radius.full,
cursor: disabled ? 'not-allowed' : 'pointer',
color: palette.color,
bgcolor: palette.bg,
@@ -169,7 +169,8 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
const result = await dispatch(createWorkflow({
title,
description,
steps: steps.map((s) => ({ id: s.id, text: s.text })),
steps: steps.map((s) => ({ id: s.id, text: s.text, label: s.label })),
metadata_generated: card?.metaGenerated === true,
source_session_id: sourceSessionId,
use_synced_prompt: true,
// The user's configured default wins over whatever model the source chat
@@ -184,7 +185,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
const wf = result.payload as Workflow;
if (wf?.id) return wf;
return null;
}, [canSave, dispatch, title, description, steps, sourceSessionId, liveDraft, defaultModel, defaultMode]);
}, [canSave, dispatch, title, description, steps, sourceSessionId, liveDraft, defaultModel, defaultMode, card]);
const onIgnore = useCallback(async () => {
if (busy) return;
@@ -267,7 +268,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
fontSize: '0.88rem', fontWeight: 700,
px: 1.75, py: 0.6, borderRadius: 999,
px: 1.75, py: 0.6, borderRadius: c.radius.full,
color: '#fff', bgcolor: c.accent.primary,
cursor: busy ? 'wait' : canSave ? 'pointer' : 'not-allowed',
opacity: busy || !canSave ? 0.6 : 1,
@@ -300,7 +301,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft,
role="button"
onClick={canSave ? onSaveDraft : undefined}
title={canSave ? undefined : 'Add at least one step before saving'}
sx={{ fontSize: '0.84rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary, borderRadius: 999, cursor: busy ? 'wait' : canSave ? 'pointer' : 'not-allowed', px: 1.5, py: 0.6, opacity: busy || !canSave ? 0.6 : 1, '&:hover': { filter: 'brightness(1.06)' } }}>
sx={{ fontSize: '0.84rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary, borderRadius: c.radius.full, cursor: busy ? 'wait' : canSave ? 'pointer' : 'not-allowed', px: 1.5, py: 0.6, opacity: busy || !canSave ? 0.6 : 1, '&:hover': { filter: 'brightness(1.06)' } }}>
Save
</Box>
</DialogActions>
@@ -466,7 +467,7 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
fontSize: '0.88rem', fontWeight: 700,
px: 1.75, py: 0.6, borderRadius: 999,
px: 1.75, py: 0.6, borderRadius: c.radius.full,
color: '#fff', bgcolor: c.accent.primary,
cursor: 'pointer',
'&:hover': { bgcolor: c.accent.primary, filter: 'brightness(1.06)' },
@@ -499,12 +500,12 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo
display: 'inline-flex', alignItems: 'center', gap: 0.45,
fontSize: '0.82rem', fontWeight: 600,
px: 1.25, py: 0.5,
borderRadius: 999,
borderRadius: c.radius.full,
cursor: 'pointer',
color: c.text.secondary,
bgcolor: 'transparent',
border: `1px solid ${c.border.medium}`,
'&:hover': { bgcolor: c.bg.elevated, borderColor: c.border.strong || c.border.medium, color: c.text.primary },
'&:hover': { bgcolor: c.bg.elevated, borderColor: c.border.strong, color: c.text.primary },
}}>
<EditOutlined sx={{ fontSize: 15 }} />
Edit
@@ -596,7 +597,7 @@ function AuditTraceLink({ workflowId }: { workflowId: string }) {
<Box onClick={open} role="button" sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.3,
fontSize: '0.7rem', color: c.text.muted, cursor: 'pointer',
px: 0.5, py: 0.25, borderRadius: 0.75,
px: 0.5, py: 0.25, borderRadius: c.radius.sm,
'&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated },
}}>
<HistoryIcon sx={{ fontSize: 12 }} />
@@ -712,7 +713,7 @@ export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleF
color: filter === k ? c.accent.primary : c.text.muted,
bgcolor: filter === k ? c.accent.primary + '14' : 'transparent',
border: `1px solid ${filter === k ? c.accent.primary + '40' : c.border.subtle}`,
px: 0.7, py: 0.2, borderRadius: 999, cursor: 'pointer',
px: 0.75, py: 0.3, borderRadius: c.radius.full, cursor: 'pointer',
'&:hover': { color: c.accent.primary },
}}>
{k === 'all' ? 'All' : k === 'success' ? 'Success' : k === 'failure' ? 'Failures' : 'Skipped'}
@@ -733,8 +734,8 @@ export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleF
<Box key={r.id}>
<Box
onClick={() => setExpandedId(expanded ? null : r.id)}
sx={{ display: 'flex', alignItems: 'center', gap: 1.25, py: 0.6, px: 0.5, cursor: 'pointer', borderRadius: 0.75, '&:hover': { bgcolor: c.bg.elevated } }}>
<Box sx={{ fontSize: '0.72rem', fontWeight: 700, color: statusColor(r.status, c), bgcolor: statusBg(r.status, c), px: 0.8, py: 0.3, borderRadius: 0.75, minWidth: 64, textAlign: 'center' }}>
sx={{ display: 'flex', alignItems: 'center', gap: 1.25, py: 0.6, px: 0.5, cursor: 'pointer', borderRadius: c.radius.sm, '&:hover': { bgcolor: c.bg.elevated } }}>
<Box sx={{ fontSize: '0.72rem', fontWeight: 700, color: statusColor(r.status, c), bgcolor: statusBg(r.status, c), px: 0.8, py: 0.3, borderRadius: c.radius.sm, minWidth: 64, textAlign: 'center' }}>
{labelForStatus(r.status)}
</Box>
{showWorkflow && workflowTitleFor ? (
@@ -753,7 +754,8 @@ export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleF
<Box sx={{ fontSize: '0.7rem', color: c.text.ghost, transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s ease' }}>▾</Box>
</Box>
{expanded && (
<Box sx={{ ml: 8, mt: 0.25, mb: 0.75, px: 1, py: 0.75, bgcolor: c.bg.elevated, borderRadius: 0.75, border: `1px solid ${c.border.subtle}`, display: 'flex', alignItems: 'center' }}>
<Box sx={{ ml: 8, mt: 0.25, mb: 0.75, px: 1, py: 0.75, bgcolor: c.bg.elevated, borderRadius: c.radius.sm, border: `1px solid ${c.border.subtle}`, display: 'flex', alignItems: 'center' }}>
{r.error ? (
<Typography sx={{ fontSize: '0.78rem', color: c.status.error, lineHeight: 1.4 }}>{r.error}</Typography>
) : r.session_id ? (
@@ -781,11 +783,11 @@ export function HistoryDetail({ run, onBack }: { run: WorkflowRun | null; onBack
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box onClick={onBack} role="button" sx={{ fontSize: '0.82rem', color: c.text.muted, cursor: 'pointer', '&:hover': { color: c.accent.primary } }}>← back</Box>
<Box sx={{ fontSize: '0.72rem', fontWeight: 700, color: statusColor(run.status, c), bgcolor: statusBg(run.status, c), px: 0.8, py: 0.3, borderRadius: 0.75 }}>{labelForStatus(run.status)}</Box>
<Box sx={{ fontSize: '0.72rem', fontWeight: 700, color: statusColor(run.status, c), bgcolor: statusBg(run.status, c), px: 0.8, py: 0.3, borderRadius: c.radius.sm }}>{labelForStatus(run.status)}</Box>
<Typography sx={{ fontSize: '0.88rem', color: c.text.primary, fontWeight: 600 }}>{formatRunDate(run.started_at)}</Typography>
</Box>
{run.error && (
<Typography sx={{ fontSize: '0.85rem', color: c.status.error, bgcolor: c.status.errorBg, p: 1, borderRadius: 0.75 }}>{run.error}</Typography>
<Typography sx={{ fontSize: '0.85rem', color: c.status.error, bgcolor: c.status.errorBg, p: 1, borderRadius: c.radius.sm }}>{run.error}</Typography>
)}
<Typography sx={{ fontSize: '0.85rem', color: c.text.secondary, lineHeight: 1.5 }}>Started {formatRunDate(run.started_at)}, finished {run.finished_at ? formatRunDate(run.finished_at) : 'in progress'}.</Typography>
{run.session_id && (
@@ -3,6 +3,7 @@ import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import InputBase from '@mui/material/InputBase';
import Fade from '@mui/material/Fade';
import CloseIcon from '@mui/icons-material/Close';
import AddIcon from '@mui/icons-material/Add';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
@@ -19,7 +20,7 @@ import {
setWorkflowsHubPosition,
setWorkflowsHubSize,
} from '@/shared/state/dashboardLayoutSlice';
import { openWorkflowCard, createWorkflow, fetchPausedState, fetchWorkflows, setPausedAll, updateWorkflow, deleteWorkflow, runWorkflowNow } from '@/shared/state/workflowsSlice';
import { openWorkflowCard, createWorkflow, fetchWorkflows, fetchPausedState, setPausedAll, updateWorkflow, deleteWorkflow, runWorkflowNow } from '@/shared/state/workflowsSlice';
import type { Workflow } from '@/shared/state/workflowsSlice';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
@@ -56,7 +57,7 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record<string, any> }[] = [
];
interface Props {
dashboardId?: string;
dashboardId: string;
cardX: number;
cardY: number;
cardWidth: number;
@@ -77,54 +78,6 @@ interface Props {
type CalendarView = 'Week' | 'Month' | 'List';
// Small badge in the hub header that adds up successful scheduled runs
// across all workflows and renders an approximate "time saved" figure.
// Heuristic: 3 minutes saved per scheduled run that the user would have
// otherwise done by hand. Not precise — meant as a quiet "you got back
// X hours" affirmation, not an audit number.
function TimeSavedBadge() {
const c = useClaudeTokens();
const runsByWorkflow = useAppSelector((s) => s.workflows.runs);
const items = useAppSelector((s) => s.workflows.items);
let count = 0;
for (const arr of Object.values(runsByWorkflow)) {
for (const r of arr) {
if (r.triggered_by === 'schedule' && (r.status === 'success' || r.status === 'ran_late')) count += 1;
}
}
// Fallback: if no runs are loaded yet (cards never opened), use
// last_run_status as a coarse proxy so brand-new users don't see 0.
if (count === 0) {
for (const w of Object.values(items)) {
if (w.last_run_status === 'success' || w.last_run_status === 'ran_late') count += 1;
}
}
if (count === 0) return null;
const totalMin = count * 3;
const hours = totalMin / 60;
// Show "X done · ~Y hrs" so the user gets both the run count and a
// sense of time. Dot-separator reads quieter than the old green pill.
const timeLabel = hours >= 1 ? `~${hours.toFixed(1)} hrs` : `~${totalMin} min`;
return (
<Tooltip title={`${count} workflow runs completed for you. Rough estimate of ~3 min saved per run vs. doing it by hand.`}>
<Box sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
ml: 1, px: 0.85, py: 0.2,
fontSize: '0.74rem', fontWeight: 600,
color: c.text.secondary,
bgcolor: 'transparent',
border: `1px solid ${c.border.subtle}`,
borderRadius: 999,
}}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 14, height: 14, borderRadius: '50%', bgcolor: (c.status.success || c.accent.primary) + '22', color: c.status.success || c.accent.primary, fontSize: 9, fontWeight: 800 }}>✓</Box>
<span style={{ color: c.text.primary }}>{count}</span>
<span style={{ color: c.text.muted }}>·</span>
<span style={{ color: c.text.secondary }}>{timeLabel} back</span>
</Box>
</Tooltip>
);
}
const WorkflowsHubCard: React.FC<Props> = ({
dashboardId,
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
@@ -138,10 +91,12 @@ const WorkflowsHubCard: React.FC<Props> = ({
const paused = useAppSelector((s) => s.workflows.paused);
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
useEffect(() => {
dispatch(fetchPausedState());
dispatch(fetchWorkflows(dashboardId));
}, [dispatch, dashboardId]);
useEffect(() => { dispatch(fetchPausedState()); }, [dispatch]);
// The hub is the signal "user is looking at workflows now", so load them
// eagerly here instead of waiting on the dashboard's deferred idle fetch
// (which left the calendar blank for ~2s). The thunk's !loading condition
// dedups against that idle dispatch.
useEffect(() => { dispatch(fetchWorkflows(dashboardId)); }, [dashboardId, dispatch]);
const togglePaused = useCallback(() => {
dispatch(setPausedAll(!paused));
@@ -356,7 +311,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
? c.shadow.lg
: isSelected
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
: c.shadow.md;
: c.shadow.sm;
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
return (
@@ -385,7 +340,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
height: dh,
bgcolor: c.bg.surface,
border,
borderRadius: `${c.radius.lg}px`,
borderRadius: 3,
boxShadow: shadow,
overflow: 'hidden',
display: 'flex',
@@ -415,15 +370,15 @@ const WorkflowsHubCard: React.FC<Props> = ({
points right, matching the Workflows brand mark. */}
<CallSplitRoundedIcon sx={{ fontSize: 16, transform: 'rotate(90deg)' }} />
</Box>
<Typography sx={{ flex: 1, fontWeight: 700, fontSize: '0.88rem', color: c.text.primary }}>Workflows</Typography>
<Typography sx={{ flex: 1, fontWeight: 600, fontSize: '0.95rem', color: c.text.primary }}>Workflows</Typography>
<IconButton
size="small"
data-no-drag
onClick={(e) => { e.stopPropagation(); dispatch(closeWorkflowsHub()); }}
onPointerDown={(e) => e.stopPropagation()}
sx={{ p: 0.35, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}
sx={{ p: 0.5, color: c.text.ghost, '&:hover': { color: c.status.error, bgcolor: c.status.errorBg } }}
>
<CloseIcon sx={{ fontSize: 15 }} />
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
@@ -455,11 +410,11 @@ const WorkflowsHubCard: React.FC<Props> = ({
data-no-drag
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4, ml: 0.5,
fontSize: '0.8rem', fontWeight: 600,
color: paused ? c.status.warning || c.accent.primary : c.text.secondary,
bgcolor: paused ? (c.status.warningBg || c.bg.elevated) : 'transparent',
border: `1px solid ${paused ? (c.status.warning || c.accent.primary) + '60' : c.border.subtle}`,
px: 0.85, py: 0.3, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
fontSize: '0.82rem', fontWeight: 600,
color: c.text.secondary,
bgcolor: paused ? c.bg.elevated : 'transparent',
border: `1px solid ${paused ? c.border.medium : c.border.subtle}`,
px: 1, py: 0.35, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
'&:hover': { color: c.text.primary, borderColor: c.border.medium },
}}>
<Switch size="small" checked={paused} sx={{ pointerEvents: 'none', mr: -0.5, ml: -0.5 }} />
@@ -475,13 +430,12 @@ const WorkflowsHubCard: React.FC<Props> = ({
sx={{
fontSize: '0.82rem', fontWeight: 500, color: c.text.secondary,
border: `1px solid ${c.border.subtle}`,
px: 1.1, py: 0.35, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
px: 1, py: 0.35, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
'&:hover': { color: c.text.primary, borderColor: c.border.medium },
}}>Today</Box>
<IconButton size="small" data-no-drag onClick={() => setRefDate(addDays(refDate, view === 'Month' ? -28 : -7))} sx={{ p: 0.3 }}><ChevronLeftIcon sx={{ fontSize: 18 }} /></IconButton>
<IconButton size="small" data-no-drag onClick={() => setRefDate(addDays(refDate, view === 'Month' ? 28 : 7))} sx={{ p: 0.3 }}><ChevronRightIcon sx={{ fontSize: 18 }} /></IconButton>
<Typography sx={{ fontSize: '0.92rem', fontWeight: 600, color: c.text.primary }}>{monthLabel}</Typography>
<TimeSavedBadge />
</Box>
<Box sx={{ position: 'relative' }}>
@@ -499,7 +453,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
{view}
<KeyboardArrowDownIcon sx={{ fontSize: 16 }} />
</Box>
{viewOpen && (
<Fade in={viewOpen} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box sx={{ position: 'absolute', top: '100%', right: 0, mt: 0.5, bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, boxShadow: c.shadow.md, zIndex: 5, minWidth: 110 }}>
{(['List', 'Week', 'Month'] as const).map((v) => (
<Box
@@ -511,7 +465,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
</Box>
))}
</Box>
)}
</Fade>
</Box>
</Box>
@@ -690,7 +644,7 @@ function SidebarSection({ title, items, onPick, scheduled, onContext, onSchedule
<Box
onClick={(e) => toggleEnabled(w, e)}
sx={{
width: 14, height: 14, borderRadius: '3px', flexShrink: 0,
width: 14, height: 14, borderRadius: c.radius.sm, flexShrink: 0,
border: `1.5px solid ${w.schedule.enabled ? c.accent.primary : c.border.medium}`,
bgcolor: w.schedule.enabled ? c.accent.primary : 'transparent',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
@@ -706,7 +660,7 @@ function SidebarSection({ title, items, onPick, scheduled, onContext, onSchedule
<Box
onClick={(e) => { e.stopPropagation(); onSchedule?.(w, e.currentTarget); }}
sx={{
width: 16, height: 16, borderRadius: '4px', flexShrink: 0,
width: 16, height: 16, borderRadius: c.radius.sm, flexShrink: 0,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: c.text.muted, cursor: 'pointer',
'&:hover': { color: c.accent.primary, bgcolor: c.bg.elevated },
@@ -724,7 +678,7 @@ function SidebarSection({ title, items, onPick, scheduled, onContext, onSchedule
)}
</Typewriter>
{scheduled && (!w.schedule.enabled || allPaused) && (
<Box sx={{ flexShrink: 0, px: 0.6, py: 0.1, borderRadius: '3px', bgcolor: c.bg.elevated, color: c.text.muted, fontSize: '0.62rem', fontWeight: 600, lineHeight: 1.5, letterSpacing: '0.02em' }}>Paused</Box>
<Box sx={{ flexShrink: 0, px: 0.6, py: 0.1, borderRadius: c.radius.sm, bgcolor: c.bg.elevated, color: c.text.muted, fontSize: '0.62rem', fontWeight: 600, lineHeight: 1.5, letterSpacing: '0.02em' }}>Paused</Box>
)}
</Box>
))}
@@ -18,7 +18,6 @@ export function defaultSchedule(): ScheduleConfig {
hour: 9,
minute: 0,
timezone: tz,
on_missed: 'skip',
ends_at: null,
max_runs: null,
runs_count: 0,
@@ -36,7 +36,7 @@ export function ActionBtn({ label, tone, disabled, onClick, icon }: { label: str
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.45,
fontSize: LABEL_FS, fontWeight: 600, px: 1.25, py: 0.5,
borderRadius: 999,
borderRadius: c.radius.full,
cursor: disabled ? 'not-allowed' : 'pointer',
color: palette.color,
bgcolor: palette.bg,
@@ -48,7 +48,7 @@ export type LastRunStatus = NonNullable<Workflow['last_run_status']>;
export function statusDotColor(status: LastRunStatus | null | undefined, c: ReturnType<typeof useClaudeTokens>) {
switch (status) {
case 'success': return c.status.success;
case 'ran_late': return c.status.warning || '#f59e0b';
case 'ran_late': return c.status.warning;
case 'failure': return c.status.error;
case 'running': return c.accent.primary;
case 'skipped': return c.text.muted;
@@ -75,7 +75,7 @@ export function StatusDot({ status }: { status: LastRunStatus | null | undefined
<Tooltip title={status ? `Last run: ${word.toLowerCase()}` : 'This workflow has never run.'}>
<Box sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
height: 18, px: 0.6, borderRadius: 999,
height: 18, px: 0.75, borderRadius: c.radius.full,
bgcolor: status === 'failure' ? c.status.errorBg : status === 'ran_late' ? c.status.warningBg : status === 'success' ? c.status.successBg : c.bg.elevated,
border: `1px solid ${dotColor}55`,
flexShrink: 0,
@@ -163,7 +163,7 @@ export function PermissionChip({ workflow }: { workflow: Workflow }) {
color: c.text.secondary,
bgcolor: c.bg.elevated,
border: `1px solid ${c.border.subtle}`,
px: 0.85, py: 0.3, borderRadius: 999,
px: 0.75, py: 0.3, borderRadius: c.radius.full,
}}>
{tiers.map((t, i) => (
<React.Fragment key={i}>
@@ -204,7 +204,7 @@ export function ScheduleChip({ workflow }: { workflow: Workflow }) {
color: enabled ? c.accent.primary : c.text.muted,
bgcolor: enabled ? c.accent.primary + '14' : c.bg.elevated,
border: `1px solid ${enabled ? c.accent.primary + '40' : c.border.subtle}`,
px: 0.85, py: 0.3, borderRadius: 999,
px: 0.75, py: 0.3, borderRadius: c.radius.full,
cursor: enabled ? 'pointer' : 'default',
'&:hover': enabled ? { bgcolor: c.accent.primary + '22' } : undefined,
}}>
@@ -354,7 +354,7 @@ function chipSx(c: ReturnType<typeof useClaudeTokens>) {
color: c.text.secondary,
bgcolor: c.bg.elevated,
border: `1px solid ${c.border.subtle}`,
px: 0.75, py: 0.3, borderRadius: 999,
px: 0.75, py: 0.3, borderRadius: c.radius.full,
} as const;
}
@@ -432,10 +432,10 @@ export function StreakBadge({ runs }: { runs: WorkflowRun[] | undefined }) {
<Box sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.3,
fontSize: '0.72rem', fontWeight: 700,
color: c.status.warning || '#f59e0b',
bgcolor: (c.status.warningBg || c.bg.elevated),
border: `1px solid ${(c.status.warning || '#f59e0b') + '60'}`,
px: 0.7, py: 0.2, borderRadius: 999,
color: c.status.success,
bgcolor: c.status.successBg,
border: `1px solid ${c.status.success + '60'}`,
px: 0.75, py: 0.3, borderRadius: c.radius.full,
}}>
🔥 {n}
</Box>
@@ -0,0 +1,177 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
// Generic list windowing, lifted from AgentChat's transcript virtualizer so a
// long schedule list mounts only the rows near the viewport (off-screen rows
// unmount, replaced by measured-height spacers). Rows can be any height; the
// hook measures them once on screen and estimates the rest. Top-anchored: the
// list reads from the top, no bottom-following like the chat does.
// Keep this many screens of real content mounted on EACH side of the viewport.
const BUFFER_SCREENS_PER_SIDE = 3;
// Floor on mounted count so one very tall row can't strand an empty window.
const MIN_BUFFER_ITEMS = 2;
// Pure solver: given scroll position and a per-index height accessor (measured
// where known, estimated otherwise), return the [start, end) slice to mount.
// Buffer is in PIXELS (N screens per side), so a few tall rows can't blow the
// mounted set up to the whole list.
export function computeDesiredWindow(
scrollTop: number,
clientHeight: number,
total: number,
heightOf: (index: number) => number,
bufferPx: number,
): { start: number; end: number } {
if (total <= 0) return { start: 0, end: 0 };
const keepTop = scrollTop - bufferPx;
const keepBottom = scrollTop + clientHeight + bufferPx;
let offset = 0;
let start = -1;
let end = total;
for (let i = 0; i < total; i++) {
const h = heightOf(i);
const itemTop = offset;
const itemBottom = offset + h;
if (start === -1 && itemBottom > keepTop) start = i;
if (itemTop < keepBottom) {
end = i + 1;
} else {
break;
}
offset += h;
}
if (start === -1) start = Math.max(0, total - 1);
end = Math.min(total, Math.max(end, start + 1));
if (end - start < MIN_BUFFER_ITEMS) {
start = Math.max(0, Math.min(start, end - MIN_BUFFER_ITEMS));
}
return { start: Math.max(0, start), end };
}
interface UseWindowedListArgs {
// Stable id per row, in render order. Heights are cached by id so a measured
// row keeps its height across re-renders even as the window slides.
ids: string[];
estimateHeight: (index: number) => number;
// Off below this gates windowing entirely: render all, no spacers. Short
// lists don't benefit and the spacer recompute just fights the scrollbar.
enabled: boolean;
}
interface UseWindowedListResult {
setScrollEl: (el: HTMLDivElement | null) => void;
onScroll: () => void;
start: number;
end: number;
topSpacer: number;
bottomSpacer: number;
}
export function useWindowedList({ ids, estimateHeight, enabled }: UseWindowedListArgs): UseWindowedListResult {
const total = ids.length;
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
const heightsRef = useRef<Map<string, number>>(new Map());
const [heightVersion, setHeightVersion] = useState(0);
const [start, setStart] = useState(0);
const [end, setEnd] = useState(total);
const startRef = useRef(0);
const endRef = useRef(total);
const idsRef = useRef(ids);
idsRef.current = ids;
const estimateRef = useRef(estimateHeight);
estimateRef.current = estimateHeight;
const heightOf = useCallback((index: number): number => {
const id = idsRef.current[index];
if (id == null) return 0;
const measured = heightsRef.current.get(id);
if (measured != null) return measured;
return estimateRef.current(index);
}, []);
const applyWindow = useCallback(() => {
const el = scrollEl;
if (!el || !enabled) return;
const count = idsRef.current.length;
const clientHeight = Math.max(1, el.clientHeight);
const tightPx = BUFFER_SCREENS_PER_SIDE * clientHeight;
const loosePx = tightPx + clientHeight;
const tight = computeDesiredWindow(el.scrollTop, clientHeight, count, heightOf, tightPx);
const loose = computeDesiredWindow(el.scrollTop, clientHeight, count, heightOf, loosePx);
const curStart = startRef.current;
const curEnd = endRef.current;
// Hysteresis: must-mount the tight band, but keep already-mounted edges
// until they drift past the looser band, so rows on the boundary don't
// flip-flop mount/unmount on every scroll tick.
let next = Math.max(loose.start, Math.min(curStart, tight.start));
let nextEnd = Math.min(loose.end, Math.max(curEnd, tight.end));
next = Math.max(0, Math.min(next, Math.max(0, nextEnd - 1)));
if (next === curStart && nextEnd === curEnd) return;
startRef.current = next;
endRef.current = nextEnd;
setStart(next);
setEnd(nextEnd);
}, [scrollEl, enabled, heightOf]);
const rafRef = useRef<number | null>(null);
const onScroll = useCallback(() => {
if (rafRef.current != null) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
applyWindow();
});
}, [applyWindow]);
useEffect(() => {
if (!scrollEl) return;
applyWindow();
const obs = new ResizeObserver(() => applyWindow());
obs.observe(scrollEl);
return () => {
obs.disconnect();
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [scrollEl, enabled, total, heightVersion, applyWindow]);
// Measure mounted rows after paint; a real height replaces its estimate and
// nudges the window + spacers to the truth on the next frame.
useLayoutEffect(() => {
if (!scrollEl) return;
let changed = false;
scrollEl.querySelectorAll<HTMLElement>('[data-wl-id]').forEach((node) => {
const id = node.dataset.wlId;
if (!id) return;
const h = node.offsetHeight;
if (h <= 0) return;
const prev = heightsRef.current.get(id);
if (prev === undefined || Math.abs(prev - h) > 1) {
heightsRef.current.set(id, h);
changed = true;
}
});
if (changed) setHeightVersion((v) => v + 1);
});
const safeStart = enabled ? Math.min(Math.max(0, start), Math.max(0, total - 1)) : 0;
const safeEnd = enabled ? Math.min(Math.max(safeStart + 1, end), total) : total;
const { topSpacer, bottomSpacer } = useMemo(() => {
if (!enabled) return { topSpacer: 0, bottomSpacer: 0 };
let top = 0;
for (let i = 0; i < safeStart; i++) top += heightOf(i);
let bottom = 0;
for (let i = safeEnd; i < total; i++) bottom += heightOf(i);
return { topSpacer: top, bottomSpacer: bottom };
// heightVersion: spacers depend on the measured-height map (a ref the dep
// checker can't see), recompute when a measurement lands.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, safeStart, safeEnd, total, heightVersion, heightOf]);
return { setScrollEl, onScroll, start: safeStart, end: safeEnd, topSpacer, bottomSpacer };
}
@@ -22,12 +22,14 @@ export const DEFAULT_WORKFLOW_CARD_W = 480;
export const DEFAULT_WORKFLOW_CARD_H = 520;
export const DEFAULT_WORKFLOWS_HUB_W = 1200;
export const DEFAULT_WORKFLOWS_HUB_H = 640;
export const DEFAULT_MISSED_RUNS_W = 460;
export const DEFAULT_MISSED_RUNS_H = 420;
export const EXPANDED_CARD_MIN_H = 620;
export const GRID_GAP = 24;
const GRID_ORIGIN = { x: 40, y: 100 };
const GRID_COLS_FALLBACK = 4;
export type CardType = 'agent' | 'view' | 'browser' | 'note' | 'workflow' | 'workflows-hub';
export type CardType = 'agent' | 'view' | 'browser' | 'note' | 'workflow' | 'workflows-hub' | 'missed_runs';
export interface CardPosition {
session_id: string;
@@ -89,6 +91,17 @@ export interface WorkflowsHubPosition {
zOrder: number;
}
// Ephemeral launch-time card listing scheduled fires missed while the app was
// closed. Singleton like workflowsHub, but deliberately NOT persisted to the
// saved layout: the launch hook decides each session whether to show it.
export interface MissedRunsCardPosition {
x: number;
y: number;
width: number;
height: number;
zOrder: number;
}
export type NoteColor = 'yellow' | 'pink' | 'blue' | 'green' | 'purple' | 'gray';
export interface NotePosition {
@@ -120,6 +133,7 @@ export interface DashboardLayoutState {
workflowCards: Record<string, WorkflowCardPosition>;
configurePanels: Record<string, ConfigurePanelPosition>;
workflowsHub: WorkflowsHubPosition | null;
missedRunsCard: MissedRunsCardPosition | null;
notes: Record<string, NotePosition>;
closedCardPositions: Record<string, CardPosition>;
glowingBrowserCards: Record<string, { sourceId: string; fading: boolean; label?: string }>;
@@ -138,6 +152,8 @@ export interface DashboardLayoutState {
/** Transient: id of the view card the user has clicked into; preload stops forwarding canvas gestures while set. */
activeViewCardId: string | null;
pendingFocusWorkflowId: string | null;
/** Transient: signals Dashboard to pan/zoom to the missed-runs card on open. */
pendingFocusMissedRuns: boolean;
/** Transient: signals Dashboard to pan/zoom to the singleton Workflows Hub on open. */
pendingFocusWorkflowsHub: boolean;
}
@@ -149,6 +165,7 @@ const initialState: DashboardLayoutState = {
workflowCards: {},
configurePanels: {},
workflowsHub: null,
missedRunsCard: null,
notes: {},
closedCardPositions: {},
glowingBrowserCards: {},
@@ -163,6 +180,7 @@ const initialState: DashboardLayoutState = {
endingBrowserCards: {},
activeViewCardId: null,
pendingFocusWorkflowId: null,
pendingFocusMissedRuns: false,
pendingFocusWorkflowsHub: false,
};
@@ -278,6 +296,9 @@ function collectOccupiedRects(
if (state.workflowsHub) {
rects.push({ x: state.workflowsHub.x, y: state.workflowsHub.y, w: state.workflowsHub.width, h: state.workflowsHub.height });
}
if (state.missedRunsCard) {
rects.push({ x: state.missedRunsCard.x, y: state.missedRunsCard.y, w: state.missedRunsCard.width, h: state.missedRunsCard.height });
}
for (const n of Object.values(state.notes)) {
rects.push({ x: n.x, y: n.y, w: n.width, h: n.height });
}
@@ -454,7 +475,7 @@ const dashboardLayoutSlice = createSlice({
bringToFront(
state,
action: PayloadAction<{ id: string; type: 'agent' | 'view' | 'browser' | 'note' | 'workflow' | 'workflows-hub' }>,
action: PayloadAction<{ id: string; type: CardType }>,
) {
const { id, type } = action.payload;
// Compute the current top zOrder across ALL card types so we can
@@ -473,11 +494,13 @@ const dashboardLayoutSlice = createSlice({
for (const c of Object.values(state.workflowCards)) tally(c.zOrder);
for (const n of Object.values(state.notes)) tally(n.zOrder);
if (state.workflowsHub) tally(state.workflowsHub.zOrder);
if (state.missedRunsCard) tally(state.missedRunsCard.zOrder);
if (type === 'agent') currentZ = state.cards[id]?.zOrder ?? 0;
else if (type === 'view') currentZ = state.viewCards[id]?.zOrder ?? 0;
else if (type === 'note') currentZ = state.notes[id]?.zOrder ?? 0;
else if (type === 'workflow') currentZ = state.workflowCards[id]?.zOrder ?? 0;
else if (type === 'workflows-hub') currentZ = state.workflowsHub?.zOrder ?? 0;
else if (type === 'missed_runs') currentZ = state.missedRunsCard?.zOrder ?? 0;
else currentZ = state.browserCards[id]?.zOrder ?? 0;
if (currentZ >= maxZ) return; // Already on top: no-op.
@@ -496,6 +519,8 @@ const dashboardLayoutSlice = createSlice({
if (card) card.zOrder = z;
} else if (type === 'workflows-hub') {
if (state.workflowsHub) state.workflowsHub.zOrder = z;
} else if (type === 'missed_runs') {
if (state.missedRunsCard) state.missedRunsCard.zOrder = z;
} else {
const card = state.browserCards[id];
if (card) card.zOrder = z;
@@ -552,7 +577,8 @@ const dashboardLayoutSlice = createSlice({
const viewCards = Object.values(state.viewCards);
const bCards = Object.values(state.browserCards);
const wCards = Object.values(state.workflowCards);
const total = agentCards.length + viewCards.length + bCards.length + wCards.length;
const hub = state.workflowsHub;
const total = agentCards.length + viewCards.length + bCards.length + wCards.length + (hub ? 1 : 0);
if (total === 0) return;
const allItems = [
@@ -560,6 +586,7 @@ const dashboardLayoutSlice = createSlice({
...viewCards.map((c) => ({ kind: 'view' as const, id: c.output_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
...bCards.map((c) => ({ kind: 'browser' as const, id: c.browser_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
...wCards.map((c) => ({ kind: 'workflow' as const, id: c.workflow_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })),
...(hub ? [{ kind: 'workflows-hub' as const, id: 'workflows-hub', x: hub.x, y: hub.y, storedW: hub.width, storedH: hub.height }] : []),
];
allItems.sort((a, b) => a.y - b.y || a.x - b.x);
@@ -587,6 +614,8 @@ const dashboardLayoutSlice = createSlice({
} else if (item.kind === 'workflow') {
const card = state.workflowCards[item.id];
if (card) { card.x = pos.x; card.y = pos.y; }
} else if (item.kind === 'workflows-hub') {
if (state.workflowsHub) { state.workflowsHub.x = pos.x; state.workflowsHub.y = pos.y; }
} else {
const card = state.browserCards[item.id];
if (card) { card.x = pos.x; card.y = pos.y; }
@@ -915,6 +944,37 @@ const dashboardLayoutSlice = createSlice({
state.pendingFocusWorkflowsHub = false;
},
openMissedRunsCard(state, action: PayloadAction<{ expandedSessionIds?: string[] } | undefined>) {
state.pendingFocusMissedRuns = true;
if (state.missedRunsCard) {
state.missedRunsCard.zOrder = state.nextZOrder++;
return;
}
const rects = collectOccupiedRects(state, action.payload?.expandedSessionIds);
const pos = findOpenGridCell(rects, DEFAULT_MISSED_RUNS_W, DEFAULT_MISSED_RUNS_H);
state.missedRunsCard = {
x: pos.x,
y: pos.y,
width: DEFAULT_MISSED_RUNS_W,
height: DEFAULT_MISSED_RUNS_H,
zOrder: state.nextZOrder++,
};
},
clearPendingFocusMissedRuns(state) {
state.pendingFocusMissedRuns = false;
},
closeMissedRunsCard(state) {
state.missedRunsCard = null;
},
setMissedRunsCardPosition(state, action: PayloadAction<{ x: number; y: number }>) {
if (!state.missedRunsCard) return;
state.missedRunsCard.x = action.payload.x;
state.missedRunsCard.y = action.payload.y;
},
closeWorkflowsHub(state) {
state.workflowsHub = null;
},
@@ -1116,6 +1176,11 @@ const dashboardLayoutSlice = createSlice({
state.workflowsHub.x += dx;
state.workflowsHub.y += dy;
}
} else if (item.type === 'missed_runs') {
if (state.missedRunsCard) {
state.missedRunsCard.x += dx;
state.missedRunsCard.y += dy;
}
} else {
const card = state.browserCards[item.id];
if (card) {
@@ -1246,6 +1311,7 @@ const dashboardLayoutSlice = createSlice({
state.workflowCards = {};
state.configurePanels = {};
state.workflowsHub = null;
state.missedRunsCard = null;
state.notes = {};
state.closedCardPositions = {};
state.glowingBrowserCards = {};
@@ -1257,6 +1323,7 @@ const dashboardLayoutSlice = createSlice({
state.suspendedBrowserCards = {};
state.endingBrowserCards = {};
state.pendingFocusWorkflowId = null;
state.pendingFocusMissedRuns = false;
},
},
@@ -1417,6 +1484,10 @@ export const {
setWorkflowsHubPosition,
setWorkflowsHubSize,
clearPendingFocusWorkflowsHub,
openMissedRunsCard,
clearPendingFocusMissedRuns,
closeMissedRunsCard,
setMissedRunsCardPosition,
addNote,
setNotePosition,
setNoteSize,
@@ -0,0 +1,84 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const API = `${API_BASE}/workflows`;
export interface MissedRunItem {
id: string;
workflow_id: string;
workflow_title: string;
workflow_icon: string;
/** ISO instant the fire was supposed to happen. */
scheduled_for: string;
}
interface State {
items: MissedRunItem[];
loading: boolean;
toastOpen: boolean;
}
const initialState: State = {
items: [],
loading: false,
toastOpen: false,
};
export const fetchMissedRuns = createAsyncThunk('missedRuns/fetch', async () => {
const res = await fetch(`${API}/missed`);
const data = await res.json();
return data.missed as MissedRunItem[];
});
export const runMissedRuns = createAsyncThunk('missedRuns/run', async (ids: string[]) => {
await fetch(`${API}/missed/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
return ids;
});
export const dismissMissedRuns = createAsyncThunk('missedRuns/dismiss', async (ids: string[]) => {
await fetch(`${API}/missed/dismiss`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
return ids;
});
const missedRunsSlice = createSlice({
name: 'missedRuns',
initialState,
reducers: {
hideMissedRunsToast(state) {
state.toastOpen = false;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchMissedRuns.pending, (state) => {
state.loading = true;
})
.addCase(fetchMissedRuns.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload || [];
state.toastOpen = (action.payload?.length ?? 0) > 0;
})
.addCase(fetchMissedRuns.rejected, (state) => {
state.loading = false;
});
// Both run and dismiss remove the acted-on ids from the list.
for (const thunk of [runMissedRuns, dismissMissedRuns]) {
builder.addCase(thunk.fulfilled, (state, action) => {
const gone = new Set(action.payload);
state.items = state.items.filter((m) => !gone.has(m.id));
});
}
},
});
export const { hideMissedRunsToast } = missedRunsSlice.actions;
export default missedRunsSlice.reducer;
+2
View File
@@ -16,6 +16,7 @@ import modelsReducer from './modelsSlice';
import interactionReducer from './interactionSlice';
import subscriptionsReducer from './subscriptionsSlice';
import workflowsReducer from './workflowsSlice';
import missedRunsReducer from './missedRunsSlice';
import onboardingProgressReducer from '@/shared/state/onboardingProgressSlice';
export const store = configureStore({
@@ -37,6 +38,7 @@ export const store = configureStore({
interaction: interactionReducer,
subscriptions: subscriptionsReducer,
workflows: workflowsReducer,
missedRuns: missedRunsReducer,
onboardingProgress: onboardingProgressReducer,
},
// Disable Redux Toolkit's dev-mode invariant middleware (serializable +
+55 -2
View File
@@ -19,7 +19,6 @@ export interface ScheduleConfig {
hour: number;
minute: number;
timezone: string;
on_missed: 'skip' | 'run_once' | 'run_all';
/** End conditions; null on both = forever. Scheduler auto-disables on threshold. */
ends_at: string | null;
max_runs: number | null;
@@ -163,6 +162,12 @@ export interface OpenCard {
/** Pre-seed message for the Fix-with-Agent flow so the EditAgent composer
* knows which failure context to lead with. Cleared once consumed. */
fixSeed?: { runId: string; stepIdx: number; stepLabel: string; error: string } | null;
/** True while the preview-time aux naming call is in flight; drives the
* header's subtle pulse on a just-converted draft. */
metaLoading?: boolean;
/** True once preview-time naming filled a real title, so save trusts the
* draft's metadata instead of regenerating it server-side. */
metaGenerated?: boolean;
}
export interface RunningToast {
@@ -264,7 +269,7 @@ export const fetchWorkflows = createAsyncThunk(
export const createWorkflow = createAsyncThunk(
'workflows/create',
async (body: Partial<Workflow>) => {
async (body: Partial<Workflow> & { metadata_generated?: boolean }) => {
const res = await fetch(`${API}/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -275,6 +280,54 @@ export const createWorkflow = createAsyncThunk(
},
);
export interface GeneratedMetadata {
title: string;
description: string;
step_labels: string[];
}
export const generateWorkflowMetadata = createAsyncThunk(
'workflows/generateMetadata',
async (arg: { steps: Array<{ id: string; text: string }>; model?: string }) => {
const res = await fetch(`${API}/generate-metadata`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(arg),
});
if (!res.ok) throw new Error(`metadata failed ${res.status}`);
return (await res.json()) as GeneratedMetadata;
},
);
// Merge preview-time generated metadata into a draft card, filling only the
// fields the user hasn't typed into so a rename mid-flight survives.
export const applyGeneratedMetadata = createAsyncThunk(
'workflows/applyGeneratedMetadata',
async (arg: { workflowId: string; meta: GeneratedMetadata }, { getState, dispatch }) => {
const state = getState() as { workflows: State };
const card = state.workflows.openCards[arg.workflowId];
if (!card) return;
const draft = (card.draft || {}) as Partial<Workflow>;
const { meta } = arg;
const steps = draft.steps || [];
const nextDraft: Partial<Workflow> = { ...draft };
let changed = false;
if (meta.step_labels && meta.step_labels.length === steps.length) {
nextDraft.steps = steps.map((s, i) => (meta.step_labels[i] ? { ...s, label: meta.step_labels[i] } : s));
changed = true;
}
const hasTitle = Boolean(meta.title && meta.title.trim());
if (hasTitle && !(draft.title || '').trim()) { nextDraft.title = meta.title; changed = true; }
if (meta.description && meta.description.trim() && !(draft.description || '').trim()) {
nextDraft.description = meta.description;
changed = true;
}
const patch: Partial<OpenCard> = { metaLoading: false, metaGenerated: hasTitle };
if (changed) patch.draft = nextDraft;
dispatch(updateWorkflowCard({ workflowId: arg.workflowId, patch }));
},
);
// Optimistic concurrency via If-Match: server 409s on stale writes; rejectWithValue lets FE distinguish.
export const updateWorkflow = createAsyncThunk<
Workflow,