From 3f4e1388d705e3c31bd1e029f8aff7f5fc065964 Mon Sep 17 00:00:00 2001 From: abccodes Date: Tue, 23 Jun 2026 19:45:51 -0700 Subject: [PATCH] [aidan] feat/workflows: auto-generate workflow + step titles with typewriter animation --- backend/apps/workflows/workflows.py | 94 ++++++++++++++----- .../app/pages/Workflows/app/ComposeView.tsx | 53 ++++------- .../app/pages/Workflows/app/DetailView.tsx | 29 +++--- .../src/app/pages/Workflows/app/HomeView.tsx | 19 +++- .../src/app/pages/Workflows/app/LeftRail.tsx | 5 +- .../app/pages/Workflows/app/RunMonitor.tsx | 5 +- .../src/app/pages/Workflows/app/StepsCard.tsx | 24 ++++- .../app/pages/Workflows/app/WorkflowTitle.tsx | 23 +++++ 8 files changed, 171 insertions(+), 81 deletions(-) create mode 100644 frontend/src/app/pages/Workflows/app/WorkflowTitle.tsx diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index eb1b01ac..9e830c32 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -335,12 +335,22 @@ async def generate_workflow_metadata(body: GenerateMetadataRequest) -> GenerateM async def _generate_workflow_metadata(wf: Workflow) -> tuple[str, str, list[str]]: + """(title, description, step_labels) for the workflow's live steps. Thin + wrapper over p_generate_metadata_for_steps.""" + return await p_generate_metadata_for_steps(wf.steps, wf.model) + + +async def p_generate_metadata_for_steps( + steps: list[WorkflowStep], model: Optional[str] +) -> tuple[str, str, list[str]]: """Single aux-model call returning (title, description, step_labels). One round-trip for all three so we don't burn 3x aux cost. Returns - ("", "", []) on any failure; caller writes back unconditionally. + ("", "", []) on any failure; caller writes back unconditionally. Takes an + explicit step list so the draft-build path can name from draft_steps before + they're committed onto the live workflow. """ - if not wf.steps: + if not steps: return "", "", [] try: from backend.apps.agents.providers.registry import resolve_aux_model, get_api_type @@ -354,13 +364,13 @@ async def _generate_workflow_metadata(wf: Workflow) -> tuple[str, str, list[str] # 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), + settings, preferred_tier="haiku", primary_api=get_api_type(model), ) client = get_anthropic_client_for_model(settings, aux_model) except Exception: return "", "", [] - steps_lines = "\n".join(f"{i+1}. {s.text}" for i, s in enumerate(wf.steps) if s.text) - n_steps = len(wf.steps) + steps_lines = "\n".join(f"{i+1}. {s.text}" for i, s in enumerate(steps) if s.text) + n_steps = len(steps) prompt = ( "You name and describe a saved automation routine that the user " "can re-run later, AND produce a short at-a-glance label for " @@ -442,12 +452,12 @@ async def _generate_workflow_metadata(wf: Workflow) -> tuple[str, str, list[str] _PLACEHOLDER_TITLES = {"", "New workflow", "Untitled workflow", "Scheduled workflow"} -def _fallback_title(wf: Workflow) -> str: +def p_fallback_title_for_steps(steps: list[WorkflowStep]) -> 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: + for s in steps: base = ((s.label or "") or (s.text or "")).strip() if base: words = base.split()[:5] @@ -455,14 +465,32 @@ def _fallback_title(wf: Workflow) -> str: return "" -async def p_relabel_changed_steps(wf: Workflow, before_steps: list[dict]) -> None: +def p_short_step_label(text: str) -> str: + """Deterministic short label from a step's prompt, used when the aux model is + unreachable or hands back a mis-sized list. Without it an unlabeled step falls + back to showing its whole prompt as the title. First ~6 words, sentence case.""" + base = (text or "").strip() + if not base: + return "" + label = " ".join(base.split()[:6]) + return (label[:1].upper() + label[1:])[:48] + + +async def p_relabel_steps( + wf: Workflow, before_steps: list[dict], steps: list[WorkflowStep], model: Optional[str] +) -> None: + """Generate short per-step labels for changed/unlabeled steps, and name the + workflow once from the steps. Works on any step list (live `steps` or the + build `draft_steps`) so naming fires the moment a step lands, agent or manual. + + Auto-name fires only while the title is still a placeholder ("Untitled + workflow") and the workflow is still auto-named: that names it once on the + first real step, never drifts on later edits, and stops two paths (the draft + stage and its commit) from both spending an aux call on the same title.""" 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): + content_changed = len(before_steps) != len(steps) + for i, step in enumerate(steps): old = before_by_id.get(step.id) old_text = (old or {}).get("text") or "" old_label = (old or {}).get("label") or "" @@ -473,15 +501,19 @@ async def p_relabel_changed_steps(wf: Workflow, before_steps: list[dict]) -> Non if not new_label and old_label: step.label = old_label continue + # A step with no distinct user label gets one generated from its prompt. if not (new_label and new_label != old_label): regen_idxs.append(i) - # 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) + need_autoname = ( + wf.auto_named + and (wf.title or "").strip() in _PLACEHOLDER_TITLES + and content_changed + and any(s.text for s in steps) + ) if not regen_idxs and not need_autoname: return try: - title, description, labels = await _generate_workflow_metadata(wf) + title, description, labels = await p_generate_metadata_for_steps(steps, model) except Exception: return # One aux call covers labels AND auto-naming. A manual rename sets @@ -489,18 +521,27 @@ async def p_relabel_changed_steps(wf: Workflow, before_steps: list[dict]) -> Non if need_autoname: if title: wf.title = title - elif (wf.title or "").strip() in _PLACEHOLDER_TITLES: + else: # 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) + # a step-derived name so the workflow doesn't stay "Untitled workflow". + fb = p_fallback_title_for_steps(steps) 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]: - wf.steps[i].label = labels[i] + # Per-index, not all-or-nothing: the cheap aux tier sometimes returns a + # mis-sized (or non-list) step_labels, which used to drop EVERY label and + # leave the raw prompt showing as the step title. Take whatever aux gave for + # this slot, else a deterministic short label so a step is never its prompt. + for i in regen_idxs: + aux = labels[i].strip() if i < len(labels) and labels[i] else "" + new_label = aux or p_short_step_label(steps[i].text) + if new_label: + steps[i].label = new_label + + +async def p_relabel_changed_steps(wf: Workflow, before_steps: list[dict]) -> None: + await p_relabel_steps(wf, before_steps, wf.steps, wf.model) def _last_run_cost(wid: str) -> float: @@ -786,12 +827,17 @@ async def update_workflow( # the stale draft). The main chat agent never opens an Edit Agent, so it # has no draft and falls through to the live path below. if wf.draft_steps is not None and "steps" in data: + before_draft = before.get("draft_steps") or [] wf.draft_steps = data["steps"] # Any non-steps fields in the same patch still apply live (rare from # the Edit Agent, whose tools only touch steps). for k, v in data.items(): if k != "steps": setattr(wf, k, v) + # Label the new draft steps and name the workflow off them (once, while + # still "Untitled"), so the title + step labels fill in the instant a + # step lands instead of waiting for Save. + await p_relabel_steps(wf, before_draft, wf.draft_steps, wf.model) wf.updated_at = datetime.now() _normalize_schedule_state(wf) storage.save_workflow(wf) diff --git a/frontend/src/app/pages/Workflows/app/ComposeView.tsx b/frontend/src/app/pages/Workflows/app/ComposeView.tsx index 27a35932..6daf65bf 100644 --- a/frontend/src/app/pages/Workflows/app/ComposeView.tsx +++ b/frontend/src/app/pages/Workflows/app/ComposeView.tsx @@ -5,6 +5,8 @@ import { sendMessage } from '@/shared/state/agentsSlice'; import { defaultSchedule, stepsSignature, needsScheduleTestWarning } from '@/app/pages/Workflows/scheduleUtils'; import { runWorkflowTest } from '@/app/pages/Workflows/runWorkflowTest'; import AgentChat from '@/app/pages/AgentChat/AgentChat'; +import InlineEditableTitle from '@/app/components/InlineEditableTitle'; +import { Typewriter } from '@/app/components/feedback/Animated'; import { useWC, FONT_SERIF, colorForWorkflow } from './uiKit'; import ColorSwatch from './ColorSwatch'; import { useEditAgentSession } from './useEditAgentSession'; @@ -26,36 +28,20 @@ const NEW_CHIPS: Array<{ label: string; prompt: string }> = [ { label: 'Watch a webpage for changes', prompt: 'Watch a webpage and alert me when it changes.' }, ]; -// Short provisional name from the first message so the workflow isn't "Untitled" -// the moment it lands in the sidebar; the agent refines it once it adds steps. -function deriveTitle(content: unknown): string { - const text = typeof content === 'string' ? content : ''; - const words = text.trim().split(/\s+/).filter(Boolean).slice(0, 6).join(' '); - return words.slice(0, 60); -} - const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => { const WC = useWC(); const dispatch = useAppDispatch(); const patch = useWorkflowPatch(); const [draftId, setDraftId] = useState(null); - const [name, setName] = useState(''); const [testing, setTesting] = useState(false); const [guardOpen, setGuardOpen] = useState(false); // null = follow the auto open-on-first-message behavior; true/false = user override. const [paneManual, setPaneManual] = useState(null); const created = useRef(false); - const nameFocused = useRef(false); const handedOff = useRef(false); const workflow = useAppSelector((s) => (draftId ? s.workflows.items[draftId] : undefined)); - // Track the live title (agent rename) unless the user is editing the field, - // so the header never shows a stale name nor blurs it back over the rename. - useEffect(() => { - if (!nameFocused.current) setName(workflow?.title ?? ''); - }, [workflow?.title]); - // One unsaved draft per visit to "New". The backend hides unsaved drafts from // lists, so an abandoned one stays out of the way until GC. useEffect(() => { @@ -65,7 +51,6 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => { try { const wf = await dispatch(createWorkflow({ unsaved: true, title: 'Untitled workflow', steps: [], schedule: defaultSchedule() })).unwrap(); setDraftId(wf.id); - setName(wf.title || ''); } catch { /* surfaced by the empty state */ } })(); }, [dispatch]); @@ -106,24 +91,17 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => { }; // The conversation started, so the workflow is real: reveal it under Workflows - // (and hand off to its detail) right away, even before any step or save, and - // give it a name from the first message. auto_named stays true so the agent's - // step-based naming still refines it later. + // (and hand off to its detail) right away. The title stays "Untitled workflow" + // until the first step lands and the backend auto-names it (auto_named stays + // true), so the name types in from the steps, not the raw prompt. const revealed = useRef(false); const firstUserMsg = (session?.messages || []).find((m) => m.role === 'user' && !m.hidden); useEffect(() => { if (revealed.current || !workflow || !firstUserMsg || workflow.unsaved === false) return; revealed.current = true; - const id = workflow.id; - const cur = (workflow.title || '').trim(); - const fresh = workflow.auto_named !== false && (cur === '' || cur === 'Untitled workflow'); - const provisional = fresh ? deriveTitle(firstUserMsg.content) : ''; // Reveal immediately so it lands in the sidebar the moment you send; the // handoff to detail waits for the agent to finish (see effect above). - dispatch(updateWorkflow({ - id, - patch: provisional ? { unsaved: false, title: provisional, auto_named: true } : { unsaved: false }, - })); + dispatch(updateWorkflow({ id: workflow.id, patch: { unsaved: false } })); // eslint-disable-next-line react-hooks/exhaustive-deps }, [firstUserMsg, workflow?.unsaved, dispatch]); @@ -139,7 +117,6 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => { } const tested = workflow.steps.length > 0 && stepsSignature(workflow.steps) === (workflow.tested_signature ?? ''); - const commitName = () => { const t = name.trim(); if (t && t !== workflow.title) patch(workflow, { title: t, auto_named: false }); }; const doTest = async () => { if (testing || workflow.steps.length === 0) return; @@ -165,14 +142,18 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => {
patch(workflow, { color: hex })} size={15} /> - setName(e.target.value)} - onFocus={() => { nameFocused.current = true; }} - onBlur={() => { nameFocused.current = false; commitName(); }} + patch(workflow, { title: t, auto_named: false })} placeholder="Untitled workflow" - style={{ flex: 1, minWidth: 0, border: 'none', background: 'transparent', fontFamily: "'Newsreader',serif", fontSize: 21, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em' }} - /> + sx={{ flex: 1, minWidth: 0, fontFamily: "'Newsreader',serif", fontSize: 21, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em' }} + > + + {(t) => ( + {t} + )} + + Draft
setPaneManual(!paneOpen)} diff --git a/frontend/src/app/pages/Workflows/app/DetailView.tsx b/frontend/src/app/pages/Workflows/app/DetailView.tsx index bd812517..792660fe 100644 --- a/frontend/src/app/pages/Workflows/app/DetailView.tsx +++ b/frontend/src/app/pages/Workflows/app/DetailView.tsx @@ -1,10 +1,12 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { runWorkflowNow } from '@/shared/state/workflowsSlice'; import { openWorkflowMonitor, setWorkflowsRunContext, clearWorkflowsRunContext } from '@/shared/state/dashboardLayoutSlice'; import { stepsSignature, isScheduleActive } from '@/app/pages/Workflows/scheduleUtils'; import { askRun } from './api'; import AgentChat from '@/app/pages/AgentChat/AgentChat'; +import InlineEditableTitle from '@/app/components/InlineEditableTitle'; +import { Typewriter } from '@/app/components/feedback/Animated'; import { useWC, colorForWorkflow, statusChip } from './uiKit'; import { isRunning, runContextChip } from './model'; import { useEditAgentSession } from './useEditAgentSession'; @@ -22,14 +24,12 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId const workflow = useAppSelector((s) => s.workflows.items[workflowId]); const active = useAppSelector((s) => s.workflows.active); const sessionId = useEditAgentSession(workflowId); - const [name, setName] = useState(workflow?.title ?? ''); const detailRuns = useAppSelector((s) => s.workflows.runs[workflowId]); const runContext = useAppSelector((s) => s.dashboardLayout.workflowsRunContext); // When you Run now from this chat, attach that run as a context chip once it // finishes, so the next question rides on its transcript (removable, no popup). const autoCtxRunId = useRef(null); - useEffect(() => { setName(workflow?.title ?? ''); }, [workflow?.title]); useEffect(() => { const rid = autoCtxRunId.current; if (!rid) return; @@ -52,10 +52,6 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId .unwrap().then((res) => { autoCtxRunId.current = res.run_id || null; }).catch(() => {}); dispatch(openWorkflowMonitor({ workflowId: workflow.id })); }; - const commitName = () => { - const t = name.trim(); - if (t && t !== workflow.title) patch(workflow, { title: t, auto_named: false }); - }; return ( <> @@ -63,13 +59,18 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId
patch(workflow, { color: hex })} size={14} /> - setName(e.target.value)} - onBlur={commitName} - onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} - style={{ flex: 1, minWidth: 0, border: 'none', background: 'transparent', fontFamily: "'Newsreader',serif", fontSize: 25, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em' }} - /> + patch(workflow, { title: t, auto_named: false })} + placeholder="Untitled workflow" + sx={{ flex: 1, minWidth: 0, fontFamily: "'Newsreader',serif", fontSize: 25, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em' }} + > + + {(t) => ( + {t} + )} + + {statusText}