diff --git a/backend/apps/workflows/models.py b/backend/apps/workflows/models.py index 5118b517..80e4fa4c 100644 --- a/backend/apps/workflows/models.py +++ b/backend/apps/workflows/models.py @@ -140,6 +140,14 @@ class Workflow(BaseModel): # scramble it). Auto-maintained on runs; enforcement stays workflow-level # via remembered_approvals, this is the finer per-step picture. step_tool_usage: dict[str, dict[str, bool]] = Field(default_factory=dict) + # False once the user explicitly sets a title; True means the backend may + # overwrite the title via auto-naming when steps are added/changed. + auto_named: bool = False + # True for a brand-new "+ New" workflow that the user is still building in + # the Edit Agent and hasn't saved yet. The Workflows hub hides these from + # the scheduled/unscheduled lists until the first commit clears the flag, + # so an in-progress build doesn't litter the sidebar. + unsaved: bool = False class WorkflowRun(BaseModel): @@ -165,6 +173,10 @@ class WorkflowRun(BaseModel): class WorkflowCreate(BaseModel): title: str = "Untitled workflow" + auto_named: bool = True + # Only the "+ New" build flow sets this; every other create path is a + # deliberate save and stays visible immediately. + unsaved: bool = False description: str = "" icon: str = "" system_prompt: Optional[str] = None @@ -183,6 +195,7 @@ class WorkflowCreate(BaseModel): class WorkflowUpdate(BaseModel): title: Optional[str] = None + auto_named: Optional[bool] = None description: Optional[str] = None icon: Optional[str] = None system_prompt: Optional[str] = None diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 06219754..43c29670 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -175,6 +175,8 @@ async def create_workflow(body: WorkflowCreate): mode=body.mode or "agent", provider=body.provider or "anthropic", cost_cap_usd_monthly=body.cost_cap_usd_monthly, + auto_named=body.auto_named, + unsaved=body.unsaved, ) wf.remembered_approvals = p_source_session_approvals(body.source_session_id) if not wf.icon: @@ -187,10 +189,14 @@ async def create_workflow(body: WorkflowCreate): # them the UI falls back to truncated raw prompts. try: title, description, labels = await _generate_workflow_metadata(wf) - if title: - wf.title = title - if description: - wf.description = description + # Respect a user-supplied title (auto_named=False); only auto-fill the + # name + description while the workflow is still auto-named. Labels are + # always safe to fill since they don't override a user's title. + if wf.auto_named: + if title: + wf.title = title + if description: + wf.description = description if labels and len(labels) == len(wf.steps): for i, lab in enumerate(labels): if lab: @@ -211,14 +217,19 @@ async def _generate_workflow_metadata(wf: Workflow) -> tuple[str, str, list[str] if not wf.steps: return "", "", [] try: - from backend.apps.agents.providers.registry import resolve_aux_model + from backend.apps.agents.providers.registry import resolve_aux_model, get_api_type from backend.apps.settings.credentials import get_anthropic_client_for_model from backend.apps.settings.settings import load_settings as _ls except Exception: return "", "", [] settings = _ls() try: - aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku") + # Stay on the family the user is actually paying for (same as + # generate_title); without primary_api the aux call can resolve to a + # lane that returns nothing on subscription setups. + aux_model, _ = await resolve_aux_model( + settings, preferred_tier="haiku", primary_api=get_api_type(wf.model), + ) client = get_anthropic_client_for_model(settings, aux_model) except Exception: return "", "", [] @@ -274,20 +285,20 @@ async def _generate_workflow_metadata(wf: Workflow) -> tuple[str, str, list[str] return None try: - resp = await client.messages.create( + # Stream, don't use messages.create: 9router's non-streaming response + # translator drops `content` for some provider lanes (same reason + # generate_title streams), which left the title empty. Streaming also + # means no assistant-prefill hack; _extract_json_object finds the + # object even if the model wraps it in prose or a code fence. + chunks: list[str] = [] + async with client.messages.stream( model=aux_model, max_tokens=400 + n_steps * 30, - messages=[ - {"role": "user", "content": prompt}, - {"role": "assistant", "content": "{"}, - ], - ) - text = "" - if isinstance(resp.content, list): - for block in resp.content: - if getattr(block, "type", None) == "text": - text += getattr(block, "text", "") - raw = "{" + text.strip() if not text.strip().startswith("{") else text.strip() + messages=[{"role": "user", "content": prompt}], + ) as stream: + async for chunk in stream.text_stream: + chunks.append(chunk) + raw = "".join(chunks) data = _extract_json_object(raw) if not data: logger.warning("workflow meta gen: failed to parse aux model output: %s", raw[:400]) @@ -302,26 +313,64 @@ async def _generate_workflow_metadata(wf: Workflow) -> tuple[str, str, list[str] return "", "", [] +_PLACEHOLDER_TITLES = {"", "New workflow", "Untitled workflow", "Scheduled workflow"} + + +def _fallback_title(wf: Workflow) -> str: + """Deterministic title derived from the steps, used when the aux model is + unreachable. A step-based name beats leaving the workflow as "New workflow". + Takes the first meaningful step's label (or its text), keeps it to ~5 words, + and Title-Cases it while preserving already-capitalized tokens (Gmail).""" + for s in wf.steps: + base = ((s.label or "") or (s.text or "")).strip() + if base: + words = base.split()[:5] + return " ".join(w.capitalize() if w.islower() else w for w in words)[:60] + return "" + + async def p_relabel_changed_steps(wf: Workflow, before_steps: list[dict]) -> None: before_by_id = {s.get("id"): s for s in before_steps} regen_idxs: list[int] = [] + # Step content changed at all? Drives auto-naming, which must fire even when + # every step already carries a label (regen_idxs empty) because the title + # describes the whole routine, not a single step. + content_changed = len(before_steps) != len(wf.steps) for i, step in enumerate(wf.steps): old = before_by_id.get(step.id) old_text = (old or {}).get("text") or "" old_label = (old or {}).get("label") or "" new_label = (step.label or "").strip() + if old is None or old_text != step.text: + content_changed = True if old is not None and old_text == step.text: if not new_label and old_label: step.label = old_label continue if not (new_label and new_label != old_label): regen_idxs.append(i) - if not regen_idxs: + # Auto-name while the workflow is still auto-named (user hasn't renamed it) + # and the step content actually changed and there's text to name from. + need_autoname = wf.auto_named and content_changed and any(s.text for s in wf.steps) + if not regen_idxs and not need_autoname: return try: - labels = (await _generate_workflow_metadata(wf))[2] + title, description, labels = await _generate_workflow_metadata(wf) except Exception: return + # One aux call covers labels AND auto-naming. A manual rename sets + # auto_named=False, so the title/description below are left untouched then. + if need_autoname: + if title: + wf.title = title + elif (wf.title or "").strip() in _PLACEHOLDER_TITLES: + # Aux model returned nothing (flaky lane / rate limit). Fall back to + # a step-derived name so the workflow doesn't stay "New workflow". + fb = _fallback_title(wf) + if fb: + wf.title = fb + if description: + wf.description = description if labels and len(labels) == len(wf.steps): for i in regen_idxs: if labels[i]: @@ -495,6 +544,12 @@ async def update_workflow( ) before = wf.model_dump(mode="json") data = body.model_dump(exclude_unset=True) + # A user-initiated title rename locks the name so later step edits don't + # auto-rename over it. Only an actual change counts, so the full-object + # editor save (which echoes the current title unchanged) doesn't lock. If + # the FE passes auto_named explicitly, that wins (handled by setattr below). + if "title" in data and "auto_named" not in data and data.get("title") != before.get("title"): + wf.auto_named = False # While an Edit-Agent draft is in flight, ANY PATCH that touches steps # stages those steps into the draft instead of the live workflow, so the # commit/discard pair is the only thing that moves the live steps. The @@ -683,6 +738,10 @@ async def commit_draft(workflow_id: str): wf = storage.get_workflow(workflow_id) if not wf: raise HTTPException(status_code=404, detail="Workflow not found") + # Clicking Save is the user committing to this workflow, so reveal it in + # the hub (clears the "+ New" build-in-progress flag) even if there's no + # pending draft to flush. + wf.unsaved = False if wf.draft_steps is None: await p_end_edit_session(wf) storage.save_workflow(wf) diff --git a/frontend/src/app/pages/Workflows/WorkflowCard.tsx b/frontend/src/app/pages/Workflows/WorkflowCard.tsx index 3ec65f6b..c89fd0d7 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCard.tsx @@ -9,7 +9,6 @@ import CloseIcon from '@mui/icons-material/Close'; import HistoryIcon from '@mui/icons-material/HistoryRounded'; import PlayArrowIcon from '@mui/icons-material/PlayArrowRounded'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; -import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined'; import InputBase from '@mui/material/InputBase'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; @@ -41,9 +40,10 @@ import { CompletedView, FailedView, RunningView } from './WorkflowCardLiveViews' import SchedulingView from './SchedulingView'; import EditAgentView from './EditAgentView'; import InlineEditableTitle from '@/app/components/InlineEditableTitle'; +import { Typewriter } from '@/app/components/feedback/Animated'; import StopRounded from '@mui/icons-material/StopRounded'; import PauseRounded from '@mui/icons-material/PauseRounded'; -import { StatusDot, RunSparkline, LastFiredHint, isStaleSinceLastRun } from './workflowVisuals'; +import { StatusDot, RunSparkline, LastFiredHint, isStaleSinceLastRun, isRealTitle } from './workflowVisuals'; import { store } from '@/shared/state/store'; import { getAgentWorkTime, fmtSeconds } from '@/shared/agentWorkTime'; @@ -197,25 +197,13 @@ const WorkflowCard: React.FC = ({ const steps = (workflow?.steps || card?.draft?.steps || []) as Workflow['steps']; const [draftCloseNonce, setDraftCloseNonce] = useState(0); - // Edit-agent chrome lives in the card header: the model/time subtitle and - // the Save Workflow button. EditAgentView owns the live session and reports - // its id up here. The Save button pulses once when a turn finishes adding a - // step, nudging the user that there's something worth saving. + // Edit-agent chrome lives in the card header: the model/time subtitle. + // EditAgentView owns the live session and reports its id up here so the + // subtitle can render the live model + work time. Saving is handled by the + // Discard/Save controls inside EditAgentView itself. const isEditAgentView = card?.view === 'edit_agent' || card?.view === 'fix_agent'; - const showHeaderSaveWorkflow = isEditAgentView && !(card?.view === 'edit_agent' && workflow?.source_session_id); const [editSessionId, setEditSessionId] = useState(null); const editSession = useAppSelector((s) => editSessionId ? s.agents.sessions[editSessionId] : undefined); - const [savePulseNonce, setSavePulseNonce] = useState(0); - const prevEditStatusRef = useRef(undefined); - useEffect(() => { - const status = editSession?.status; - const prev = prevEditStatusRef.current; - const wasRunning = prev === 'running' || prev === 'waiting_approval'; - if (wasRunning && status === 'completed' && steps.length > 0) { - setSavePulseNonce((n) => n + 1); - } - prevEditStatusRef.current = status; - }, [editSession?.status, steps.length]); // ---- Card drag via title bar ---- const DRAG_THRESHOLD = 3; @@ -527,7 +515,19 @@ const WorkflowCard: React.FC = ({ value={title} onCommit={(name) => dispatch(updateWorkflow({ id: workflow.id, patch: { title: name }, ifMatch: workflow.updated_at || null }))} sx={{ flex: '0 1 auto', fontWeight: 600, fontSize: '0.95rem', color: c.text.primary, letterSpacing: '-0.005em' }} - /> + > + {/* Once the title flips from its placeholder to the generated + name (after commit), retype it letter-by-letter, same as the + chat card title. The new title arrives in the commit response + so the animation runs after generation, not before. */} + + {(t) => ( + + {t} + + )} + + ) : ( {title} @@ -538,47 +538,6 @@ const WorkflowCard: React.FC = ({ )} {runs && runs.length > 0 && } - {!isDraft && workflow && showHeaderSaveWorkflow && ( - 0 ? 'Save the workflow and close the editor' : 'Add at least one step before saving'}> - { - e.stopPropagation(); - if (steps.length === 0) { setRunToast('Add at least one step to your workflow first.'); return; } - dispatch(updateWorkflowCard({ workflowId, patch: { view: 'saved' } })); - }} - onPointerDown={(e) => e.stopPropagation()} - sx={{ - display: 'inline-flex', alignItems: 'center', gap: 0.5, - fontSize: '0.78rem', fontWeight: 700, - px: 1.1, py: 0.5, - borderRadius: `${c.radius.md}px`, - cursor: 'pointer', - ...(steps.length > 0 ? { - color: '#fff', - bgcolor: c.accent.primary, - border: `1px solid ${c.accent.primary}`, - boxShadow: `0 0 0 0 ${c.accent.primary}00`, - animation: savePulseNonce > 0 ? `workflow-save-pulse-${savePulseNonce} 0.95s ease-out 1` : 'none', - [`@keyframes workflow-save-pulse-${savePulseNonce}`]: { - '0%': { boxShadow: `0 0 0 0 ${c.accent.primary}55`, transform: 'scale(1)' }, - '55%': { boxShadow: `0 0 0 8px ${c.accent.primary}00`, transform: 'scale(1.035)' }, - '100%': { boxShadow: `0 0 0 0 ${c.accent.primary}00`, transform: 'scale(1)' }, - }, - '&:hover': { filter: 'brightness(1.05)' }, - } : { - color: c.text.muted, - bgcolor: c.bg.elevated, - border: `1px solid ${c.border.subtle}`, - }), - }} - > - - Save Workflow - - - )} = ({ // unticked the box, which feels wrong. on_days/hour/minute being set // is a good proxy for "user already configured this." Falls back to // enabled flag for legacy records. - const scheduled = useMemo(() => Object.values(workflows).filter((w) => isWorkflowSchedulable(w)), [workflows]); - const unscheduled = useMemo(() => Object.values(workflows).filter((w) => !isWorkflowSchedulable(w)), [workflows]); + // Hide brand-new "+ New" workflows that the user is still building and + // hasn't saved yet; commit (Save) clears `unsaved` and they appear. + const saved = useMemo(() => Object.values(workflows).filter((w) => !w.unsaved), [workflows]); + const scheduled = useMemo(() => saved.filter((w) => isWorkflowSchedulable(w)), [saved]); + const unscheduled = useMemo(() => saved.filter((w) => !isWorkflowSchedulable(w)), [saved]); const monthLabel = refDate.toLocaleString('en', { month: 'long', year: 'numeric' }); @@ -176,7 +181,7 @@ 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: [] })); + const result = await dispatch(createWorkflow({ title: 'New workflow', steps: [], unsaved: true })); if (!createWorkflow.fulfilled.match(result)) return; const wf = result.payload; dispatch(addWorkflowCard({ workflowId: wf.id })); @@ -677,7 +682,14 @@ function SidebarSection({ title, items, onPick, scheduled, onContext, onSchedule )} - {w.title} + {/* Retype the title letter-by-letter when it auto-renames, matching + the workflow card. Gated on a real (non-placeholder) title so it + never animates on first appearance or for already-named rows. */} + + {(t) => ( + {t} + )} + {scheduled && !w.schedule.enabled && ( Paused )} diff --git a/frontend/src/app/pages/Workflows/workflowVisuals.tsx b/frontend/src/app/pages/Workflows/workflowVisuals.tsx index c2e1d808..acfcbeeb 100644 --- a/frontend/src/app/pages/Workflows/workflowVisuals.tsx +++ b/frontend/src/app/pages/Workflows/workflowVisuals.tsx @@ -31,6 +31,16 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import type { Workflow, WorkflowRun, ScheduleConfig, PermissionTier } from '@/shared/state/workflowsSlice'; import { formatTime, WEEKDAY_LABEL, isScheduleConfigured } from './scheduleUtils'; +// ---------- Title placeholders ---------- + +// Placeholder titles the backend uses before auto-naming kicks in. The title +// Typewriter only animates once the title is a real (generated/user) name, so +// the UI doesn't animate on mount or while still showing a placeholder. +const PLACEHOLDER_TITLES = new Set(['', 'New workflow', 'Untitled workflow', 'Scheduled workflow']); +export function isRealTitle(title?: string | null): boolean { + return !!title && !PLACEHOLDER_TITLES.has(title.trim()); +} + // ---------- Status colors ---------- export type LastRunStatus = NonNullable; diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index b5f93da4..a775a498 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -91,6 +91,10 @@ export interface Workflow { * unattended scheduled fire doesn't stall on a prompt. tool name -> answer. */ remembered_approvals?: Record; step_tool_usage?: Record>; + /** False once the user explicitly renames the workflow; backend may auto-rename while true. */ + auto_named?: boolean; + /** True while a brand-new "+ New" workflow is still being built and hasn't been saved; hub hides these. */ + unsaved?: boolean; } export interface WorkflowRun {