diff --git a/backend/apps/workflows/models.py b/backend/apps/workflows/models.py index b5c3212e..c4748189 100644 --- a/backend/apps/workflows/models.py +++ b/backend/apps/workflows/models.py @@ -225,3 +225,9 @@ class WorkflowUpdate(BaseModel): cost_cap_usd_monthly: Optional[float] = None remembered_approvals: Optional[dict[str, Literal["allow", "deny"]]] = None step_tool_usage: Optional[dict[str, dict[str, bool]]] = None + + +class DraftCommitBody(BaseModel): + # The model the user settled on in the Edit Agent picker, applied to the + # workflow's run model only on Save (save-gated; Discard drops it). + model: Optional[str] = None diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 785b7f6b..3c2f35d5 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -13,6 +13,7 @@ from backend.apps.workflows.models import ( WorkflowUpdate, WorkflowRun, WorkflowStep, + DraftCommitBody, ) from backend.apps.workflows import storage, scheduler, executor, audit, escalation @@ -840,8 +841,31 @@ async def p_end_edit_session(wf) -> None: logger.debug("could not close edit session %s", sid, exc_info=True) +def p_sync_model_on_save(wf, model: Optional[str]) -> None: + """On Save, adopt whatever model the user settled on in the Edit Agent picker as + the workflow's run model, so a mid-build model switch sticks to scheduled runs. + Save-only: Discard must not persist a switch the user is throwing away. + + The frontend passes the model it tracks live; we fall back to the backend edit + session's model for callers that send none (e.g. the Test Agent save button), + which can be stale until a message is sent but is no worse than before.""" + if model: + wf.model = model + return + sid = getattr(wf, "edit_agent_session_id", None) + if not sid: + return + try: + from backend.apps.agents.agent_manager import agent_manager + session = agent_manager.sessions.get(sid) + if session and session.model: + wf.model = session.model + except Exception: + logger.debug("could not sync model from edit session %s", sid, exc_info=True) + + @workflows.router.post("/{workflow_id}/draft/commit") -async def commit_draft(workflow_id: str): +async def commit_draft(workflow_id: str, body: Optional[DraftCommitBody] = None): """Commit the Edit-Agent draft: draft_steps become the live steps.""" wf = storage.get_workflow(workflow_id) if not wf: @@ -852,6 +876,7 @@ async def commit_draft(workflow_id: str): # Clicking Save is the user committing to this workflow, so reveal it # in the hub (clears the "+ New" build-in-progress flag). wf.unsaved = False + p_sync_model_on_save(wf, body.model if body else None) await p_end_edit_session(wf) storage.save_workflow(wf) return _enriched(wf) @@ -869,6 +894,7 @@ async def commit_draft(workflow_id: str): if not wf.icon: wf.icon = _derive_icon(wf) _normalize_schedule_state(wf) + p_sync_model_on_save(wf, body.model if body else None) await p_end_edit_session(wf) storage.save_workflow(wf) audit.log_change(wf.id, "user", before, wf.model_dump(mode="json")) diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 4bb8ab44..db0dbf47 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -7,6 +7,7 @@ import Tooltip from '@mui/material/Tooltip'; import TextField from '@mui/material/TextField'; import ClickAwayListener from '@mui/material/ClickAwayListener'; import Fade from '@mui/material/Fade'; +import SwapHorizRoundedIcon from '@mui/icons-material/SwapHorizRounded'; import CloseIcon from '@mui/icons-material/Close'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; @@ -352,6 +353,10 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const mcpSnapshotRef = useRef>([]); const [mode, setMode] = useState('agent'); const [model, setModel] = useState('sonnet'); + // Workflow build chat only: brief "this model now runs the workflow" notice + // when the user switches models, so the run-model change isn't silent. + const [workflowModelNotice, setWorkflowModelNotice] = useState(null); + const workflowModelNoticeTimer = useRef | null>(null); const wsRef = useRef | null>(null); // Current status for the WS-cleanup closure (effect deps can't include it). @@ -914,9 +919,16 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose }, [id, isDraft, dispatch]); const handleModelChange = useCallback((newModel: string) => { + if (workflowEditId && newModel !== model) { + setWorkflowModelNotice(resolveModelLabel(newModel)); + if (workflowModelNoticeTimer.current) clearTimeout(workflowModelNoticeTimer.current); + workflowModelNoticeTimer.current = setTimeout(() => setWorkflowModelNotice(null), 5000); + } setModel(newModel); if (id && !isDraft) dispatch(updateSessionModel({ sessionId: id, model: newModel })); - }, [id, isDraft, dispatch]); + }, [id, isDraft, dispatch, workflowEditId, model, resolveModelLabel]); + + useEffect(() => () => { if (workflowModelNoticeTimer.current) clearTimeout(workflowModelNoticeTimer.current); }, []); const handleThinkingLevelChange = useCallback((level: 'off' | 'low' | 'medium' | 'high' | 'auto') => { if (!id) return; @@ -2236,7 +2248,9 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose {isStoppableSidecar ? ( ) : ( - + + = ({ sessionId: sessionIdProp, onClose thinkingLevel={session?.thinking_level ?? 'auto'} onThinkingLevelChange={handleThinkingLevelChange} onActivityLabelChange={setPreSendActivityLabel} - /> + /> + )} @@ -2262,4 +2277,30 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose ); }; +// Brief toast above the build chat's composer confirming a model switch also +// changes the model the scheduled workflow will run on. Holds the last label in +// a ref so the exit fade renders content instead of blanking mid-animation. +function WorkflowModelNotice({ c, label }: { c: ReturnType; label: string | null }) { + const last = React.useRef(null); + if (label) last.current = label; + const display = last.current; + if (!display) return null; + return ( + + + + + This workflow will run on {display} after you save. + + + + ); +} + export default AgentChat; diff --git a/frontend/src/app/pages/Workflows/EditAgentView.tsx b/frontend/src/app/pages/Workflows/EditAgentView.tsx index ecf722a3..b3bfce8e 100644 --- a/frontend/src/app/pages/Workflows/EditAgentView.tsx +++ b/frontend/src/app/pages/Workflows/EditAgentView.tsx @@ -142,12 +142,12 @@ export default function EditAgentView({ workflow, steps, isFixMode = false, onEd if (!canSave) return; setSavePhase('idle'); try { - await dispatch(commitDraft(workflow.id)).unwrap(); + await dispatch(commitDraft({ id: workflow.id, model: editSession?.model })).unwrap(); } catch { return; } toSaved(); - }, [canSave, dispatch, workflow.id, toSaved]); + }, [canSave, dispatch, workflow.id, editSession?.model, toSaved]); const onSaveClick = useCallback((e: React.MouseEvent) => { if (!canSave) return; @@ -244,7 +244,7 @@ export default function EditAgentView({ workflow, steps, isFixMode = false, onEd thread runs edge-to-edge like a normal chat (it supplies its own px). */} {editSessionId ? ( - + ) : ( Starting the Edit Agent... diff --git a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx index afafcdc2..853f4043 100644 --- a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx @@ -134,6 +134,7 @@ const WorkflowsHubCard: React.FC = ({ const dispatch = useAppDispatch(); const workflows = useAppSelector((s) => s.workflows.items); const paused = useAppSelector((s) => s.workflows.paused); + const defaultModel = useAppSelector((s) => s.settings.data.default_model); useEffect(() => { dispatch(fetchPausedState()); }, [dispatch]); @@ -198,12 +199,19 @@ const WorkflowsHubCard: React.FC = ({ // session has a real id to attach to; an abandoned (still 0-step) one is // cleaned up on card close (WorkflowCard.onClose). const onNew = useCallback(async () => { - const result = await dispatch(createWorkflow({ title: 'New workflow', steps: [], unsaved: true })); + // Clean up any abandoned empty drafts first so "New" always starts fresh + // instead of leaving a half-built 0-step workflow lingering on the canvas. + Object.values(workflows) + .filter((w) => w.unsaved && (w.steps?.length ?? 0) === 0) + .forEach((w) => dispatch(deleteWorkflow(w.id))); + // Build on the user's chosen default model (not a hardcoded one) so the + // Edit Agent, and the scheduled runs, use the model they actually intend. + const result = await dispatch(createWorkflow({ title: 'New workflow', steps: [], unsaved: true, model: defaultModel })); if (!createWorkflow.fulfilled.match(result)) return; const wf = result.payload; dispatch(addWorkflowCard({ workflowId: wf.id })); dispatch(openWorkflowCard({ workflowId: wf.id, view: 'edit_agent' })); - }, [dispatch]); + }, [dispatch, workflows, defaultModel]); // ---- Card drag via header ---- const DRAG_THRESHOLD = 3; diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index 52d820bd..2777b08b 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -307,8 +307,18 @@ export const updateWorkflow = createAsyncThunk< }, ); -export const commitDraft = createAsyncThunk('workflows/commitDraft', async (id: string) => { - const res = await fetch(`${API}/${id}/draft/commit`, { method: 'POST' }); +type CommitDraftArg = string | { id: string; model?: string }; + +export const commitDraft = createAsyncThunk('workflows/commitDraft', async (arg: CommitDraftArg) => { + const id = typeof arg === 'string' ? arg : arg.id; + // Save-gated: the model the user settled on in the Edit Agent picker is applied + // to the workflow's run model here (Discard never reaches this path). + const model = typeof arg === 'string' ? undefined : arg.model; + const res = await fetch(`${API}/${id}/draft/commit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(model ? { model } : {}), + }); if (!res.ok) throw new Error(`commit failed ${res.status}`); return (await res.json()) as Workflow; });