From 1f1fa220fe59f3885efd2f8b29ab7521378c8359 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 18 May 2026 17:18:44 -0700 Subject: [PATCH] [eric] small bug fixes --- backend/apps/agents/agent_manager.py | 27 +++- .../src/app/pages/AgentChat/AgentChat.tsx | 64 +++++---- .../src/app/pages/Dashboard/AgentCard.tsx | 2 + .../src/app/pages/Workflows/WorkflowCard.tsx | 124 +++++------------- .../app/pages/Workflows/WorkflowEditViews.tsx | 68 +++++----- .../pages/Workflows/workflowEditCommon.tsx | 6 +- frontend/src/shared/state/agentsSlice.ts | 8 ++ .../src/shared/state/dashboardLayoutSlice.ts | 7 +- 8 files changed, 150 insertions(+), 156 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 125ec2c8..b1981a6a 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -186,7 +186,6 @@ FULL_TOOLS = [ "WebSearch", "WebFetch", "NotebookEdit", "TodoWrite", "EnterPlanMode", "ExitPlanMode", "EnterWorktree", "TaskOutput", "TaskStop", - "CronCreate", "CronList", "CronDelete", "InvokeAgent", "Agent", # ToolSearch is the loader the CLI uses to expose deferred tool schemas @@ -667,6 +666,15 @@ class AgentManager: "Calendar/Drive, the equivalent OpenSwarm server is listed below; " "activate that one via MCPActivate instead." ) + sections.append( + "1b. NEVER call CronCreate, CronList, CronDelete, ScheduleWakeup, " + "PushNotification, RemoteTrigger, or any Task* tool. Those are " + "claude.ai Routines/Tasks; OpenSwarm has its own scheduler that " + "the user drives by clicking 'Schedule this task' on the chat " + "card. If the user asks to schedule something, do the work once, " + "then tell them to click that button. Do not propose a routine, " + "do not say 'I'll schedule it', do not call any scheduling tool." + ) sections.append( "2. After MCPActivate returns, end the turn; a follow-up turn fires " "automatically with the new tools available." @@ -2305,8 +2313,25 @@ class AgentManager: # here, and confuse the model into picking the partner shim # instead of our vetted server. Hard-block them at the SDK # layer so the model can't even attempt the call. + # claude.ai partner shims and claude.ai's Routines/Tasks + # product compete with OpenSwarm's own MCP gate and workflow + # scheduler. Block them at the SDK so the model can't reach + # for them even when it's tempted. OpenSwarm scheduling is + # user-initiated via the "Schedule this task" UI, not + # something the agent calls a tool to set up. options_kwargs["disallowed_tools"] = [ "mcp__claude_ai_*", + "Skill", + "CronCreate", + "CronList", + "CronDelete", + "PushNotification", + "RemoteTrigger", + "ScheduleWakeup", + "TaskCreate", + "TaskGet", + "TaskList", + "TaskUpdate", ] if session.cwd: diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index fb5c1a1d..f09e242a 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useMemo, useState, useCallback } from 'react'; -import { useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Chip from '@mui/material/Chip'; @@ -40,6 +40,7 @@ import { fetchSession, AgentMessage, clearSessionMessages, + clearMcpSuggestions, } from '@/shared/state/agentsSlice'; import { fetchModes } from '@/shared/state/modesSlice'; import { createSessionWs } from '@/shared/ws/WebSocketManager'; @@ -170,6 +171,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose }; const { id: routeId } = useParams<{ id: string }>(); const id = sessionIdProp || routeId; + const navigate = useNavigate(); const dispatch = useAppDispatch(); const session = useAppSelector((state) => (id ? state.agents.sessions[id] : undefined)); const modesMap = useAppSelector((state) => state.modes.items); @@ -1111,8 +1113,32 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose borderRadius: 1.5, border: `1px solid ${c.border.medium}`, bgcolor: c.bg.secondary, + position: 'relative', }}> - + id && dispatch(clearMcpSuggestions({ sessionId: id }))} + sx={{ + position: 'absolute', + top: 6, + right: 8, + width: 20, + height: 20, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '1rem', + lineHeight: 1, + color: c.text.muted, + cursor: 'pointer', + borderRadius: 0.75, + '&:hover': { color: c.text.primary, bgcolor: c.bg.elevated }, + }} + > + × + + Looks like this might need an integration @@ -1152,8 +1178,18 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose parent_session_id: session.id, }), }); + const body = await r.json().catch(() => ({} as any)); if (!r.ok) { setActivateError(`Activation failed (${r.status})`); + } else if (body?.status === 'unknown_server') { + // Not yet connected; jump straight to Actions + // so the user can finish OAuth. Nothing here + // can do it on their behalf. + navigate('/actions'); + } else if (id) { + // Activation succeeded; clear the banner so the user + // gets visual confirmation the click did something. + dispatch(clearMcpSuggestions({ sessionId: id })); } } catch (e: any) { setActivateError(e?.message || 'Activation failed'); @@ -1340,30 +1376,6 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose seedKey={`${session.id}:${session.messages?.length ?? 0}`} /> )} - {scheduleSuggestion && id && ( - - setScheduleAnchor(e.currentTarget as HTMLElement)} - role="button" - sx={{ - display: 'inline-flex', alignItems: 'center', gap: 0.6, - px: 1.1, py: 0.55, - fontSize: '0.78rem', fontWeight: 600, - color: c.accent.primary, - bgcolor: c.accent.primary + '14', - border: `1px solid ${c.accent.primary}40`, - borderRadius: 999, - cursor: 'pointer', - '&:hover': { bgcolor: c.accent.primary + '22' }, - }}> - - Schedule: {scheduleSuggestion.presetLabel} - { e.stopPropagation(); setSuggestDismissedFor(scheduleSuggestion.messageId); }} - sx={{ ml: 0.4, color: c.text.muted, fontSize: '0.8rem', '&:hover': { color: c.text.primary } }}>× - - - )} {showResumeBubble && session.status === 'stopped' && ( = ({ const isDashboardActive = useDashboardActive(); const hasApiKey = !!useAppSelector((s) => s.settings.data.anthropic_api_key); const modelsByProvider = useAppSelector((s) => s.models.byProvider); + const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds); // Curated picker label with a tidy fallback for unknowns. const friendlyModelLabel = useMemo(() => { const value = session.model; @@ -945,6 +946,7 @@ const AgentCard: React.FC = ({ dispatch(addWorkflowCard({ workflowId: tempId, sourceSessionId: session.id, + expandedSessionIds, })); dispatch(openWorkflowCard({ workflowId: tempId, diff --git a/frontend/src/app/pages/Workflows/WorkflowCard.tsx b/frontend/src/app/pages/Workflows/WorkflowCard.tsx index a734af9d..8d0866b2 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCard.tsx @@ -4,11 +4,6 @@ import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; import Tooltip from '@mui/material/Tooltip'; import Snackbar from '@mui/material/Snackbar'; -import Dialog from '@mui/material/Dialog'; -import DialogTitle from '@mui/material/DialogTitle'; -import DialogContent from '@mui/material/DialogContent'; -import DialogActions from '@mui/material/DialogActions'; -import Button from '@mui/material/Button'; import CloseIcon from '@mui/icons-material/Close'; import EditIcon from '@mui/icons-material/EditOutlined'; import HistoryIcon from '@mui/icons-material/HistoryRounded'; @@ -20,7 +15,6 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { closeWorkflowCard, - deleteWorkflow, fetchRuns, openWorkflowCard as openWorkflowCardAction, rekeyOpenCard, @@ -30,15 +24,21 @@ import { type Workflow, } from '@/shared/state/workflowsSlice'; import { + DEFAULT_CARD_H, + DEFAULT_CARD_W, + placeCard, rekeyWorkflowCard, removeWorkflowCard, setWorkflowCardPosition, setWorkflowCardSize, } from '@/shared/state/dashboardLayoutSlice'; +import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; +import { fetchSession } from '@/shared/state/agentsSlice'; import { AnimatePresence, motion } from 'framer-motion'; import WorkflowEditViews from './WorkflowEditViews'; import { HistoryDetail, HistoryList, PreviewView, SavedView } from './WorkflowCardSubviews'; import { StatusDot, RunSparkline, LastFiredHint, isStaleSinceLastRun } from './workflowVisuals'; +import { store } from '@/shared/state/store'; type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; @@ -99,6 +99,7 @@ const WorkflowCard: React.FC = ({ const card = useAppSelector((s) => s.workflows.openCards[workflowId]); const workflow = useAppSelector((s) => s.workflows.items[workflowId]); const runs = useAppSelector((s) => s.workflows.runs[workflowId]); + const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds); // Transient "Starting…" label state on the Run button. See onClick handler // for the full rationale (avoid no-feedback flicker on fast manual runs). const [runStarting, setRunStarting] = useState(false); @@ -329,34 +330,13 @@ const WorkflowCard: React.FC = ({ (e.target as HTMLElement).releasePointerCapture(e.pointerId); }, [computeResize, dispatch, workflowId]); - // ---- Close: drop transient view state AND remove from layout ---- - // Two-step when the schedule is on: a quiet X would make the workflow - // a "ghost" (still firing on a hidden timer) which surprises users who - // mentally model X as "throw away." Confirm-then-act lets them choose - // between hiding the card and actually killing the schedule. - const [closeConfirmOpen, setCloseConfirmOpen] = useState(false); - const hardClose = useCallback(() => { + // X just hides the card. Schedule keeps firing in the background; the + // user can re-open from the Workflows hub. A confirm dialog here was + // more friction than value (users clicked through it without reading). + const onClose = useCallback(() => { dispatch(closeWorkflowCard(workflowId)); dispatch(removeWorkflowCard(workflowId)); }, [dispatch, workflowId]); - const onClose = useCallback(() => { - if (workflow?.schedule?.enabled) { - setCloseConfirmOpen(true); - return; - } - hardClose(); - }, [workflow?.schedule?.enabled, hardClose]); - const onConfirmHide = useCallback(() => { - setCloseConfirmOpen(false); - hardClose(); - }, [hardClose]); - const onConfirmStopAndDelete = useCallback(async () => { - setCloseConfirmOpen(false); - if (workflow?.id) { - await dispatch(deleteWorkflow(workflow.id)); - } - hardClose(); - }, [dispatch, workflow?.id, hardClose]); // ---- Display calculations ---- const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0; @@ -369,9 +349,6 @@ const WorkflowCard: React.FC = ({ if (!card) return null; - // A "running" run is one that's actively executing right now. While - // running, the card grows a subtle conic-gradient halo + a faint title - // pulse so a glance at the canvas tells you something's working. const isRunning = (runs || []).some((r) => r.status === 'running') || workflow?.last_run_status === 'running'; // Hairline border for the default idle state (item #19 in target #54 @@ -399,7 +376,6 @@ const WorkflowCard: React.FC = ({ data-select-type="workflow-card" data-select-id={workflowId} data-select-meta={JSON.stringify({ name: title })} - data-running={isRunning ? 'true' : undefined} onPointerDownCapture={() => onBringToFront?.(workflowId, 'workflow')} onClick={(e: React.MouseEvent) => { if (justDraggedRef.current) return; @@ -426,43 +402,6 @@ const WorkflowCard: React.FC = ({ zIndex: (isDragging || isResizing) ? 999999 : cardZOrder, transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease', '&:hover .resize-handle': { opacity: 1 }, - // Running halo: conic-gradient sweep around the card border + a - // faint inner glow. Lives on ::before so the card body stays - // crisp and isn't redrawn each frame. Only renders when the - // data-running attribute is set (no perf cost when idle). - '&[data-running="true"]::before': { - content: '""', - position: 'absolute', - inset: -1, - borderRadius: '15px', - padding: '1.5px', - background: `conic-gradient(from 0deg, transparent 0deg, ${c.accent.primary} 60deg, transparent 120deg, transparent 240deg, ${c.accent.primary} 300deg, transparent 360deg)`, - WebkitMask: 'linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)', - WebkitMaskComposite: 'xor', - maskComposite: 'exclude', - animation: 'workflowRunSweep 2.4s linear infinite', - pointerEvents: 'none', - zIndex: 0, - opacity: 0.85, - }, - '&[data-running="true"]::after': { - content: '""', - position: 'absolute', - inset: 0, - borderRadius: '14px', - background: `radial-gradient(120% 80% at 50% 0%, ${c.accent.primary}10 0%, transparent 60%)`, - animation: 'workflowRunPulse 2.4s ease-in-out infinite', - pointerEvents: 'none', - zIndex: 0, - }, - '@keyframes workflowRunSweep': { - '0%': { transform: 'rotate(0deg)' }, - '100%': { transform: 'rotate(360deg)' }, - }, - '@keyframes workflowRunPulse': { - '0%, 100%': { opacity: 0.5 }, - '50%': { opacity: 1 }, - }, }} > {/* ===== Title bar / drag handle ===== @@ -664,7 +603,31 @@ const WorkflowCard: React.FC = ({ {card.view === 'history' && workflow && ( dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history_detail', historyRunId: run.id } }))} + onOpen={async (run) => { + if (!run.session_id) { + dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history_detail', historyRunId: run.id } })); + return; + } + const sid = run.session_id; + if (!store.getState().agents.sessions[sid]) { + try { await dispatch(fetchSession(sid)).unwrap(); } catch { /* fall back to detail */ } + } + if (!store.getState().agents.sessions[sid]) { + dispatch(updateWorkflowCard({ workflowId, patch: { view: 'history_detail', historyRunId: run.id } })); + return; + } + if (!store.getState().dashboardLayout.cards[sid]) { + dispatch(placeCard({ + sessionId: sid, + x: cardX + cardWidth + 60, + y: cardY, + width: DEFAULT_CARD_W, + height: DEFAULT_CARD_H, + expandedSessionIds, + })); + } + dispatch(setPendingFocusAgentId(sid)); + }} /> )} {card.view === 'history_detail' && workflow && ( @@ -725,21 +688,6 @@ const WorkflowCard: React.FC = ({ message={runToast || ''} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} /> - {/* Ghost-protection dialog: only opens when an enabled-schedule - card is X'd out. Cancel keeps the card; "Hide card" closes - but leaves the schedule alive; "Stop & delete" wipes the - workflow entirely. */} - setCloseConfirmOpen(false)}> - Close this workflow card? - - The schedule will keep firing in the background even after you close this card. Choose what you want to happen. - - - - - - - ); }; diff --git a/frontend/src/app/pages/Workflows/WorkflowEditViews.tsx b/frontend/src/app/pages/Workflows/WorkflowEditViews.tsx index 410d6d5f..3e49cf65 100644 --- a/frontend/src/app/pages/Workflows/WorkflowEditViews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowEditViews.tsx @@ -26,8 +26,6 @@ export default function WorkflowEditViews({ workflow, facet, onChangeFacet, onDi const dispatch = useAppDispatch(); const [draft, setDraft] = useState(workflow); const [busy, setBusy] = useState(false); - // Save-feedback state. `savedFlash` flashes a checkmark for 1.4s then - // auto-clears; `saveError` carries a string the user can read. const [savedFlash, setSavedFlash] = useState(false); const [saveError, setSaveError] = useState(null); @@ -39,20 +37,9 @@ export default function WorkflowEditViews({ workflow, facet, onChangeFacet, onDi // a stale "you have unsaved changes" dot on the tab. useEffect(() => () => { onDirtyChange?.(false); }, [onDirtyChange]); - // Auto-save on a quiet idle. We debounce 800ms after the last edit so - // rapid typing doesn't fire dozens of PATCHes. Validation still gates - // the network call so bad drafts (empty phone, etc.) don't auto-save - // a broken state. Explicit Save still works for users who want it. - useEffect(() => { - if (!dirty || busy) return; - if (validateDraft(draft)) return; // skip auto-save while invalid - const handle = window.setTimeout(() => { onSaveRef.current?.(); }, 800); - return () => window.clearTimeout(handle); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [draft, dirty, busy]); - // onSave refs itself so the effect above doesn't depend on it. - const onSaveRef = React.useRef<(() => Promise) | null>(null); - + // Save is explicit only. The previous auto-save raced the Save button: + // the user toggled a field, autosave fired 800ms later, dirty went + // false, and a manual Save click became a no-op. const onSave = useCallback(async () => { if (busy || !dirty) return; const reason = validateDraft(draft); @@ -72,6 +59,11 @@ export default function WorkflowEditViews({ workflow, facet, onChangeFacet, onDi ifMatch: workflow.updated_at || null, })); if (updateWorkflow.fulfilled.match(result)) { + // Rebase the draft on the server's echoed copy. Without this, + // `dirty` would stay true after Save (because updated_at differs) + // and the user would see a phantom "unsaved" state. + const saved = result.payload as Workflow; + if (saved) setDraft(saved); setSavedFlash(true); setTimeout(() => setSavedFlash(false), 1400); } else if (result.payload?.kind === 'stale') { @@ -86,10 +78,6 @@ export default function WorkflowEditViews({ workflow, facet, onChangeFacet, onDi } }, [busy, dirty, dispatch, workflow.id, workflow.updated_at, draft]); - // Keep the ref pointing at the latest onSave so the auto-save effect - // can call it without re-subscribing on every keystroke. - useEffect(() => { onSaveRef.current = onSave; }, [onSave]); - const onDiscard = useCallback(() => { setDraft(workflow); setSaveError(null); @@ -109,32 +97,36 @@ export default function WorkflowEditViews({ workflow, facet, onChangeFacet, onDi Discard + Save are the same pill-style buttons used at the bottom of SavedView; placing them here gives the user a single place to commit OR throw away whatever they just edited. */} - - Currently Editing + + Currently Editing - - - + + + + + + + {saveError && ( diff --git a/frontend/src/app/pages/Workflows/workflowEditCommon.tsx b/frontend/src/app/pages/Workflows/workflowEditCommon.tsx index 76f26cb0..285e8c04 100644 --- a/frontend/src/app/pages/Workflows/workflowEditCommon.tsx +++ b/frontend/src/app/pages/Workflows/workflowEditCommon.tsx @@ -1,6 +1,8 @@ import React from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import CheckIcon from '@mui/icons-material/Check'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; export const BODY_FS = '0.88rem'; @@ -42,8 +44,8 @@ export function ActionBtn({ label, tone, disabled, onClick, icon }: { label: str opacity: disabled ? 0.5 : 1, '&:hover': { bgcolor: palette.hover }, }}> - {icon === 'trash' && {'\u{1F5D1}'}} - {icon === 'check' && {'✓'}} + {icon === 'trash' && } + {icon === 'check' && } {label} ); diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 6513f7e3..4b95bb76 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -1000,6 +1000,8 @@ const agentsSlice = createSlice({ ? existing.pending_approvals : s.pending_approvals ?? [], tool_group_meta: { ...existing?.tool_group_meta, ...s.tool_group_meta }, + mcp_suggestions: existing?.mcp_suggestions ?? [], + mcp_suggestions_is_vague: existing?.mcp_suggestions_is_vague ?? false, }; if (activeStatuses.has(s.status) && !state.trackedNotificationIds.includes(s.id)) { state.trackedNotificationIds.push(s.id); @@ -1181,6 +1183,12 @@ const agentsSlice = createSlice({ ...session, pending_approvals: session.pending_approvals ?? existing?.pending_approvals ?? [], tool_group_meta: session.tool_group_meta ?? existing?.tool_group_meta ?? {}, + // mcp_suggestions live in client state only (the backend never + // returns them in the session payload). Preserve them across + // refresh so the suggestion banner stays put until the user + // dismisses it or activates one. + mcp_suggestions: existing?.mcp_suggestions ?? [], + mcp_suggestions_is_vague: existing?.mcp_suggestions_is_vague ?? false, }; }) .addCase(fetchSession.rejected, (state, action) => { diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index e609a2fe..0c224831 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -630,7 +630,12 @@ const dashboardLayoutSlice = createSlice({ state.pendingFocusWorkflowId = workflowId; return; } - const rects = collectOccupiedRects(state, expandedSessionIds); + // Fall back to persistedExpandedSessionIds when the caller didn't + // wire the live list through. Without it, collectOccupiedRects sees + // every chat at its stored (collapsed) height, and a workflow + // spawned from an open chat lands on top of the visibly-tall card. + const expanded = expandedSessionIds ?? state.persistedExpandedSessionIds; + const rects = collectOccupiedRects(state, expanded); let posX: number, posY: number; const parentCard = sourceSessionId ? state.cards[sourceSessionId] : null; if (parentCard) {