[eric] small bug fixes

This commit is contained in:
ciregenz
2026-05-18 17:18:44 -07:00
parent 13218b9c24
commit 1f1fa220fe
8 changed files with 150 additions and 156 deletions
+26 -1
View File
@@ -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:
+38 -26
View File
@@ -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<AgentChatProps> = ({ 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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
borderRadius: 1.5,
border: `1px solid ${c.border.medium}`,
bgcolor: c.bg.secondary,
position: 'relative',
}}>
<Typography variant="body2" sx={{ color: c.text.primary, fontWeight: 500, mb: 0.5 }}>
<Box
role="button"
aria-label="Dismiss integration suggestion"
onClick={() => 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 },
}}
>
×
</Box>
<Typography variant="body2" sx={{ color: c.text.primary, fontWeight: 500, mb: 0.5, pr: 3 }}>
Looks like this might need an integration
</Typography>
<Typography variant="caption" sx={{ color: c.text.secondary, display: 'block', mb: 1 }}>
@@ -1152,8 +1178,18 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
seedKey={`${session.id}:${session.messages?.length ?? 0}`}
/>
)}
{scheduleSuggestion && id && (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75, ml: 1 }}>
<Box
onClick={(e) => 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' },
}}>
<ScheduleIcon sx={{ fontSize: 14 }} />
Schedule: {scheduleSuggestion.presetLabel}
<Box
onClick={(e) => { e.stopPropagation(); setSuggestDismissedFor(scheduleSuggestion.messageId); }}
sx={{ ml: 0.4, color: c.text.muted, fontSize: '0.8rem', '&:hover': { color: c.text.primary } }}>×</Box>
</Box>
</Box>
)}
{showResumeBubble && session.status === 'stopped' && (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
<Box
@@ -295,6 +295,7 @@ const AgentCard: React.FC<Props> = ({
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<Props> = ({
dispatch(addWorkflowCard({
workflowId: tempId,
sourceSessionId: session.id,
expandedSessionIds,
}));
dispatch(openWorkflowCard({
workflowId: tempId,
@@ -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<Props> = ({
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<Props> = ({
(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<Props> = ({
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<Props> = ({
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<Props> = ({
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<Props> = ({
{card.view === 'history' && workflow && (
<HistoryList
runs={runs || []}
onOpen={(run) => 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<Props> = ({
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. */}
<Dialog open={closeConfirmOpen} onClose={() => setCloseConfirmOpen(false)}>
<DialogTitle>Close this workflow card?</DialogTitle>
<DialogContent>
The schedule will keep firing in the background even after you close this card. Choose what you want to happen.
</DialogContent>
<DialogActions>
<Button onClick={() => setCloseConfirmOpen(false)}>Cancel</Button>
<Button onClick={onConfirmHide}>Hide card (schedule keeps running)</Button>
<Button color="error" onClick={onConfirmStopAndDelete}>Stop &amp; delete</Button>
</DialogActions>
</Dialog>
</Box>
);
};
@@ -26,8 +26,6 @@ export default function WorkflowEditViews({ workflow, facet, onChangeFacet, onDi
const dispatch = useAppDispatch();
const [draft, setDraft] = useState<Workflow>(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<string | null>(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<void>) | 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. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, fontWeight: 500 }}>Currently Editing</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'nowrap', minWidth: 0 }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.secondary, fontWeight: 500, flexShrink: 0 }}>Currently Editing</Typography>
<Select
size="small"
value={facet}
onChange={(e) => onChangeFacet(e.target.value as Props['facet'])}
sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.4 } }}>
sx={{ fontSize: LABEL_FS, minWidth: 0, '& .MuiSelect-select': { py: 0.4 } }}>
<MenuItem value="General">General</MenuItem>
<MenuItem value="Actions">Actions</MenuItem>
<MenuItem value="Schedule">Schedule</MenuItem>
</Select>
<Box sx={{ flex: 1 }} />
<ActionBtn
label="Discard"
tone="danger"
icon="trash"
disabled={!dirty || busy}
onClick={onDiscard}
/>
<ActionBtn
label={busy ? 'Saving…' : 'Save'}
tone="success"
icon="check"
disabled={!dirty || busy || saveState === 'saved'}
onClick={onSave}
/>
<Box sx={{ flex: 1, minWidth: 0 }} />
<Box sx={{ display: 'inline-flex', flexShrink: 0 }}>
<ActionBtn
label="Discard"
tone="danger"
icon="trash"
disabled={!dirty || busy}
onClick={onDiscard}
/>
</Box>
<Box sx={{ display: 'inline-flex', flexShrink: 0, minWidth: 80, justifyContent: 'center' }}>
<ActionBtn
label={busy ? 'Saving…' : 'Save'}
tone="success"
icon="check"
disabled={!dirty || busy || saveState === 'saved'}
onClick={onSave}
/>
</Box>
</Box>
{saveError && (
@@ -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' && <Box component="span" sx={{ fontSize: 13, lineHeight: 1 }}>{'\u{1F5D1}'}</Box>}
{icon === 'check' && <Box component="span" sx={{ fontSize: 13, lineHeight: 1 }}>{'✓'}</Box>}
{icon === 'trash' && <DeleteOutlineIcon sx={{ fontSize: 15 }} />}
{icon === 'check' && <CheckIcon sx={{ fontSize: 15 }} />}
{label}
</Box>
);
+8
View File
@@ -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) => {
@@ -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) {