mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user