[aidan] feat/workflows: auto-generate workflow + step titles with typewriter animation

This commit is contained in:
abccodes
2026-06-23 19:45:51 -07:00
parent 2ca785de1e
commit 3f4e1388d7
8 changed files with 171 additions and 81 deletions
+70 -24
View File
@@ -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)
@@ -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<string | null>(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<boolean | null>(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 }) => {
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', background: WC.page, position: 'relative' }}>
<div style={{ flex: 'none', padding: '15px 28px', borderBottom: `1px solid rgba(${WC.inkRGB},0.06)`, display: 'flex', alignItems: 'center', gap: 12 }}>
<ColorSwatch value={colorForWorkflow(workflow)} onChange={(hex) => patch(workflow, { color: hex })} size={15} />
<input
value={name}
onChange={(e) => setName(e.target.value)}
onFocus={() => { nameFocused.current = true; }}
onBlur={() => { nameFocused.current = false; commitName(); }}
<InlineEditableTitle
value={workflow.title || ''}
onCommit={(t) => 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' }}
>
<Typewriter value={workflow.title || 'Untitled workflow'} enabled={workflow.auto_named !== false}>
{(t) => (
<span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: "'Newsreader',serif", fontSize: 21, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em' }}>{t}</span>
)}
</Typewriter>
</InlineEditableTitle>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10, letterSpacing: '0.06em', textTransform: 'uppercase', fontWeight: 500, color: WC.muted, background: `rgba(${WC.inkRGB},0.07)`, padding: '4px 10px', borderRadius: 999, flex: 'none' }}>Draft</span>
<div
onClick={() => setPaneManual(!paneOpen)}
@@ -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<string | null>(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
<div style={{ flex: 'none', padding: '20px 28px 16px', borderBottom: `1px solid rgba(${WC.inkRGB},0.06)` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 13 }}>
<ColorSwatch value={colorForWorkflow(workflow)} onChange={(hex) => patch(workflow, { color: hex })} size={14} />
<input
value={name}
onChange={(e) => 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' }}
/>
<InlineEditableTitle
value={workflow.title || ''}
onCommit={(t) => 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' }}
>
<Typewriter value={workflow.title || 'Untitled workflow'} enabled={workflow.auto_named !== false}>
{(t) => (
<span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: "'Newsreader',serif", fontSize: 25, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em' }}>{t}</span>
)}
</Typewriter>
</InlineEditableTitle>
<span style={statusChip(status, WC)}>{statusText}</span>
<button onClick={runNow} disabled={running} style={{ display: 'flex', alignItems: 'center', gap: 8, background: running ? WC.inset : WC.ink, color: running ? WC.muted : WC.paper, border: 'none', borderRadius: 9, padding: '8px 15px', fontSize: 13, fontWeight: 600, cursor: running ? 'default' : 'pointer', flex: 'none' }}>
{running
@@ -5,6 +5,7 @@ import { openWorkflowMonitor } from '@/shared/state/dashboardLayoutSlice';
import { useCalendarOccurrences } from './useCalendarOccurrences';
import { colorForWorkflow, useWC, statusChip, statusDot } from './uiKit';
import { clockOf, whenText } from './model';
import WorkflowTitle from './WorkflowTitle';
import type { AppNav } from './types';
interface ComingRun { wfId: string; title: string; time: string; sortKey: number; steps: number; color: string; }
@@ -23,6 +24,8 @@ const HomeView: React.FC<{ nav: AppNav }> = ({ nav }) => {
const now = new Date();
const todayLabel = now.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' });
// Animate only AI-driven renames; a user-renamed workflow (auto_named false) snaps.
const animateOf = (wfId: string) => items[wfId]?.auto_named !== false;
const ongoing = useMemo(() => active.map((a) => {
const wf = items[a.workflow_id];
@@ -105,7 +108,9 @@ const HomeView: React.FC<{ nav: AppNav }> = ({ nav }) => {
<div style={{ width: 8, height: 8, borderRadius: '50%', background: o.color, flex: 'none' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: WC.ink, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{o.title}</span>
<WorkflowTitle value={o.title} animate={animateOf(o.wfId)}>
{(t) => <span style={{ fontSize: 14, fontWeight: 600, color: WC.ink, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t}</span>}
</WorkflowTitle>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10.5, color: WC.muted2, flex: 'none' }}>{o.stepLabel}</span>
</div>
<div style={{ height: 4, borderRadius: 999, background: WC.inset, overflow: 'hidden', marginTop: 7 }}>
@@ -137,7 +142,9 @@ const HomeView: React.FC<{ nav: AppNav }> = ({ nav }) => {
<div key={m.id} style={{ display: 'flex', alignItems: 'center', gap: 12, background: WC.raised, border: '1px solid rgba(194,72,58,0.20)', borderRadius: WC.radius.md, padding: '10px 14px' }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: WC.danger, flex: 'none' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: WC.ink, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{m.workflow_title}</div>
<WorkflowTitle value={m.workflow_title} animate={animateOf(m.workflow_id)}>
{(t) => <div style={{ fontSize: 13.5, fontWeight: 600, color: WC.ink, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t}</div>}
</WorkflowTitle>
<div style={{ fontSize: 11.5, color: WC.muted, marginTop: 1 }}>Missed while the app was closed</div>
</div>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11, color: WC.muted2, flex: 'none' }}>{whenText(new Date(m.scheduled_for), now)}</span>
@@ -174,7 +181,9 @@ const HomeView: React.FC<{ nav: AppNav }> = ({ nav }) => {
{(expandedDays.has(g.key) ? g.runs : g.runs.slice(0, COMING_CAP)).map((r, i) => (
<div key={`${r.wfId}-${i}`} onClick={() => nav.selectWorkflow(r.wfId)} style={{ display: 'flex', alignItems: 'center', gap: 13, background: WC.raised, border: `1px solid rgba(${WC.inkRGB},0.08)`, borderRadius: WC.radius.md, padding: '12px 15px', cursor: 'pointer' }}>
<div style={{ width: 3, height: 30, borderRadius: 3, background: r.color, flex: 'none' }} />
<span style={{ fontSize: 14, fontWeight: 600, color: WC.ink, flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
<WorkflowTitle value={r.title} animate={animateOf(r.wfId)}>
{(t) => <span style={{ fontSize: 14, fontWeight: 600, color: WC.ink, flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t}</span>}
</WorkflowTitle>
<span style={{ fontSize: 12, color: WC.muted }}>{r.steps} steps</span>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 12, color: WC.ink3, minWidth: 74, textAlign: 'right' }}>{r.time}</span>
</div>
@@ -200,7 +209,9 @@ const HomeView: React.FC<{ nav: AppNav }> = ({ nav }) => {
<div key={r.id} onClick={() => nav.selectWorkflow(r.wfId)} style={{ display: 'flex', alignItems: 'center', gap: 13, padding: '11px 4px', borderBottom: `1px solid rgba(${WC.inkRGB},0.05)`, cursor: 'pointer' }}>
<div style={statusDot(r.status, WC)} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: WC.ink }}>{r.title}</div>
<WorkflowTitle value={r.title} animate={animateOf(r.wfId)}>
{(t) => <div style={{ fontSize: 13.5, fontWeight: 600, color: WC.ink }}>{t}</div>}
</WorkflowTitle>
<div style={{ fontSize: 12, color: WC.muted, marginTop: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.summary}</div>
</div>
<div style={{ width: 72, display: 'flex', justifyContent: 'flex-end', flex: 'none' }}>
@@ -4,6 +4,7 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { deleteWorkflow } from '@/shared/state/workflowsSlice';
import { isScheduleActive, describeSchedule } from '@/app/pages/Workflows/scheduleUtils';
import { colorForWorkflow, useWC } from './uiKit';
import WorkflowTitle from './WorkflowTitle';
import type { AppNav } from './types';
const navBase: CSSProperties = {
@@ -96,7 +97,9 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
>
<div style={{ width: 8, height: 8, borderRadius: '50%', flex: 'none', background: colorForWorkflow(w), opacity: active ? 1 : 0.35 }} />
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: active ? WC.ink : WC.muted, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{w.title || 'Untitled workflow'}</div>
<WorkflowTitle value={w.title} animate={w.auto_named !== false}>
{(t) => <div style={{ fontSize: 13.5, fontWeight: 600, color: active ? WC.ink : WC.muted, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t}</div>}
</WorkflowTitle>
<div style={{ fontSize: 11, color: WC.muted2, marginTop: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{active ? describeSchedule(w.schedule) : 'Paused'}
</div>
@@ -10,6 +10,7 @@ import {
bringToFront, closeWorkflowMonitor, setWorkflowsMonitorPosition,
} from '@/shared/state/dashboardLayoutSlice';
import type { CardType } from '@/shared/state/dashboardLayoutSlice';
import WorkflowTitle from './WorkflowTitle';
type StepState = 'done' | 'running' | 'failed' | 'pending';
const DRAG_THRESHOLD = 3;
@@ -181,7 +182,9 @@ const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHe
style={{ height: 44, flex: 'none', display: 'flex', alignItems: 'center', gap: 9, padding: '0 10px 0 14px', borderBottom: `1px solid ${c.border.subtle}`, background: c.bg.elevated, cursor: localPos ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none' }}
>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: headColor, flex: 'none', ...(isRunning ? { animation: 'os-pulse 1.1s ease-in-out infinite' } : {}) }} />
<span style={{ fontSize: 13.5, fontWeight: 600, color: c.text.primary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{workflow.title || 'Untitled workflow'}</span>
<WorkflowTitle value={workflow.title} animate={workflow.auto_named !== false}>
{(t) => <span style={{ fontSize: 13.5, fontWeight: 600, color: c.text.primary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t}</span>}
</WorkflowTitle>
<span style={{ fontSize: 11, fontWeight: 600, color: headColor, background: headBg, padding: '2px 9px', borderRadius: 999, flex: 'none' }}>{headStatus}</span>
<div style={{ flex: 1 }} />
<span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11.5, color: c.text.tertiary, flex: 'none' }}>{clock}</span>
@@ -43,6 +43,26 @@ const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sig]);
// A manual add lands with an empty label that the backend names from the step
// text. The label arrives without changing the steps signature (same text), so
// fill it in here without a full reseed and without touching a label you're
// mid-typing.
useEffect(() => {
setLocal((prev) => {
const byId = new Map(workflow.steps.map((s) => [s.id, s]));
let changed = false;
const next = prev.map((s) => {
const srv = byId.get(s.id);
if (srv && !s.label.trim() && (srv.label || '').trim()) {
changed = true;
return { ...s, label: srv.label as string };
}
return s;
});
return changed ? next : prev;
});
}, [workflow.steps]);
const commit = (next: LocalStep[]) => {
patch(workflow, { steps: next.map((s) => ({ id: s.id, text: s.text, label: s.label, enabled: s.enabled })) });
};
@@ -56,7 +76,9 @@ const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
const onAdd = () => {
const t = draft.trim();
if (!t) return;
const next = [...local, { id: newStepId(), label: t, text: t, open: false, enabled: true }];
// What you type is the step's prompt; the short label is generated from it
// server-side (empty label tells the backend to name this step).
const next = [...local, { id: newStepId(), label: '', text: t, open: false, enabled: true }];
setLocal(next);
setDraft('');
commit(next);
@@ -0,0 +1,23 @@
import React from 'react';
import { Typewriter } from '@/app/components/feedback/Animated';
interface WorkflowTitleProps {
// Raw title; the 'Untitled workflow' fallback is applied here so every surface
// agrees on the empty-name text.
value: string | null | undefined;
// Animate AI-driven renames only. Pass `workflow.auto_named !== false`: a user
// rename flips auto_named false and the new title should just snap (they typed
// it), while the build agent's first-step rename types in like the chat card.
animate: boolean;
children: (shown: string) => React.ReactNode;
}
// One home for the workflow-title typewriter so every place a workflow name
// renders animates the same way AgentCard does when its name regenerates.
export const WorkflowTitle: React.FC<WorkflowTitleProps> = ({ value, animate, children }) => (
<Typewriter value={value || 'Untitled workflow'} enabled={animate}>
{children}
</Typewriter>
);
export default WorkflowTitle;