From a9496fd0f1a6e7cf5b1fa882ee009431d467de7e Mon Sep 17 00:00:00 2001 From: abccodes Date: Tue, 23 Jun 2026 00:01:41 -0700 Subject: [PATCH] [aidan] feat/compose: new-workflow landing page and auto-commit build flow --- backend/apps/workflows/models.py | 7 + backend/apps/workflows/workflows.py | 41 +++++- .../app/pages/Workflows/app/ComposeView.tsx | 134 ++++++++++++++++-- .../Workflows/app/useEditAgentSession.ts | 35 +---- frontend/src/shared/state/workflowsSlice.ts | 9 +- 5 files changed, 180 insertions(+), 46 deletions(-) diff --git a/backend/apps/workflows/models.py b/backend/apps/workflows/models.py index f701c3bd..08958fda 100644 --- a/backend/apps/workflows/models.py +++ b/backend/apps/workflows/models.py @@ -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 diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 698df960..da94c3b6 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -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() diff --git a/frontend/src/app/pages/Workflows/app/ComposeView.tsx b/frontend/src/app/pages/Workflows/app/ComposeView.tsx index 76b32706..b128bf74 100644 --- a/frontend/src/app/pages/Workflows/app/ComposeView.tsx +++ b/frontend/src/app/pages/Workflows/app/ComposeView.tsx @@ -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(null); const [name, setName] = useState(''); const [testing, setTesting] = useState(false); const [guardOpen, setGuardOpen] = useState(false); + // null = follow the auto open-on-first-message behavior; true/false = user override. + const [paneManual, setPaneManual] = useState(null); const created = useRef(false); + const nameFocused = useRef(false); + const handedOff = useRef(false); const workflow = useAppSelector((s) => (draftId ? s.workflows.items[draftId] : undefined)); + // Track the live title (agent rename) unless the user is editing the field, + // so the header never shows a stale name nor blurs it back over the rename. + useEffect(() => { + if (!nameFocused.current) setName(workflow?.title ?? ''); + }, [workflow?.title]); + // One unsaved draft per visit to "New". The backend hides unsaved drafts from // lists, so an abandoned one stays out of the way until GC. useEffect(() => { @@ -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 (
-
- Setting up your workflow… +
+ Setting up your workflow…
); @@ -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 ( <>
-
-
+
+ patch(workflow, { color: hex })} size={15} /> 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' }} /> - Draft + Draft +
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' }} + > + +
-
+
{sessionId ? :
} + {composeEmpty && ( +
+
+
+

Describe the workflow to automate

+
+
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.
+
+ {NEW_CHIPS.map((c) => ( + + ))} +
+
+ )}
{guardOpen && ( @@ -100,12 +204,15 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => { )}
-
+ {/* Hidden on the blank landing page; opens with a smooth width/fade once + the conversation starts. */} +
+
-
+
{!tested && (
@@ -113,7 +220,7 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => {
)}
-
+
); diff --git a/frontend/src/app/pages/Workflows/app/useEditAgentSession.ts b/frontend/src/app/pages/Workflows/app/useEditAgentSession.ts index 499ebd09..7df178d7 100644 --- a/frontend/src/app/pages/Workflows/app/useEditAgentSession.ts +++ b/frontend/src/app/pages/Workflows/app/useEditAgentSession.ts @@ -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(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; } diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index 002e9c7f..82c954df 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -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 = {}; + 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;