Merge remote-tracking branch 'origin/aidan/feat/scheduled-tasks' into aidan/feat/workflows-ux-polish

# Conflicts:
#	frontend/src/app/pages/Dashboard/cards/AgentCard.tsx
#	frontend/src/app/pages/Workflows/ScheduleCalendar.tsx
#	frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx
#	frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx
This commit is contained in:
abccodes
2026-06-19 19:58:10 -07:00
16 changed files with 646 additions and 140 deletions
+64 -9
View File
@@ -612,6 +612,12 @@ class AgentManager:
"mcp__openswarm-schedule__DeleteScheduledWorkflow",
"mcp__openswarm-schedule__PauseAllWorkflows",
}
_CLAUDE_INTERNAL_SCHEDULER_TOOLS = ("CronCreate", "CronList", "CronDelete")
def _is_claude_schedule_skill(tool_name: str, tool_input) -> bool:
if tool_name != "Skill" or not isinstance(tool_input, dict):
return False
return str(tool_input.get("skill") or "").strip().lower() == "schedule"
# OS-level scheduling across macOS/Linux/Windows. Agent must
# not install cron entries, launchd plists, Windows scheduled
@@ -732,6 +738,8 @@ class AgentManager:
"""
if tool_name == "Bash" and _looks_like_os_scheduling(tool_input):
return "ask", None
if tool_name in _CLAUDE_INTERNAL_SCHEDULER_TOOLS:
return "deny", None
# Committing or mutating a native recurring schedule is the in-app
# twin of the crontab gate above: real, user-visible, hard-to-undo,
# so it goes through ApprovalBar every time regardless of the
@@ -907,6 +915,14 @@ class AgentManager:
return decision
async def can_use_tool(tool_name, input_data, context):
if _is_claude_schedule_skill(tool_name, input_data):
p_note_tool_used(tool_name, False)
return PermissionResultDeny(
message=(
"Use the openswarm-schedule MCP tools instead of "
"Claude's internal schedule skill."
)
)
sensitive_pattern: str | None = None
if tool_name != "AskUserQuestion":
policy, sensitive_pattern = _maybe_override_policy(
@@ -936,6 +952,18 @@ class AgentManager:
if tool_name and tool_name != "AskUserQuestion":
tool_input = input_data.get("tool_input", {})
if _is_claude_schedule_skill(tool_name, tool_input):
p_note_tool_used(tool_name, False)
return {
"hookSpecificOutput": {
"hookEventName": hook_event,
"permissionDecision": "deny",
"permissionDecisionReason": (
"Use the openswarm-schedule MCP tools instead "
"of Claude's internal schedule skill."
),
}
}
policy, sensitive_pattern = _maybe_override_policy(
_get_effective_policy(tool_name), tool_name, tool_input
)
@@ -1225,17 +1253,35 @@ class AgentManager:
mcp_registry_ctx = self._build_mcp_registry_summary(session.allowed_tools, session.active_mcps)
global_settings = load_settings()
# Nudge the agent to surface workflow conversion proactively when
# the user's ask looks recurring. The actual conversion is owned
# by the UI prompt; chat should not turn this into a cadence Q&A.
schedule_ctx = (
"<scheduling_guidance>\n"
"After completing a substantive task, if the work looks "
"repeatable (the user said 'every', 'each', 'daily', "
"'weekly', 'morning', 'before standup', or you just did "
"the same sequence twice in this session), offer to "
"schedule it. Use AskUserQuestion to confirm cadence, "
"then ScheduleWorkflow to create it. Never reach for "
"crontab, launchctl, or schtasks; always use the native "
"scheduler so the user can see, pause, and edit it. "
"Don't ask after trivial one-off requests.\n"
"When a normal chat produces work that would naturally be "
"useful again later, prefer the openswarm-schedule MCP "
"workflow path over Claude's internal scheduler. If the user "
"explicitly says 'daily', 'weekly', 'every', 'each', "
"'morning', 'before standup', 'monitor', 'alert me', or "
"'keep this updated', treat that as a strong recurring-work "
"signal. After completing that work, call "
"SuggestConvertToWorkflow with a short reason and cadence "
"hint so the UI can open its workflow-conversion prompt. You "
"may say one short sentence before the tool call, such as "
"'This is a good fit for built-in Workflows.' Do not repeat "
"that advice after the tool call, do not send a follow-up "
"assistant message like 'Done', do not say you are 'nudging' "
"the UI, and do not mention the tool. If the user "
"then says 'ok', 'yes', or 'do it', do not ask what time and "
"do not schedule from chat; the UI prompt/Convert button owns "
"the conversion flow. Only call ScheduleWorkflow when the user "
"explicitly asks you to create a live schedule and has already "
"given an exact cadence and time (for example, 'every weekday "
"at 8am'). If cadence or time is missing, use "
"SuggestConvertToWorkflow instead of asking a follow-up. Never "
"use Skill('schedule'), CronCreate, CronList, CronDelete, "
"crontab, launchctl, or schtasks for user-facing recurring "
"work.\n"
"</scheduling_guidance>"
)
composed_prompt = self._compose_system_prompt(
@@ -1551,6 +1597,15 @@ class AgentManager:
else:
effective_allowed.append(f"mcp__{name}__*")
if "openswarm-schedule" in mcp_servers:
effective_allowed = [
t for t in effective_allowed
if t not in _CLAUDE_INTERNAL_SCHEDULER_TOOLS
]
for _bt in _CLAUDE_INTERNAL_SCHEDULER_TOOLS:
if _bt not in effective_disallowed:
effective_disallowed.append(_bt)
# If the openswarm-web MCP was registered, the CLI's built-in
# WebSearch/WebFetch are guaranteed to fail (no Anthropic
# backend). Suppress them so the model picks our MCP variants
+59 -5
View File
@@ -4,9 +4,9 @@
Why this exists: the agent should be able to schedule recurring work on
the user's behalf, but ALWAYS through the native scheduler (visible,
auditable, cost-capped) rather than `crontab`. Each tool is a thin
wrapper around /api/workflows/*. The descriptions are written to nudge
the agent toward AskUserQuestion-first behavior (confirm cadence with
the user before calling ScheduleWorkflow).
wrapper around /api/workflows/*. The descriptions are written to prefer
UI-owned workflow conversion for vague recurring asks, and to reserve
ScheduleWorkflow for exact, user-specified live schedules.
"""
import json
@@ -52,8 +52,13 @@ TOOLS = [
"name": "ScheduleWorkflow",
"description": (
"Create a recurring scheduled workflow for the user. Use this "
"ONLY after confirming cadence with the user via AskUserQuestion "
"(do not assume — the user must pick or accept the time). "
"ONLY when the user explicitly asks you to create a live schedule "
"and has already supplied an exact cadence and time. Do not use "
"this after a generic convert-to-workflow suggestion, and do not "
"ask follow-up questions like 'what time should it run' from a "
"normal chat. If cadence or time is missing, call "
"SuggestConvertToWorkflow instead so the UI can open the workflow "
"conversion prompt. "
"The workflow runs the listed steps on the schedule and is "
"visible in the user's Workflows hub. Never use crontab, "
"launchctl, or schtasks to schedule recurring work; always use "
@@ -237,6 +242,44 @@ TOOLS = [
"required": ["workflow_id"],
},
},
{
"name": "SuggestConvertToWorkflow",
"description": (
"Call this at the end of a response when the completed task is a clear "
"candidate for repeatable scheduled work (e.g. a daily report, weekly "
"digest, recurring data check, monitoring ping, inbox triage, or status "
"briefing). Prefer this native workflow nudge over Claude's internal "
"schedule skill or CronCreate/CronList/CronDelete tools. Use it whenever "
"the user explicitly mentions daily, weekly, every, each, mornings, "
"standup, monitoring, alerts, or keeping something updated, and when you "
"have just done a sequence that would naturally be useful again later. Do "
"NOT call it for one-off tasks, debugging sessions, creative work, or "
"anything where 'repeat it tomorrow' would be odd. It is OK to call this "
"more than once per session for distinct workflow candidates, but avoid "
"repeated nudges for the same task. This nudges the frontend to highlight "
"the 'Convert to Workflow' button and open the workflow-conversion "
"prompt. In user-facing text, say at most one short sentence, such "
"as: 'This is a good fit for built-in Workflows.' Do not repeat the "
"advice after this tool returns, do not say you are nudging the UI, "
"and do not ask what time it should run. After this tool returns, "
"do not send another assistant message like 'Done'; the UI prompt "
"will handle the next step."
),
"inputSchema": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"description": "A brief, user-friendly explanation of why this task is a good candidate for a recurring workflow (e.g. 'This is a daily report that stays the same'). Shown in the tool bubble.",
},
"suggested_cadence": {
"type": "string",
"description": "Optional freeform cadence hint (e.g. 'every weekday morning at 9am' or 'weekly on Monday'). Leave blank if uncertain. The frontend will parse it to prefill the schedule.",
},
},
"required": ["reason"],
},
},
]
@@ -298,6 +341,7 @@ def handle_schedule_workflow(args: dict) -> dict:
"steps": [{"id": f"s{i+1}", "text": s} for i, s in enumerate(steps_in) if s],
"schedule": schedule,
"source_session_id": args.get("source_session_id") or PARENT_SESSION_ID or None,
"dashboard_id": DASHBOARD_ID or None,
}
r = _call("POST", "/create", body)
if "_error" in r:
@@ -507,6 +551,15 @@ def handle_read_test_transcript(args: dict) -> dict:
return _ok(f"Test Agent transcript (status: {status}):\n\n{transcript}")
def handle_suggest_convert_to_workflow(args: dict) -> dict:
reason = (args.get("reason") or "").strip()
if not reason:
return _err("reason is required.")
cadence = (args.get("suggested_cadence") or "").strip()
result = json.dumps({"reason": reason, "cadence": cadence})
return {"content": [{"type": "text", "text": result}]}
HANDLERS = {
"ScheduleWorkflow": handle_schedule_workflow,
"ListScheduledWorkflows": handle_list,
@@ -520,6 +573,7 @@ HANDLERS = {
"DeleteWorkflowStep": handle_delete_step,
"TestWorkflow": handle_test_workflow,
"ReadTestTranscript": handle_read_test_transcript,
"SuggestConvertToWorkflow": handle_suggest_convert_to_workflow,
}
+10 -1
View File
@@ -288,7 +288,16 @@ async def create_workflow(body: WorkflowCreate):
pass
storage.save_workflow(wf)
scheduler.kick()
return _enriched(wf)
enriched = _enriched(wf)
try:
from backend.apps.agents.core.ws_manager import ws_manager
await ws_manager.broadcast_global("workflow:updated", {
"workflow_id": wf.id,
"workflow": enriched,
})
except Exception:
pass
return enriched
@workflows.router.post("/generate-metadata")
@@ -30,7 +30,7 @@ import ChatInput from '@/app/pages/AgentChat/ChatInput';
import type { ContextPath } from '@/app/components/editor/DirectoryBrowser';
import SchedulePopover from '@/app/pages/Workflows/SchedulePopover';
import { openWorkflowCard, fetchAllRuns, upsertRun } from '@/shared/state/workflowsSlice';
import { addWorkflowCard, openWorkflowsHub } from '@/shared/state/dashboardLayoutSlice';
import { addWorkflowCard, openWorkflowsHub, closeWorkflowsHub } from '@/shared/state/dashboardLayoutSlice';
import { useElementSelection } from '@/app/components/editor/ElementSelectionContext';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
@@ -191,6 +191,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
const allRuns = useAppSelector((s) => s.workflows.allRuns);
const allRunsLoading = useAppSelector((s) => s.workflows.allRunsLoading);
const workflowItems = useAppSelector((s) => s.workflows.items);
const workflowsHubOpen = useAppSelector((s) => Boolean(s.dashboardLayout.workflowsHub));
const outputList = useMemo(() => Object.values(outputs), [outputs]);
const filteredOutputs = useMemo(() => {
@@ -814,15 +815,16 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
enterDelay={200}
title={
<Box sx={{ textAlign: 'center' }}>
<Box sx={{ fontWeight: 600 }}>History O</Box>
<Box sx={{ fontWeight: 600 }}>Workflows</Box>
<Box sx={{ opacity: 0.6, fontSize: '0.7rem', mt: '1px' }}>Schedule and calendar</Box>
</Box>
}
>
<Box
role="button"
aria-label="History"
aria-label="Workflows"
tabIndex={0}
onClick={handleOpenHistory}
onClick={() => dispatch(workflowsHubOpen ? closeWorkflowsHub() : openWorkflowsHub({ expandedSessionIds: [] }))}
sx={{
display: 'flex',
alignItems: 'center',
@@ -830,14 +832,15 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
width: BTN,
height: BTN,
borderRadius: `${c.radius.md}px`,
color: c.text.tertiary,
color: workflowsHubOpen ? c.accent.primary : c.text.tertiary,
bgcolor: workflowsHubOpen ? c.bg.secondary : 'transparent',
cursor: 'pointer',
transition: 'opacity 0.15s, background-color 0.15s',
'&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary },
...popIn(3),
}}
>
<HistoryRoundedIcon sx={{ fontSize: 22 }} />
<CalendarMonthRounded sx={{ fontSize: 22 }} />
</Box>
</WarmTooltip>
@@ -876,6 +879,40 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
</Box>
</WarmTooltip>
<WarmTooltip
tokens={c}
placement="top"
arrow
enterDelay={200}
title={
<Box sx={{ textAlign: 'center' }}>
<Box sx={{ fontWeight: 600 }}>History O</Box>
</Box>
}
>
<Box
role="button"
aria-label="History"
tabIndex={0}
onClick={handleOpenHistory}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: BTN,
height: BTN,
borderRadius: `${c.radius.md}px`,
color: c.text.tertiary,
cursor: 'pointer',
transition: 'opacity 0.15s, background-color 0.15s',
'&:hover': { opacity: 1, bgcolor: c.bg.secondary, color: c.accent.primary },
...popIn(5),
}}
>
<HistoryRoundedIcon sx={{ fontSize: 22 }} />
</Box>
</WarmTooltip>
{placeholderItems.map(({ icon: PlaceholderIcon, label, sub }) => (
<WarmTooltip
key={label}
@@ -1,5 +1,6 @@
import React, { type RefObject } from 'react';
import Box from '@mui/material/Box';
import { useAppSelector } from '@/shared/hooks';
import DashboardToolbar from '../DashboardToolbar';
import CanvasControls from '../controls/CanvasControls';
import CardSearchPalette from '../controls/CardSearchPalette';
@@ -81,6 +82,7 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
toolbarPrefill,
toolbarPrefillMode,
}) => {
const missedRunsCard = useAppSelector((s) => s.dashboardLayout.missedRunsCard);
return (
<>
{/* Floating bottom toolbar */}
@@ -131,6 +133,7 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
browserCards,
workflowCards,
workflowsHub,
missedRunsCard,
}}
onMinimapPan={(px, py) => canvas.actions.setState({ panX: px, panY: py, zoom: canvas.zoom })}
/>
@@ -7,6 +7,7 @@ import Button from '@mui/material/Button';
import Tooltip from '@mui/material/Tooltip';
import Fade from '@mui/material/Fade';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import CheckIcon from '@mui/icons-material/Check';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
@@ -69,6 +70,93 @@ function extractStepsFromSession(session: { messages: Array<{ role: string; cont
return out;
}
function isWorkflowSuggestionTool(toolName: unknown, mcpServer?: unknown): boolean {
const normalizedTool = String(toolName || '').toLowerCase();
const normalizedServer = String(mcpServer || '').toLowerCase();
if (!normalizedTool) return false;
if (normalizedTool === 'suggestconverttoworkflow') return true;
if (normalizedTool.endsWith('__suggestconverttoworkflow')) return true;
return normalizedTool.includes('suggestconverttoworkflow') && (
normalizedTool.includes('openswarm-schedule') ||
normalizedServer.includes('openswarm-schedule')
);
}
function isScheduleWorkflowTool(toolName: unknown): boolean {
const normalizedTool = String(toolName || '').toLowerCase();
if (!normalizedTool) return false;
return normalizedTool === 'scheduleworkflow' || normalizedTool.endsWith('__scheduleworkflow');
}
function parseWorkflowSuggestion(text: unknown): { reason: string; cadence: string } | null {
if (typeof text !== 'string' || !text.trim()) return null;
try {
const parsed = JSON.parse(text);
if (!parsed?.reason || typeof parsed.reason !== 'string') return null;
return {
reason: parsed.reason,
cadence: typeof parsed.cadence === 'string'
? parsed.cadence
: (typeof parsed.suggested_cadence === 'string' ? parsed.suggested_cadence : ''),
};
} catch {
return null;
}
}
function parseWorkflowSuggestionFromContent(content: any): { reason: string; cadence: string } | null {
return parseWorkflowSuggestion(
content?.text ??
content?.content?.[0]?.text ??
content?.result ??
content?.output,
);
}
/** Detect if the session has a completed SuggestConvertToWorkflow tool call. */
function findWorkflowSuggestion(session: AgentSession): { reason: string; cadence: string } | null {
let found: { reason: string; cadence: string } | null = null;
for (const msg of session.messages || []) {
const msgAny = msg as any;
const directContent = msgAny.content;
if (msgAny.role === 'tool_result') {
const toolName = directContent?.tool_name ?? directContent?.tool ?? directContent?.name ?? msgAny.tool_name;
if (isWorkflowSuggestionTool(toolName, directContent?.mcpServer ?? msgAny.mcpServer)) {
found = parseWorkflowSuggestionFromContent(directContent) || found;
}
}
const blocks = Array.isArray(directContent) ? directContent : [];
for (const block of blocks) {
if (block?.type !== 'tool_result') continue;
const toolName = block?.tool_name ?? block?.tool ?? block?.name;
if (isWorkflowSuggestionTool(toolName, block?.mcpServer)) {
found = parseWorkflowSuggestionFromContent(block) || found;
}
}
}
return found;
}
/** Count completed ScheduleWorkflow tool calls so a new one (vs the mount baseline) can pop the workflow open. */
function countScheduleWorkflowCalls(session: AgentSession): number {
let count = 0;
for (const msg of session.messages || []) {
const msgAny = msg as any;
const directContent = msgAny.content;
if (msgAny.role === 'tool_result') {
const toolName = directContent?.tool_name ?? directContent?.tool ?? directContent?.name ?? msgAny.tool_name;
if (isScheduleWorkflowTool(toolName)) count += 1;
}
const blocks = Array.isArray(directContent) ? directContent : [];
for (const block of blocks) {
if (block?.type !== 'tool_result') continue;
if (isScheduleWorkflowTool(block?.tool_name ?? block?.tool ?? block?.name)) count += 1;
}
}
return count;
}
const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ service, size = 16 }) => {
if (service === 'gmail') {
return (
@@ -260,7 +348,10 @@ const AgentCard: React.FC<Props> = ({
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
const defaultMode = useAppSelector((s) => s.settings.data.default_mode);
const [converting, setConverting] = useState(false);
const [convertToast, setConvertToast] = useState<string | null>(null);
const [suggestGlowCycle, setSuggestGlowCycle] = useState(0);
const [workflowToast, setWorkflowToast] = useState('');
const [dismissedWorkflowPromptKey, setDismissedWorkflowPromptKey] = useState('');
const workflowSuggestion = useMemo(() => findWorkflowSuggestion(session), [session]);
// Hide the "Convert to workflow" button when this chat is already
// entangled with a workflow (Image #44 note). Two cases:
// (a) The session is one of a workflow's runner sessions, OR
@@ -304,24 +395,35 @@ const AgentCard: React.FC<Props> = ({
!sourceWorkflow.schedule?.enabled &&
(session.status === 'completed' || session.status === 'stopped') &&
session.messages.length >= 2;
// The convert button stays visible whenever this is a real, convertible
// chat; it's only greyed out (not hidden) while a turn is still running so
// the affordance doesn't flicker in and out mid-response.
const turnSettled = session.status === 'completed' || session.status === 'stopped';
const showConvert = session.messages.length >= 2 && !isWorkflowRunnerSession;
const handleConvert = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
const hasUserPrompt = useMemo(
() => (session.messages || []).some((m) => m.role === 'user' && !m.hidden),
[session.messages],
);
const isConvertBlockedByTurn = session.status !== 'completed' && session.status !== 'stopped';
const showConvertToWorkflow =
!session.is_welcome_draft &&
!isWorkflowRunnerSession &&
hasUserPrompt &&
(session.messages.length >= 2 || isConvertBlockedByTurn || !!workflowSuggestion);
const canConvertToWorkflow = showConvertToWorkflow && !isConvertBlockedByTurn && !converting;
const workflowSuggestionKey = workflowSuggestion
? `${session.id}:${workflowSuggestion.reason}:${workflowSuggestion.cadence}`
: '';
const showWorkflowSuggestionPrompt = Boolean(
workflowSuggestion &&
canConvertToWorkflow &&
workflowSuggestionKey &&
dismissedWorkflowPromptKey !== workflowSuggestionKey,
);
const convertChatToWorkflow = useCallback(() => {
if (converting) return;
if (session.status !== 'completed' && session.status !== 'stopped') {
setConvertToast("Can't convert to workflow mid-turn. Wait for the agent to finish.");
const steps = extractStepsFromSession(session);
if (steps.length === 0) {
setWorkflowToast('Add a prompt before converting this chat to a workflow.');
return;
}
const steps = extractStepsFromSession(session);
if (steps.length === 0) return;
setConverting(true);
const draftId = `draft-${session.id}-${Date.now()}`;
// The chat card becomes a temporary workflow draft in the same slot.
// Nothing is persisted until the user chooses Save Draft or Schedule.
dispatch(addWorkflowCard({ workflowId: draftId, sourceSessionId: session.id, expandedSessionIds }));
dispatch(setWorkflowCardPosition({ workflowId: draftId, x: cardX, y: cardY }));
dispatch(setWorkflowCardSize({ workflowId: draftId, width: cardWidth, height: cardHeight }));
@@ -341,6 +443,7 @@ const AgentCard: React.FC<Props> = ({
use_synced_prompt: true,
model: defaultModel || session.model,
mode: defaultMode || session.mode,
suggested_cadence: workflowSuggestion?.cadence || undefined,
} as Partial<Workflow>,
}));
const genModel = defaultModel || session.model;
@@ -352,7 +455,28 @@ const AgentCard: React.FC<Props> = ({
dispatch(updateWorkflowCard({ workflowId: draftId, patch: { metaLoading: false } }));
}
});
}, [converting, session, dispatch, expandedSessionIds, cardX, cardY, cardWidth, cardHeight, defaultModel, defaultMode]);
}, [
cardHeight,
cardWidth,
cardX,
cardY,
converting,
defaultMode,
defaultModel,
dispatch,
expandedSessionIds,
isConvertBlockedByTurn,
session,
workflowSuggestion?.cadence,
]);
const handleConvertToWorkflow = useCallback((e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
if (isConvertBlockedByTurn) {
setWorkflowToast('Cannot convert to a workflow while the agent is responding.');
return;
}
convertChatToWorkflow();
}, [convertChatToWorkflow, isConvertBlockedByTurn]);
// Curated picker label with a tidy fallback for unknowns.
const friendlyModelLabel = useMemo(() => {
const value = session.model;
@@ -369,6 +493,43 @@ const AgentCard: React.FC<Props> = ({
}, [session.model, modelsByProvider]);
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
const suggestionPulseRef = useRef('');
const readyPulseRef = useRef('');
useEffect(() => {
if (!workflowSuggestion) return;
const key = `${workflowSuggestion.reason}|${workflowSuggestion.cadence}`;
if (canConvertToWorkflow) {
if (readyPulseRef.current === key) return;
readyPulseRef.current = key;
} else {
if (suggestionPulseRef.current === key) return;
suggestionPulseRef.current = key;
}
setSuggestGlowCycle((n) => n + 1);
dispatch(fadeGlowingAgentCard(session.id, 3200));
}, [workflowSuggestion, canConvertToWorkflow, dispatch, session.id]);
// When the agent schedules a workflow from this chat, pop its card open
// next to the chat. Baseline the count once on mount so historical
// schedules (e.g. after an app reload) don't re-open on their own.
const scheduleWorkflowCount = useMemo(() => countScheduleWorkflowCalls(session), [session]);
const baselineScheduleCountRef = useRef<number | null>(null);
const autoOpenedWorkflowIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (baselineScheduleCountRef.current === null) {
baselineScheduleCountRef.current = scheduleWorkflowCount;
return;
}
if (scheduleWorkflowCount <= baselineScheduleCountRef.current) return;
for (const wf of Object.values(workflowItems || {})) {
if (wf.source_session_id !== session.id) continue;
if (autoOpenedWorkflowIdsRef.current.has(wf.id)) continue;
autoOpenedWorkflowIdsRef.current.add(wf.id);
dispatch(addWorkflowCard({ workflowId: wf.id, sourceSessionId: session.id, expandedSessionIds }));
dispatch(openWorkflowCard({ workflowId: wf.id, view: 'saved' }));
}
}, [scheduleWorkflowCount, workflowItems, session.id, dispatch, expandedSessionIds]);
const cardBoxRef = useRef<HTMLDivElement>(null);
// Ref so ResizeObserver sees latest value without re-attaching when active flips.
const isDashboardActiveRef = useRef(isDashboardActive);
@@ -975,62 +1136,82 @@ const AgentCard: React.FC<Props> = ({
</Box>
</Box>
<Box sx={{
display: isDraft && !expanded ? 'none' : 'flex',
alignItems: 'center',
gap: 1.5,
flexShrink: 0,
...(isDraft && { visibility: 'hidden' }),
}}>
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
{friendlyModelLabel}
</Typography>
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
<ElapsedTimer messages={session.messages} status={session.status} />
</Typography>
{session.cost_usd > 0 && hasApiKey && (
<Typography variant="caption" sx={{ color: c.accent.primary }}>
${session.cost_usd.toFixed(4)}
<Box
sx={{
display: isDraft && !expanded ? 'none' : 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
flexShrink: 0,
minWidth: 0,
...(isDraft && { visibility: 'hidden' }),
}}
>
<Box sx={{ display: 'flex', gap: 1.5, minWidth: 0, overflow: 'hidden' }}>
<Typography variant="caption" sx={{ color: c.text.tertiary, whiteSpace: 'nowrap' }}>
{friendlyModelLabel}
</Typography>
)}
{showConvert && (
<>
<Box sx={{ flex: 1 }} />
<Tooltip title={turnSettled ? 'Turn this chat into a reusable, schedulable workflow' : 'Wait for the agent to finish before converting'}>
<Box
role="button"
onClick={handleConvert}
onMouseDown={(e) => e.stopPropagation()}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5,
color: '#fff',
bgcolor: c.accent.primary,
border: `1px solid ${c.accent.primary}`,
fontSize: '0.78rem', fontWeight: 700,
px: 1.1, py: 0.5,
borderRadius: `${c.radius.md}px`,
cursor: converting ? 'wait' : turnSettled ? 'pointer' : 'not-allowed',
opacity: converting ? 0.7 : turnSettled ? 1 : 0.45,
'&:hover': turnSettled && !converting ? { filter: 'brightness(1.05)' } : {},
}}
>
<AutoAwesomeOutlinedIcon sx={{ fontSize: 14 }} />
{converting ? 'Converting…' : 'Convert to workflow'}
</Box>
</Tooltip>
</>
<Typography variant="caption" sx={{ color: c.text.tertiary, whiteSpace: 'nowrap' }}>
<ElapsedTimer messages={session.messages} status={session.status} />
</Typography>
{session.cost_usd > 0 && hasApiKey && (
<Typography variant="caption" sx={{ color: c.accent.primary, whiteSpace: 'nowrap' }}>
${session.cost_usd.toFixed(4)}
</Typography>
)}
</Box>
{showConvertToWorkflow && (
<Tooltip title={canConvertToWorkflow ? 'Turn this chat into a reusable workflow' : 'Wait for the current response to finish before converting'}>
<Box
key={`convert-workflow-${suggestGlowCycle}-${canConvertToWorkflow ? 'ready' : 'blocked'}`}
component={motion.div}
role="button"
aria-disabled={!canConvertToWorkflow}
onClick={handleConvertToWorkflow}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
animate={suggestGlowCycle > 0 ? {
scale: [1, 1.06, 1, 1.045, 1],
filter: ['brightness(1)', 'brightness(1.18)', 'brightness(1)', 'brightness(1.12)', 'brightness(1)'],
boxShadow: [
`0 0 0 0 ${c.accent.primary}00`,
`0 0 0 4px ${c.accent.primary}99, 0 0 20px 6px ${c.accent.primary}66`,
`0 0 0 8px ${c.accent.primary}00`,
`0 0 0 3px ${c.accent.primary}88, 0 0 16px 4px ${c.accent.primary}55`,
canConvertToWorkflow ? c.shadow.sm : 'none',
],
} : undefined}
transition={{ duration: 2.4, ease: 'easeInOut' }}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.35,
color: canConvertToWorkflow ? '#fff' : c.text.tertiary,
bgcolor: canConvertToWorkflow ? c.accent.primary : c.bg.secondary,
border: `1px solid ${canConvertToWorkflow ? c.accent.primary : c.border.medium}`,
fontSize: '0.68rem',
lineHeight: 1,
fontWeight: 700,
px: 0.8,
py: 0.35,
minHeight: 22,
borderRadius: `${c.radius.sm}px`,
cursor: canConvertToWorkflow ? (converting ? 'wait' : 'pointer') : 'not-allowed',
opacity: converting ? 0.7 : 1,
whiteSpace: 'nowrap',
flexShrink: 0,
boxShadow: canConvertToWorkflow ? c.shadow.sm : 'none',
'&:hover': canConvertToWorkflow ? { filter: 'brightness(1.05)' } : { bgcolor: c.bg.secondary },
}}
>
<AutoAwesomeOutlinedIcon sx={{ fontSize: 13 }} />
{converting ? 'Converting...' : 'Convert to workflow'}
</Box>
</Tooltip>
)}
</Box>
</Box>
<Snackbar
open={Boolean(convertToast)}
autoHideDuration={4000}
onClose={() => setConvertToast(null)}
message={convertToast || ''}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
/>
{expanded && (
<Box
onClick={(e) => e.stopPropagation()}
@@ -1239,6 +1420,95 @@ const AgentCard: React.FC<Props> = ({
) : null}
</>
)}
<Fade in={showWorkflowSuggestionPrompt} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box
onClick={(e) => {
e.stopPropagation();
setDismissedWorkflowPromptKey(workflowSuggestionKey);
}}
sx={{
position: 'absolute',
inset: 0,
zIndex: 30,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 2,
bgcolor: 'rgba(0,0,0,0.28)',
backdropFilter: 'blur(1.5px)',
}}
>
<Box
onClick={(e) => e.stopPropagation()}
sx={{
width: '100%',
maxWidth: 340,
bgcolor: c.bg.surface,
color: c.text.primary,
border: `1px solid ${c.border.medium}`,
borderRadius: `${c.radius.lg}px`,
boxShadow: c.shadow.lg,
p: 2.25,
}}
>
<Typography sx={{ fontSize: '0.98rem', fontWeight: 700, mb: 0.75 }}>
Would you like to make this a workflow?
</Typography>
<Typography sx={{ fontSize: '0.85rem', lineHeight: 1.5, color: c.text.secondary }}>
I can open a workflow draft from this chat. You can review the steps and choose the schedule there.
</Typography>
{workflowSuggestion?.cadence && (
<Typography sx={{ mt: 1, fontSize: '0.78rem', color: c.text.tertiary }}>
Suggested cadence: {workflowSuggestion.cadence}
</Typography>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 2 }}>
<Button
variant="text"
size="small"
onClick={() => setDismissedWorkflowPromptKey(workflowSuggestionKey)}
sx={{ textTransform: 'none', color: c.text.tertiary, fontWeight: 700 }}
>
No, keep chatting
</Button>
<Button
variant="contained"
size="small"
startIcon={<AutoAwesomeOutlinedIcon sx={{ fontSize: 14 }} />}
onClick={() => {
setDismissedWorkflowPromptKey(workflowSuggestionKey);
convertChatToWorkflow();
}}
sx={{
textTransform: 'none',
bgcolor: c.accent.primary,
color: '#fff',
fontWeight: 700,
boxShadow: c.shadow.sm,
'&:hover': { bgcolor: c.accent.hover },
}}
>
Yes, open workflow
</Button>
</Box>
</Box>
</Box>
</Fade>
<Snackbar
open={!!workflowToast}
autoHideDuration={3200}
onClose={() => setWorkflowToast('')}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity="info"
variant="filled"
onClose={() => setWorkflowToast('')}
sx={{ fontSize: '0.78rem' }}
>
{workflowToast}
</Alert>
</Snackbar>
</Box>
</motion.div>
);
@@ -1,6 +1,6 @@
import React, { useRef, useCallback, useMemo } from 'react';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice';
import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition, WorkflowsHubPosition, MissedRunsCardPosition } from '@/shared/state/dashboardLayoutSlice';
const MINIMAP_W = 200;
const MINIMAP_H = 140;
@@ -16,6 +16,7 @@ export interface MinimapProps {
browserCards: Record<string, BrowserCardPosition>;
workflowCards: Record<string, WorkflowCardPosition>;
workflowsHub: WorkflowsHubPosition | null;
missedRunsCard: MissedRunsCardPosition | null;
onPan: (panX: number, panY: number) => void;
}
@@ -24,12 +25,12 @@ interface CardRect {
y: number;
width: number;
height: number;
type: 'agent' | 'view' | 'browser' | 'workflow' | 'workflows-hub';
type: 'agent' | 'view' | 'browser' | 'workflow' | 'workflows-hub' | 'missed-runs';
}
const Minimap: React.FC<MinimapProps> = ({
panX, panY, zoom, viewportRef,
cards, viewCards, browserCards, workflowCards, workflowsHub,
cards, viewCards, browserCards, workflowCards, workflowsHub, missedRunsCard,
onPan,
}) => {
const c = useClaudeTokens();
@@ -59,8 +60,17 @@ const Minimap: React.FC<MinimapProps> = ({
type: 'workflows-hub',
});
}
if (missedRunsCard) {
result.push({
x: missedRunsCard.x,
y: missedRunsCard.y,
width: missedRunsCard.width,
height: missedRunsCard.height,
type: 'missed-runs',
});
}
return result;
}, [cards, viewCards, browserCards, workflowCards, workflowsHub]);
}, [cards, viewCards, browserCards, workflowCards, workflowsHub, missedRunsCard]);
const layout = useMemo(() => {
const vp = viewportRef.current;
@@ -149,6 +159,7 @@ const Minimap: React.FC<MinimapProps> = ({
case 'browser': return c.status.success;
case 'workflow': return c.status.warning;
case 'workflows-hub': return c.status.warning;
case 'missed-runs': return c.status.warning;
}
};
@@ -2,7 +2,7 @@ import { useEffect, type Dispatch, type SetStateAction } from 'react';
import { report } from '@/shared/serviceClient';
import { useAppDispatch } from '@/shared/hooks';
import { closeSession, toggleExpandSession } from '@/shared/state/agentsSlice';
import { removeViewCard, removeBrowserCard, removeNote, removeWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
import { removeViewCard, removeBrowserCard, removeNote, removeWorkflowCard, closeWorkflowsHub } from '@/shared/state/dashboardLayoutSlice';
import { closeWorkflowCard } from '@/shared/state/workflowsSlice';
import type { useDashboardSelection } from '../state/useDashboardSelection';
@@ -83,6 +83,8 @@ export function useDashboardShortcuts({
} else if (type === 'workflow') {
dispatch(removeWorkflowCard(id));
dispatch(closeWorkflowCard(id));
} else if (type === 'workflows-hub') {
dispatch(closeWorkflowsHub());
}
}
selection.deselectAll();
@@ -255,25 +255,25 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
return (
<Box sx={{ display: 'flex', flexDirection: 'column', color: c.text.secondary }}>
{/* Day headers: muted weekday caps; today's date gets the filled circle */}
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', gap: 0, position: 'sticky', top: 0, bgcolor: c.bg.surface, zIndex: 2, pb: 0.5 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', gap: 0, position: 'sticky', top: 0, bgcolor: c.bg.surface, zIndex: 4, borderBottom: `1px solid ${c.border.subtle}`, pt: 1.25, pb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'flex-end', pr: 1, pb: 0.5 }}>
{!compact && (
<Typography sx={{ fontSize: '0.62rem', color: c.text.ghost, fontWeight: 500 }}>{TZ_LABEL}</Typography>
)}
</Box>
{days.map((d) => {
const isToday = sameDay(d, today);
const isToday = sameDay(d, now);
return (
<Box key={d.toISOString()} sx={{ textAlign: 'center', pb: 0.5 }}>
<Typography sx={{ fontSize: DAY_LABEL, color: c.text.muted, fontWeight: 600, letterSpacing: '0.08em', lineHeight: 1.3, textTransform: 'uppercase' }}>
{WEEKDAY_LABEL_SHORT[d.getDay()]}
</Typography>
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: compact ? 30 : 38, height: compact ? 30 : 38, borderRadius: '50%', bgcolor: isToday ? c.accent.primary : 'transparent', color: isToday ? '#fff' : c.text.primary, fontWeight: isToday ? 700 : 500, fontSize: DAY_NUM, mt: 0.25 }}>{d.getDate()}</Box>
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, boxSizing: 'border-box', width: compact ? 26 : 32, height: compact ? 26 : 32, borderRadius: '50%', bgcolor: isToday ? c.accent.primary : 'transparent', color: isToday ? '#fff' : c.text.primary, fontWeight: isToday ? 600 : 500, fontSize: DAY_NUM, lineHeight: 1, mt: 0.25, boxShadow: isToday ? `0 0 0 1.5px ${c.bg.surface}, 0 0 0 3px ${c.accent.primary}` : 'none' }}>{d.getDate()}</Box>
</Box>
);
})}
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', borderTop: `1px solid ${c.border.subtle}`, position: 'relative' }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', position: 'relative' }}>
{HOURS.map((hour, hourIdx) => (
<React.Fragment key={hour}>
{/* Hour label sits inside its row (top-aligned) rather than
@@ -348,43 +348,55 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
const cells = Array.from({ length: 35 }, (_, i) => addDays(start, i));
const accent = c.accent.primary;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }}>
{/* Sticky weekday header so it stays visible even when the
calendar body scrolls. Slightly bigger + tinted bg so it
reads cleanly in both light and dark themes. */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', position: 'sticky', top: 0, bgcolor: c.bg.surface, zIndex: 2, borderBottom: `1px solid ${c.border.subtle}`, py: 0.6 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', flexShrink: 0, position: 'sticky', top: 0, bgcolor: c.bg.surface, zIndex: 2, borderBottom: `1px solid ${c.border.subtle}`, pt: 1.25, pb: 0.6 }}>
{WEEKDAY_LABEL_SHORT.map((l, i) => (
<Typography key={`${l}-${i}`} sx={{ textAlign: 'center', fontSize: '0.74rem', color: c.text.muted, fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase' }}>{l}</Typography>
))}
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 0, borderLeft: `1px solid ${c.border.subtle}` }}>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gridTemplateRows: `repeat(5, minmax(${compact ? 70 : 96}px, 1fr))`, flex: 1, minHeight: 0, gap: 0, borderLeft: `1px solid ${c.border.subtle}` }}>
{cells.map((d) => {
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const evs = eventsByDay.map.get(key) || [];
const isToday = sameDay(d, today);
const isToday = sameDay(d, now);
const inMonth = d.getMonth() === today.getMonth();
return (
<Box key={d.toISOString()} sx={{ minHeight: compact ? 70 : 96, borderRight: `1px solid ${c.border.subtle}`, borderBottom: `1px solid ${c.border.subtle}`, p: 0.5, position: 'relative', overflow: 'hidden', bgcolor: inMonth ? 'transparent' : c.bg.elevated }}>
<Box key={d.toISOString()} sx={{ borderRight: `1px solid ${c.border.subtle}`, borderBottom: `1px solid ${c.border.subtle}`, p: 0.5, position: 'relative', overflow: 'hidden', bgcolor: inMonth ? 'transparent' : c.bg.elevated }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
{/* Out-of-month dates still need to be legible (Apple
Calendar shows them in a muted shade, not invisible).
Color tweak instead of opacity so dark themes stay
readable. */}
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', minWidth: 22, height: 22, borderRadius: '50%', bgcolor: isToday ? accent : 'transparent', color: isToday ? '#fff' : inMonth ? c.text.primary : c.text.ghost, fontWeight: isToday ? 700 : 500, fontSize: '0.82rem', px: 0.5 }}>{d.getDate()}</Box>
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, boxSizing: 'border-box', width: 22, height: 22, borderRadius: '50%', bgcolor: isToday ? accent : 'transparent', color: isToday ? '#fff' : inMonth ? c.text.primary : c.text.ghost, fontWeight: isToday ? 600 : 500, fontSize: '0.82rem', lineHeight: 1, boxShadow: isToday ? `0 0 0 1.5px ${c.bg.surface}, 0 0 0 3px ${accent}` : 'none' }}>{d.getDate()}</Box>
</Box>
{evs.slice(0, compact ? 3 : 4).map((e, idx) => (
{evs.slice(0, compact ? 3 : 4).map((e, idx) => {
// Past fires read as a hollow ring, upcoming ones stay filled,
// so a glance down a day tells you what already ran.
const passed = e.date.getTime() < now.getTime();
return (
<Box
key={`${e.workflow.id}-${idx}`}
onClick={() => onSelectWorkflow?.(e.workflow.id)}
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
sx={{ mt: 0.3, display: 'flex', alignItems: 'center', gap: 0.5, fontSize: EVENT_FS, color: c.text.primary, cursor: 'pointer', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', '&:hover': { color: accent } }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: accent, flexShrink: 0 }} />
<Box sx={{ width: 6, height: 6, borderRadius: '50%', boxSizing: 'border-box', bgcolor: passed ? 'transparent' : accent, border: passed ? `1.5px solid ${accent}` : 'none', flexShrink: 0 }} />
<span style={{ color: c.text.muted, flexShrink: 0 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, fontWeight: 500 }}>{e.workflow.title}</span>
</Box>
))}
);
})}
{evs.length > (compact ? 3 : 4) && (
<Typography sx={{ fontSize: EVENT_FS, color: c.text.muted, mt: 0.3, pl: 1.4 }}>+{evs.length - (compact ? 3 : 4)} more</Typography>
<MonthDayOverflow
date={d}
count={evs.length - (compact ? 3 : 4)}
events={evs}
now={now}
fontSize={EVENT_FS}
onSelectWorkflow={onSelectWorkflow}
/>
)}
</Box>
);
@@ -571,6 +583,57 @@ function EventStack({ events, paused, onSelectWorkflow, eventFontSize, onContext
);
}
// "+N more" on a packed month cell opens a scrollable popover listing every
// run that day, so a heavy day isn't a dead end. Past fires keep the hollow
// ring the cell rows use, for a consistent at-a-glance "already ran" read.
function MonthDayOverflow({ date, count, events, now, fontSize, onSelectWorkflow }: {
date: Date;
count: number;
events: { workflow: Workflow; date: Date }[];
now: Date;
fontSize: string;
onSelectWorkflow?: (id: string) => void;
}) {
const c = useClaudeTokens();
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const accent = c.accent.primary;
return (
<>
<Typography
onClick={(e) => { e.stopPropagation(); setAnchor(e.currentTarget); }}
role="button"
sx={{ fontSize, color: c.text.muted, mt: 0.3, pl: 1.4, cursor: 'pointer', '&:hover': { color: accent } }}>
+{count} more
</Typography>
<Popover
open={Boolean(anchor)}
anchorEl={anchor}
onClose={() => setAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}>
<Box sx={{ minWidth: 240, maxHeight: 360, overflowY: 'auto', p: 1 }}>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.5 }}>
{`${events.length} scheduled · ${date.toLocaleString('en', { weekday: 'short', month: 'short', day: 'numeric' })}`}
</Typography>
{events.map((e, idx) => {
const passed = e.date.getTime() < now.getTime();
return (
<Box
key={`${e.workflow.id}-${idx}`}
onClick={() => { setAnchor(null); onSelectWorkflow?.(e.workflow.id); }}
sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 0.5, py: 0.5, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', boxSizing: 'border-box', bgcolor: passed ? 'transparent' : accent, border: passed ? `1.5px solid ${accent}` : 'none', flexShrink: 0 }} />
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.workflow.title}</Typography>
<Typography sx={{ fontSize: '0.74rem', color: c.text.muted, flexShrink: 0 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
</Box>
);
})}
</Box>
</Popover>
</>
);
}
function EventTooltipBody({ event }: { event: { workflow: Workflow; date: Date } }) {
const wf = event.workflow;
const status = wf.last_run_status;
@@ -226,8 +226,8 @@ export default function SchedulePopover({
<IconButton size="small" onClick={onNext} sx={{ p: 0.3, color: c.text.muted, '&:hover': { color: c.text.primary } }}><ChevronRightIcon sx={{ fontSize: 17 }} /></IconButton>
<Typography sx={{ fontSize: '0.84rem', fontWeight: 600, color: c.text.primary, ml: 0.25 }}>{periodLabel}</Typography>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', px: 1.5, py: 1, borderTop: `1px solid ${c.border.subtle}`, minHeight: 0 }}>
<ScheduleCalendar view={calendarView} density="roomy" onSelectWorkflow={onWorkflowSelect} refDate={refDate} />
<Box sx={{ flex: 1, overflowY: 'auto', px: 1.5, pt: 0, pb: 1, borderTop: `1px solid ${c.border.subtle}`, minHeight: 0 }}>
<ScheduleCalendar view={calendarView} density="compact" onSelectWorkflow={onWorkflowSelect} refDate={refDate} />
</Box>
</Box>
)}
@@ -66,11 +66,13 @@ export default function SchedulingView({ workflow, steps }: Props) {
(async () => {
try {
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
const cadenceHint = workflow.suggested_cadence ? ` I think it should run ${workflow.suggested_cadence}.` : '';
const prompt = `Greet me in one short sentence, then ask exactly: "When should this workflow run (e.g. every Wednesday at 1pm)?"${cadenceHint}`;
await fetch(`${API_BASE}/agents/sessions/${encodeURIComponent(scheduleSessionId)}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) },
body: JSON.stringify({
prompt: 'Greet me in one short sentence, then ask exactly: "When should this workflow run (e.g. every Wednesday at 1pm)?"',
prompt,
hidden: true,
}),
});
@@ -678,9 +678,9 @@ function groupKey(iso: string): string {
export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleFor }: { runs: WorkflowRun[]; onOpen: (r: WorkflowRun) => void; showWorkflow?: boolean; workflowTitleFor?: (workflowId: string) => string }) {
const c = useClaudeTokens();
const [expandedId, setExpandedId] = useState<string | null>(null);
// Filter chips: all / failures / late. Power-users debugging a flaky
// workflow shouldn't have to scroll past successes.
const [filter, setFilter] = useState<'all' | 'failure' | 'ran_late'>('all');
// Filter chips: all / success / failures / skipped. Power-users debugging a
// flaky workflow shouldn't have to scroll past the runs they don't care about.
const [filter, setFilter] = useState<'all' | 'success' | 'failure' | 'skipped'>('all');
const filtered = useMemo(() => {
if (filter === 'all') return runs;
return (runs || []).filter((r) => r.status === filter);
@@ -707,7 +707,7 @@ export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleF
</Typography>
)}
<Box sx={{ flex: 1 }} />
{(['all', 'failure', 'ran_late'] as const).map((k) => (
{(['all', 'success', 'failure', 'skipped'] as const).map((k) => (
<Box key={k} onClick={() => setFilter(k)} role="button" sx={{
fontSize: '0.72rem', fontWeight: 600,
color: filter === k ? c.accent.primary : c.text.muted,
@@ -716,7 +716,7 @@ export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleF
px: 0.75, py: 0.3, borderRadius: c.radius.full, cursor: 'pointer',
'&:hover': { color: c.accent.primary },
}}>
{k === 'all' ? 'All' : k === 'failure' ? 'Failures only' : 'Ran late only'}
{k === 'all' ? 'All' : k === 'success' ? 'Success' : k === 'failure' ? 'Failures' : 'Skipped'}
</Box>
))}
</Box>
@@ -754,19 +754,17 @@ 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, p: 1, bgcolor: c.bg.elevated, borderRadius: c.radius.sm, border: `1px solid ${c.border.subtle}` }}>
<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>
) : (
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, lineHeight: 1.4 }}>
{r.session_id ? 'Click below to see the full conversation.' : 'No session was recorded for this run. Click below to see the full conversation.'}
</Typography>
)}
<Box sx={{ mt: 0.5, display: 'flex', justifyContent: 'flex-end' }}>
<Box onClick={(e) => { e.stopPropagation(); onOpen(r); }} role="button" sx={{ fontSize: '0.74rem', fontWeight: 600, color: c.accent.primary, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}>
See full conversation
) : r.session_id ? (
<Box onClick={(e) => { e.stopPropagation(); onOpen(r); }} role="button" sx={{ fontSize: '0.78rem', fontWeight: 600, color: c.accent.primary, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}>
Click to see the full conversation
</Box>
</Box>
) : (
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.4 }}>No session was recorded for this run.</Typography>
)}
</Box>
)}
</Box>
@@ -342,6 +342,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
border,
borderRadius: 3,
boxShadow: shadow,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: (isDragging || isResizing) ? 999999 : cardZOrder,
@@ -356,7 +357,8 @@ const WorkflowsHubCard: React.FC<Props> = ({
onPointerUp={onHeaderPointerUp}
sx={{
display: 'flex', alignItems: 'center', gap: 0.6,
px: 1.5, py: 0.6,
px: 1.5, py: 0.5,
bgcolor: c.bg.elevated,
borderBottom: `1px solid ${c.border.subtle}`,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none', userSelect: 'none',
@@ -381,7 +383,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
</Box>
{/* ===== Toolbar row (matches Figma image #8 header) ===== */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.65, px: 1.5, py: 0.7, borderBottom: `1px solid ${c.border.subtle}`, flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.65, px: 1.5, py: 0.55, bgcolor: c.bg.elevated, borderBottom: `1px solid ${c.border.subtle}`, flexShrink: 0 }}>
<Tooltip title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}>
<IconButton size="small" data-no-drag onClick={() => setSidebarOpen((v) => !v)} sx={{ p: 0.5, color: sidebarOpen ? c.text.secondary : c.text.muted, '&:hover': { color: c.text.primary } }}>
<MenuIcon sx={{ fontSize: 18 }} />
@@ -393,9 +395,9 @@ const WorkflowsHubCard: React.FC<Props> = ({
data-no-drag
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.4,
fontSize: '0.82rem', fontWeight: 600, color: c.text.primary,
bgcolor: c.bg.elevated, border: `1px solid ${c.border.subtle}`,
px: 1, py: 0.35, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
fontSize: '0.85rem', fontWeight: 600, color: c.text.primary,
bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`,
px: 1, py: 0.4, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
'&:hover': { borderColor: c.accent.primary, color: c.accent.primary },
}}
>
@@ -436,9 +438,6 @@ const WorkflowsHubCard: React.FC<Props> = ({
<Typography sx={{ fontSize: '0.92rem', fontWeight: 600, color: c.text.primary }}>{monthLabel}</Typography>
</Box>
<IconButton size="small" data-no-drag sx={{ p: 0.5, color: c.text.muted }}>
<SearchIcon sx={{ fontSize: 18 }} />
</IconButton>
<Box sx={{ position: 'relative' }}>
<Box
onClick={() => setViewOpen((v) => !v)}
@@ -474,8 +473,8 @@ const WorkflowsHubCard: React.FC<Props> = ({
<Box sx={{ flex: 1, display: 'flex', minHeight: 0 }}>
{/* Sidebar */}
{sidebarOpen && (
<Box sx={{ width: 240, flexShrink: 0, borderRight: `1px solid ${c.border.subtle}`, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ px: 1.5, pt: 1.25, pb: 0.75 }}>
<Box sx={{ width: 210, flexShrink: 0, bgcolor: c.bg.elevated, borderRight: `1px solid ${c.border.subtle}`, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ px: 1.25, pt: 1, pb: 0.6 }}>
<InputBase
value={search}
onChange={(e) => setSearch(e.target.value)}
@@ -485,7 +484,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
/>
</Box>
<MiniMonth refDate={refDate} onPick={setRefDate} />
<Box sx={{ flex: 1, overflowY: 'auto', px: 1.5, pb: 1.5 }}>
<Box sx={{ flex: 1, overflowY: 'auto', px: 1.25, pb: 1.25 }}>
<SidebarSection title="Scheduled" items={scheduled.filter((w) => match(w.title, search))} onPick={onSelectWorkflow} scheduled onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} />
<SidebarSection title="Unscheduled" items={unscheduled.filter((w) => match(w.title, search))} onPick={onSelectWorkflow} scheduled={false} onContext={(wf, e) => setSidebarCtxMenu({ x: e.clientX, y: e.clientY, workflow: wf })} onSchedule={(wf, el) => setSchedulePopover({ anchorEl: el, workflow: wf })} />
</Box>
@@ -493,7 +492,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
)}
{/* Main calendar area */}
<Box sx={{ flex: 1, minWidth: 0, overflow: 'auto', p: 1.5 }}>
<Box sx={{ flex: 1, minWidth: 0, overflow: 'auto', px: 1.25, pt: 0, pb: 1.25, bgcolor: c.bg.surface }}>
<ScheduleCalendar view={view} density="roomy" onSelectWorkflow={onSelectWorkflow} refDate={refDate} />
</Box>
</Box>
@@ -571,7 +570,7 @@ function MiniMonth({ refDate, onPick }: { refDate: Date; onPick: (d: Date) => vo
const today = new Date();
const label = refDate.toLocaleString('en', { month: 'long', year: 'numeric' });
return (
<Box sx={{ px: 1.5, pb: 1, borderBottom: `1px solid ${c.border.subtle}` }}>
<Box sx={{ px: 1.25, pb: 0.75, borderBottom: `1px solid ${c.border.subtle}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', py: 0.5 }}>
<Typography sx={{ flex: 1, fontSize: '0.82rem', fontWeight: 700, color: c.text.primary }}>{label}</Typography>
<IconButton size="small" data-no-drag onClick={() => onPick(addMonths(refDate, -1))} sx={{ p: 0.15 }}><ChevronLeftIcon sx={{ fontSize: 14 }} /></IconButton>
@@ -386,15 +386,14 @@ function relTime(ms: number): string {
// ---------- Run history sparkline ----------
// 10-dot horizontal strip of last N runs colored by status. Easy "lately
// healthy?" check without opening the History tab. Tooltip names the
// pattern out loud so a non-dev knows the dots aren't decorative.
// healthy?" check without opening the History tab.
export function RunSparkline({ runs, max = 10 }: { runs: WorkflowRun[]; max?: number }) {
const c = useClaudeTokens();
if (!runs || runs.length === 0) return null;
const slice = runs.slice(0, max).reverse();
const successes = slice.filter((r) => r.status === 'success').length;
const failures = slice.filter((r) => r.status === 'failure').length;
const tooltip = `Last ${slice.length} run${slice.length === 1 ? '' : 's'}: ${successes} ok, ${failures} failed (oldest left → newest right). Green = success, red = failure, amber = ran late.`;
const tooltip = `Last ${slice.length} run${slice.length === 1 ? '' : 's'}: ${successes} successful, ${failures} failed`;
return (
<Tooltip title={tooltip}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.3, ml: 0.5 }}>
+1
View File
@@ -121,6 +121,7 @@ export function getWorkflowToolLabel(action: string): string | null {
if (lower === 'testworkflow') return 'Test workflow';
if (lower === 'readtesttranscript') return 'Read test results';
if (lower === 'listworkflows') return 'List workflows';
if (lower === 'suggestconverttoworkflow') return 'Suggest workflow';
return null;
}
@@ -100,6 +100,9 @@ export interface Workflow {
* conversion). Compared against the current steps to decide whether to warn
* before scheduling. See scheduleUtils.needsScheduleTestWarning. */
tested_signature?: string | null;
/** Suggested cadence from a SuggestConvertToWorkflow tool call (e.g. "every weekday at 9am").
* Used to seed the scheduling agent's prompt. Transient draft field only. */
suggested_cadence?: string;
}
export interface WorkflowRun {