mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-08 18:57:43 +02:00
[aidan] feat/workflows: auto-generate workflow + step titles with typewriter animation
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user