[aidan] feat/compose: new-workflow landing page and auto-commit build flow

This commit is contained in:
abccodes
2026-06-23 00:01:41 -07:00
parent 76cb97b9d0
commit a9496fd0f1
5 changed files with 180 additions and 46 deletions
+7
View File
@@ -250,6 +250,9 @@ class GenerateMetadataResponse(BaseModel):
class WorkflowUpdate(BaseModel):
title: Optional[str] = None
auto_named: Optional[bool] = None
# Revealing a compose draft (Save, or auto on first chat message) flips this
# to False so the hub stops hiding it. Without it here the PATCH was a no-op.
unsaved: Optional[bool] = None
description: Optional[str] = None
icon: Optional[str] = None
color: Optional[str] = None
@@ -284,3 +287,7 @@ class DraftCommitBody(BaseModel):
# The model the user settled on in the Edit Agent picker, applied to the
# workflow's run model only on Save (save-gated; Discard drops it).
model: Optional[str] = None
# Keep the edit-agent session alive across the commit. The build flow
# auto-commits steps as the agent adds them but must NOT close the chat,
# the user keeps talking in the same conversation after it becomes saved.
keep_session: bool = False
+39 -2
View File
@@ -23,6 +23,18 @@ from backend.apps.workflows import storage, scheduler, executor, audit, escalati
logger = logging.getLogger(__name__)
# Fixed opener for an existing workflow's edit chat. Deterministic on purpose,
# no aux LLM call, so it never drifts and always lays out what the user can do.
EDIT_AGENT_INTRO = (
"Here's your workflow's edit space. Tell me what you want and I'll handle it:\n\n"
"- Add, remove, or reorder steps\n"
"- Rewrite what any step does\n"
"- Connect tools it needs (email, calendar, browsing, and more)\n"
"- Test a run to see it work end to end\n\n"
"You can ask me directly here, or edit it yourself in the panel on the right: "
"Schedule sets when and how often it runs, and Steps is what it does, in order."
)
def _scan_cron_for_openswarm() -> list[str]:
"""Surface OS-level scheduled-task entries that reference us.
@@ -972,6 +984,29 @@ async def edit_agent_session(workflow_id: str):
dashboard_id=wf.dashboard_id,
)
session = await agent_manager.launch_agent(config)
# launch_agent marks the session "running" assuming a turn fires immediately,
# but an edit-agent chat sits idle until the user sends something. Settle it
# to idle or the chat is stuck "thinking" forever. An existing workflow also
# gets a fixed (non-LLM) intro message; a brand-new build stays empty so the
# compose page can show its own starter prompts.
session.status = "completed"
if wf.steps:
from backend.apps.agents.core.models import Message
session.messages.append(Message(role="assistant", content=EDIT_AGENT_INTRO))
try:
from backend.apps.agents.manager.session.session_store import _save_session
_save_session(session.id, session.model_dump(mode="json"))
except Exception:
logger.debug("could not persist edit-agent session", exc_info=True)
try:
from backend.apps.agents.core.ws_manager import ws_manager
await ws_manager.send_to_session(session.id, "agent:status", {
"session_id": session.id,
"status": "completed",
"session": session.model_dump(mode="json"),
})
except Exception:
logger.debug("could not broadcast edit-agent idle status", exc_info=True)
try:
setattr(wf, "edit_agent_session_id", session.id)
storage.save_workflow(wf)
@@ -1074,7 +1109,8 @@ async def commit_draft(workflow_id: str, body: Optional[DraftCommitBody] = None)
# in the hub (clears the "+ New" build-in-progress flag).
wf.unsaved = False
p_sync_model_on_save(wf, body.model if body else None)
await p_end_edit_session(wf)
if not (body and body.keep_session):
await p_end_edit_session(wf)
storage.save_workflow(wf)
return _enriched(wf)
before = wf.model_dump(mode="json")
@@ -1092,7 +1128,8 @@ async def commit_draft(workflow_id: str, body: Optional[DraftCommitBody] = None)
wf.icon = _derive_icon(wf)
_normalize_schedule_state(wf)
p_sync_model_on_save(wf, body.model if body else None)
await p_end_edit_session(wf)
if not (body and body.keep_session):
await p_end_edit_session(wf)
storage.save_workflow(wf)
audit.log_change(wf.id, "user", before, wf.model_dump(mode="json"))
scheduler.kick()
@@ -1,10 +1,12 @@
import React, { useEffect, useRef, useState } from 'react';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { createWorkflow, updateWorkflow } from '@/shared/state/workflowsSlice';
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 { WC } from './uiKit';
import { useWC, FONT_SERIF, colorForWorkflow } from './uiKit';
import ColorSwatch from './ColorSwatch';
import { useEditAgentSession } from './useEditAgentSession';
import { useWorkflowPatch } from './useWorkflowPatch';
import ScheduleCard from './ScheduleCard';
@@ -12,17 +14,44 @@ import StepsCard from './StepsCard';
import SaveGuard from './SaveGuard';
import type { AppNav } from './types';
const NEW_CHIPS = [
'Summarize my inbox each morning',
'Draft replies to important emails',
'Send me a daily news digest',
'Weekly report from my calendar',
'Watch a webpage for 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(() => {
@@ -37,14 +66,64 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => {
})();
}, [dispatch]);
const sessionId = useEditAgentSession(draftId ?? '', 'build');
const sessionId = useEditAgentSession(draftId ?? '');
const session = useAppSelector((s) => (sessionId ? s.agents.sessions[sessionId] : undefined));
const agentBusy = session?.status === 'running' || session?.status === 'waiting_approval';
const visibleMsgs = (session?.messages || []).filter((m) => !m.hidden).length;
// Landing state: the blank page (incl. before the session has loaded, so the
// right pane starts closed rather than open-then-snap-shut). Gone the moment a
// message lands or the agent starts working, so its "thinking" never shows here.
const composeEmpty = !agentBusy && visibleMsgs === 0;
// Open the pane only once the agent has fully answered (not mid-response, where
// the chat is reflowing and the slide looks janky). Header toggle overrides it.
const autoOpen = !agentBusy && visibleMsgs > 0;
const paneOpen = paneManual ?? autoOpen;
// Once it's revealed (in the sidebar) AND the agent has finished its first
// answer, hand off to the detail page, after a beat so the right-pane open
// animation plays here first. Same sticky edit session, so the chat carries over.
useEffect(() => {
if (handedOff.current || !draftId || !workflow) return;
if (workflow.unsaved === false && autoOpen) {
handedOff.current = true;
setTimeout(() => nav.selectWorkflow(draftId), 480);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draftId, workflow?.unsaved, autoOpen]);
const sendChip = (text: string) => {
if (!sessionId || !session) return;
dispatch(sendMessage({ sessionId, prompt: text, mode: session.mode, model: session.model }));
};
// 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.
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 },
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [firstUserMsg, workflow?.unsaved, dispatch]);
if (!workflow) {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', background: WC.paper }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 14, height: 14, borderRadius: '50%', border: '2px solid rgba(33,30,27,0.15)', borderTopColor: WC.accent, animation: 'os-spin 0.7s linear infinite' }} />
<span style={{ fontFamily: "'Newsreader',serif", fontStyle: 'italic', fontSize: 14, color: '#6B655C' }}>Setting up your workflow</span>
<div style={{ width: 14, height: 14, borderRadius: '50%', border: `2px solid rgba(${WC.inkRGB},0.15)`, borderTopColor: WC.accent, animation: 'os-spin 0.7s linear infinite' }} />
<span style={{ fontFamily: "'Newsreader',serif", fontStyle: 'italic', fontSize: 14, color: WC.ink4 }}>Setting up your workflow</span>
</div>
</div>
);
@@ -60,8 +139,11 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => {
finally { setTesting(false); }
};
// No steps / no title is fine, you can save a bare workflow and fill it in
// later. No If-Match: this is the user's own brand-new draft, so there's no
// concurrent edit to guard against and a stale stamp shouldn't block the save.
const finalizeSave = () => {
dispatch(updateWorkflow({ id: workflow.id, patch: { unsaved: false }, ifMatch: workflow.updated_at }));
dispatch(updateWorkflow({ id: workflow.id, patch: { unsaved: false } }));
nav.selectWorkflow(workflow.id);
};
const onSave = () => {
@@ -72,22 +154,44 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => {
return (
<>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', background: WC.paper, position: 'relative' }}>
<div style={{ flex: 'none', padding: '15px 28px', borderBottom: '1px solid rgba(33,30,27,0.06)', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 15, height: 15, borderRadius: 4, background: WC.accent, boxShadow: '0 0 0 1px rgba(33,30,27,0.14)', flex: 'none' }} />
<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)}
onBlur={commitName}
onFocus={() => { nameFocused.current = true; }}
onBlur={() => { nameFocused.current = false; commitName(); }}
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' }}
/>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10, letterSpacing: '0.06em', textTransform: 'uppercase', fontWeight: 500, color: WC.muted, background: 'rgba(33,30,27,0.07)', padding: '4px 10px', borderRadius: 999, flex: 'none' }}>Draft</span>
<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)}
title={paneOpen ? 'Hide schedule & steps' : 'Show schedule & steps'}
style={{ width: 28, height: 28, borderRadius: 7, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: paneOpen ? WC.ink3 : WC.muted, flex: 'none' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M14 4v16" /><path d={paneOpen ? 'M19 9l-2 3 2 3' : 'M17 9l2 3-2 3'} /></svg>
</div>
</div>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', position: 'relative' }}>
{sessionId
? <AgentChat sessionId={sessionId} embedded autoFocus workflowEditId={workflow.id} />
: <div style={{ flex: 1 }} />}
{composeEmpty && (
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', gap: 16, padding: '0 28px 96px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
<div style={{ width: 11, height: 11, borderRadius: 3, background: WC.accent, flex: 'none' }} />
<h2 style={{ margin: 0, fontFamily: FONT_SERIF, fontSize: 25, fontWeight: 500, fontStyle: 'italic', color: WC.ink }}>Describe the workflow to automate</h2>
</div>
<div style={{ fontSize: 13.5, color: WC.muted, maxWidth: 430, lineHeight: 1.55 }}>Tell me what you want this workflow to do. I'll turn it into steps you can run on a schedule, and you can tweak anything as we go.</div>
<div style={{ display: 'flex', gap: 9, flexWrap: 'wrap', justifyContent: 'center', maxWidth: 520, marginTop: 4, pointerEvents: 'auto' }}>
{NEW_CHIPS.map((c) => (
<button key={c} onClick={() => sendChip(c)} style={{ background: WC.inset, border: `1px solid rgba(${WC.inkRGB},0.08)`, borderRadius: 999, padding: '7px 14px', fontSize: 12.5, color: WC.ink3, cursor: 'pointer' }}>{c}</button>
))}
</div>
</div>
)}
</div>
{guardOpen && (
@@ -100,12 +204,15 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => {
)}
</div>
<div style={{ width: 344, flex: 'none', borderLeft: `1px solid ${WC.line}`, background: WC.rail, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{/* Hidden on the blank landing page; opens with a smooth width/fade once
the conversation starts. */}
<div style={{ width: paneOpen ? 344 : 0, flex: 'none', overflow: 'hidden', background: WC.rail, transition: 'width .4s cubic-bezier(.4,0,.2,1)' }}>
<div style={{ width: 344, height: '100%', borderLeft: `1px solid ${WC.line}`, display: 'flex', flexDirection: 'column', minHeight: 0, opacity: paneOpen ? 1 : 0, transition: 'opacity .4s ease' }}>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, padding: '18px 18px 22px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<ScheduleCard workflow={workflow} />
<StepsCard workflow={workflow} />
</div>
<div style={{ flex: 'none', borderTop: '1px solid rgba(33,30,27,0.08)', background: WC.rail, padding: '13px 18px', display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ flex: 'none', borderTop: `1px solid rgba(${WC.inkRGB},0.08)`, background: WC.rail, padding: '13px 18px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{!tested && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 7, fontSize: 11.5, lineHeight: 1.4, color: WC.muted }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={WC.warn} strokeWidth="2" style={{ flex: 'none', marginTop: 1 }}><circle cx="12" cy="12" r="9" /><path d="M12 8v5" /><path d="M12 16h.01" /></svg>
@@ -113,7 +220,7 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => {
</div>
)}
<div style={{ display: 'flex', gap: 9 }}>
<button onClick={doTest} disabled={testing || workflow.steps.length === 0} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, flex: 'none', padding: '10px 15px', borderRadius: 9, border: '1px solid rgba(33,30,27,0.14)', background: WC.paper, color: testing || workflow.steps.length === 0 ? WC.muted2 : WC.ink, fontSize: 13, fontWeight: 600, cursor: testing || workflow.steps.length === 0 ? 'default' : 'pointer' }}>
<button onClick={doTest} disabled={testing || workflow.steps.length === 0} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, flex: 'none', padding: '10px 15px', borderRadius: 9, border: `1px solid rgba(${WC.inkRGB},0.14)`, background: WC.paper, color: testing || workflow.steps.length === 0 ? WC.muted2 : WC.ink, fontSize: 13, fontWeight: 600, cursor: testing || workflow.steps.length === 0 ? 'default' : 'pointer' }}>
{testing
? <div style={{ width: 12, height: 12, borderRadius: '50%', border: '2px solid rgba(140,133,122,0.3)', borderTopColor: WC.muted, animation: 'os-spin 0.7s linear infinite', flex: 'none' }} />
: <div style={{ width: 0, height: 0, borderTop: '5px solid transparent', borderBottom: '5px solid transparent', borderLeft: `8px solid ${WC.accent}`, flex: 'none' }} />}
@@ -122,6 +229,7 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => {
<button onClick={onSave} style={{ flex: 1, background: WC.accent, color: '#fff', border: 'none', borderRadius: 9, padding: 10, fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>Save workflow</button>
</div>
</div>
</div>
</div>
</>
);
@@ -1,23 +1,19 @@
import { useEffect, useRef, useState } from 'react';
import { API_BASE, getAuthToken } from '@/shared/config';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { useAppDispatch } from '@/shared/hooks';
import { fetchSession } from '@/shared/state/agentsSlice';
import { ensureEditAgentSession } from './api';
function tok(): string { try { return getAuthToken(); } catch { return ''; } }
// Boots (or reattaches) the sticky edit-agent session for a workflow and seeds
// the opener so the agent greets in the right mode. Returns the session id once
// ready. 'build' for a fresh workflow with no steps, 'modify' for an existing one.
export function useEditAgentSession(workflowId: string, seedMode: 'build' | 'modify'): string | null {
// Boots (or reattaches) the sticky edit-agent session for a workflow and returns
// its id once ready. The opener is deterministic: an existing workflow gets a
// fixed intro message from the backend; a brand-new build starts empty and the
// compose page shows its own starter prompts.
export function useEditAgentSession(workflowId: string): string | null {
const dispatch = useAppDispatch();
const [sessionId, setSessionId] = useState<string | null>(null);
const didInit = useRef(false);
const seeded = useRef(false);
useEffect(() => {
didInit.current = false;
seeded.current = false;
setSessionId(null);
}, [workflowId]);
@@ -34,24 +30,5 @@ export function useEditAgentSession(workflowId: string, seedMode: 'build' | 'mod
return () => { alive = false; };
}, [workflowId, dispatch]);
const session = useAppSelector((s) => (sessionId ? s.agents.sessions[sessionId] : undefined));
useEffect(() => {
if (!sessionId || !session || seeded.current) return;
if ((session.messages || []).length > 0) { seeded.current = true; return; }
seeded.current = true;
const seed = seedMode === 'build'
? 'Greet me briefly, then ask: "What should this workflow do?"'
: 'Greet me briefly, then ask: "How would you like to modify this workflow?"';
(async () => {
try {
await fetch(`${API_BASE}/agents/sessions/${encodeURIComponent(sessionId)}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(tok() ? { Authorization: `Bearer ${tok()}` } : {}) },
body: JSON.stringify({ prompt: seed, hidden: true }),
});
} catch { /* best-effort */ }
})();
}, [sessionId, session, seedMode]);
return sessionId;
}
+7 -2
View File
@@ -375,17 +375,22 @@ export const updateWorkflow = createAsyncThunk<
},
);
type CommitDraftArg = string | { id: string; model?: string };
type CommitDraftArg = string | { id: string; model?: string; keep_session?: boolean };
export const commitDraft = createAsyncThunk('workflows/commitDraft', async (arg: CommitDraftArg) => {
const id = typeof arg === 'string' ? arg : arg.id;
// Save-gated: the model the user settled on in the Edit Agent picker is applied
// to the workflow's run model here (Discard never reaches this path).
const model = typeof arg === 'string' ? undefined : arg.model;
// keep_session: the build flow auto-commits steps but must keep the chat open.
const keepSession = typeof arg === 'string' ? undefined : arg.keep_session;
const body: Record<string, unknown> = {};
if (model) body.model = model;
if (keepSession) body.keep_session = true;
const res = await fetch(`${API}/${id}/draft/commit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(model ? { model } : {}),
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`commit failed ${res.status}`);
return (await res.json()) as Workflow;