[aidan] feat/workflows: auto-name workflows and polish the build flow (#95)

* [aidan] feat/workflow-auto-naming: auto-generate workflow titles from steps

Generate a title + description from a workflow's steps (one aux call,
reused for step labels) whenever it is still auto_named, so a workflow
built in the Edit Agent names itself on commit instead of staying
"New workflow". A manual rename sets auto_named=False and is never
overwritten. Stream the aux call (non-streaming drops content on some
9router lanes) and fall back to a step-derived title when the model is
unavailable.

* [aidan] feat/workflows: hide unsaved new workflows until first save

A brand-new "+ New" workflow is created with unsaved=true and kept out
of the hub's scheduled/unscheduled lists while the user is still building
it in the Edit Agent. The first commit (Save) clears the flag and the
workflow appears. Every other create path stays visible immediately.

* [aidan] ux/workflows: remove redundant save workflow button

The Edit Agent already has Discard/Save controls in its strip, so the
header "Save Workflow" button was a duplicate save path. Remove it and
its pulse/edit-session-id wiring; the model/time subtitle stays.

* [aidan] ux/workflows: animate title on auto-rename

Wrap the workflow card title in the same Typewriter the chat card uses,
so when the auto-generated name replaces the placeholder after Save it
retypes letter-by-letter. Gated on a real (non-placeholder) title so it
never animates on mount or for already-named workflows.

* [aidan] ux/workflows: animate sidebar title on auto-rename

Wrap the calendar hub's sidebar row title in the same Typewriter the
workflow card uses, so a title that auto-renames retypes letter-by-letter
in the sidebar too. Extract the placeholder/isRealTitle guard into the
shared workflowVisuals so the card and sidebar stay in sync.
This commit is contained in:
Aidan
2026-06-17 20:27:08 -07:00
committed by GitHub
parent 2bc96f603b
commit 72dd5208a6
6 changed files with 141 additions and 84 deletions
+13
View File
@@ -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
+79 -20
View File
@@ -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)
@@ -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<Props> = ({
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<string | null>(null);
const editSession = useAppSelector((s) => editSessionId ? s.agents.sessions[editSessionId] : undefined);
const [savePulseNonce, setSavePulseNonce] = useState(0);
const prevEditStatusRef = useRef<string | undefined>(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<Props> = ({
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. */}
<Typewriter value={title} enabled={isRealTitle(workflow.title)}>
{(t) => (
<Typography sx={{ fontWeight: 600, fontSize: '0.95rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', letterSpacing: '-0.005em' }}>
{t}
</Typography>
)}
</Typewriter>
</InlineEditableTitle>
) : (
<Typography sx={{ fontWeight: 600, fontSize: '0.95rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', letterSpacing: '-0.005em' }}>
{title}
@@ -538,47 +538,6 @@ const WorkflowCard: React.FC<Props> = ({
</>
)}
{runs && runs.length > 0 && <RunSparkline runs={runs} />}
{!isDraft && workflow && showHeaderSaveWorkflow && (
<Tooltip title={steps.length > 0 ? 'Save the workflow and close the editor' : 'Add at least one step before saving'}>
<Box
role="button"
data-no-drag
onClick={(e) => {
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}`,
}),
}}
>
<AutoAwesomeOutlinedIcon sx={{ fontSize: 14 }} />
Save Workflow
</Box>
</Tooltip>
)}
<IconButton
size="small"
data-no-drag
@@ -29,6 +29,8 @@ import { useEffect } from 'react';
import ScheduleCalendar from './ScheduleCalendar';
import AddToSchedulePopover from './AddToSchedulePopover';
import { WEEKDAY_LABEL, addDays, sameDay, startOfMonthGrid, isWorkflowSchedulable } from './scheduleUtils';
import { isRealTitle } from './workflowVisuals';
import { Typewriter } from '@/app/components/feedback/Animated';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
@@ -159,8 +161,11 @@ const WorkflowsHubCard: React.FC<Props> = ({
// 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<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: [] }));
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
</Box>
</Tooltip>
)}
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: scheduled && !w.schedule.enabled ? 0.7 : 1 }}>{w.title}</Typography>
{/* 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. */}
<Typewriter value={w.title} enabled={isRealTitle(w.title)}>
{(t) => (
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: scheduled && !w.schedule.enabled ? 0.7 : 1 }}>{t}</Typography>
)}
</Typewriter>
{scheduled && !w.schedule.enabled && (
<Box sx={{ flexShrink: 0, px: 0.6, py: 0.1, borderRadius: '3px', bgcolor: c.bg.elevated, color: c.text.muted, fontSize: '0.62rem', fontWeight: 600, lineHeight: 1.5, letterSpacing: '0.02em' }}>Paused</Box>
)}
@@ -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<Workflow['last_run_status']>;
@@ -91,6 +91,10 @@ export interface Workflow {
* unattended scheduled fire doesn't stall on a prompt. tool name -> answer. */
remembered_approvals?: Record<string, 'allow' | 'deny'>;
step_tool_usage?: Record<string, Record<string, boolean>>;
/** 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 {