mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-09 11:17:44 +02:00
[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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user