[aidan] feat/workflow-model: persist edit-agent model on save with switch notice and fresh drafts

This commit is contained in:
abccodes
2026-06-18 23:30:43 -07:00
parent 02f0736c63
commit b0725083a3
6 changed files with 102 additions and 11 deletions
+6
View File
@@ -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
+27 -1
View File
@@ -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"))
+44 -3
View File
@@ -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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const mcpSnapshotRef = useRef<Array<{ id: string; title: string; description: string; reason?: string }>>([]);
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<string | null>(null);
const workflowModelNoticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const wsRef = useRef<ReturnType<typeof createSessionWs> | null>(null);
// Current status for the WS-cleanup closure (effect deps can't include it).
@@ -914,9 +919,16 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
{isStoppableSidecar ? (
<ForceStopAgentBar onStop={handleStop} onSaveWorkflow={onTestSaveWorkflow} onContinueEditing={onTestContinueEditing} testState={testState} />
) : (
<ChatInput
<Box sx={{ position: 'relative' }}>
<WorkflowModelNotice c={c} label={workflowModelNotice} />
<ChatInput
ref={chatInputRef}
onSend={handleSend}
disabled={false}
@@ -2253,7 +2267,8 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
thinkingLevel={session?.thinking_level ?? 'auto'}
onThinkingLevelChange={handleThinkingLevelChange}
onActivityLabelChange={setPreSendActivityLabel}
/>
/>
</Box>
)}
</Box>
</ClickAwayListener>
@@ -2262,4 +2277,30 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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<typeof useClaudeTokens>; label: string | null }) {
const last = React.useRef<string | null>(null);
if (label) last.current = label;
const display = last.current;
if (!display) return null;
return (
<Fade in={!!label} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box sx={{
position: 'absolute', left: 8, right: 8, bottom: 'calc(100% + 8px)',
display: 'flex', alignItems: 'center', gap: 1,
bgcolor: c.bg.surface, border: `1px solid ${c.border.medium}`,
boxShadow: c.shadow.md, borderRadius: '12px',
px: 1.75, py: 1, zIndex: 6,
}}>
<SwapHorizRoundedIcon sx={{ fontSize: 17, color: c.accent.primary, flexShrink: 0 }} />
<Box sx={{ fontSize: '0.83rem', color: c.text.primary, lineHeight: 1.4 }}>
This workflow will run on <b>{display}</b> after you save.
</Box>
</Box>
</Fade>
);
}
export default AgentChat;
@@ -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<HTMLElement>) => {
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). */}
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', mx: -2, mb: -2 }}>
{editSessionId ? (
<AgentChat sessionId={editSessionId} embedded autoFocus />
<AgentChat sessionId={editSessionId} embedded autoFocus workflowEditId={workflow.id} />
) : (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: c.text.muted, fontSize: '0.85rem' }}>
Starting the Edit Agent...
@@ -134,6 +134,7 @@ const WorkflowsHubCard: React.FC<Props> = ({
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<Props> = ({
// 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;
+12 -2
View File
@@ -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;
});