diff --git a/backend/apps/agents/core/models.py b/backend/apps/agents/core/models.py index 972e9beb..3aa704af 100644 --- a/backend/apps/agents/core/models.py +++ b/backend/apps/agents/core/models.py @@ -116,6 +116,12 @@ class AgentSession(BaseModel): dashboard_id: Optional[str] = None browser_id: Optional[str] = None parent_session_id: Optional[str] = None + # For workflow Test Agent sessions: "running" while the test drives the + # steps, then "complete"/"error" when it finishes. Drives the test card's + # footer (red Force Stop -> green "workflow complete, close"). None for + # ordinary sessions. A dedicated signal because per-turn status oscillates + # completed/running between steps, so it can't mark "the whole test done". + workflow_test_state: Optional[Literal["running", "complete", "error"]] = None # Browser memory signals, drive the subtle "remembered/learned" card chip so # the user feels the agent getting smarter without lifting a finger. memory_recalled: bool = False diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py index e5c9c064..578b362e 100644 --- a/backend/apps/agents/schedule_mcp_server.py +++ b/backend/apps/agents/schedule_mcp_server.py @@ -191,10 +191,12 @@ TOOLS = [ "name": "TestWorkflow", "description": ( "Spawn a sibling Test Agent that runs the workflow end-to-end " - "(with the latest persisted steps) so the user can watch it " - "work. Use after editing a step to verify the change. The Test " - "Agent renders as a sibling card on the dashboard with a " - "'Testing' arrow chip linking back to this workflow." + "(the current draft if one is being edited, else the live steps) " + "so the user can watch it work. Use after editing a step to " + "verify the change. The Test Agent renders as a sibling card on " + "the dashboard with a 'Testing' arrow chip linking back to this " + "workflow. After it finishes, call ReadTestTranscript to see what " + "it did." ), "inputSchema": { "type": "object", @@ -204,6 +206,22 @@ TOOLS = [ "required": ["workflow_id"], }, }, + { + "name": "ReadTestTranscript", + "description": ( + "Fetch the FULL chat transcript of the most recent Test Agent run " + "for this workflow: every message, tool call, and result. Call it " + "after TestWorkflow has finished to read exactly what the test did " + "and where it succeeded or failed, so you can decide what to change." + ), + "inputSchema": { + "type": "object", + "properties": { + "workflow_id": {"type": "string", "description": "The workflow whose latest test run to read."}, + }, + "required": ["workflow_id"], + }, + }, ] @@ -383,7 +401,9 @@ def handle_edit_step(args: dict) -> dict: cur = _call("GET", f"/{wid}") if "_error" in cur: return _err(cur["_error"]) - steps = cur.get("steps") or [] + # Edit against the pending draft when one exists (Edit-Agent flow); else + # the live steps (main-agent direct edit). + steps = cur.get("draft_steps") or cur.get("steps") or [] if idx < 0 or idx >= len(steps): return _err(f"step_idx {idx} out of range (workflow has {len(steps)} steps).") # Refresh the at-a-glance label so the card reflects the edit; a preserved @@ -409,7 +429,7 @@ def handle_add_step(args: dict) -> dict: cur = _call("GET", f"/{wid}") if "_error" in cur: return _err(cur["_error"]) - steps = list(cur.get("steps") or []) + steps = list(cur.get("draft_steps") or cur.get("steps") or []) new_step = {"id": "s" + uuid.uuid4().hex[:8], "text": text, "label": label} pos = args.get("position") if isinstance(pos, int) and 0 <= pos <= len(steps): @@ -433,7 +453,7 @@ def handle_delete_step(args: dict) -> dict: cur = _call("GET", f"/{wid}") if "_error" in cur: return _err(cur["_error"]) - steps = list(cur.get("steps") or []) + steps = list(cur.get("draft_steps") or cur.get("steps") or []) if idx < 0 or idx >= len(steps): return _err(f"step_idx {idx} out of range (workflow has {len(steps)} steps).") if len(steps) <= 1: @@ -453,7 +473,23 @@ def handle_test_workflow(args: dict) -> dict: if "_error" in r: return _err(r["_error"]) sid = r.get("session_id", "") - return _ok(f"Test Agent spawned (session {sid[:8]}...). It runs the latest workflow on the dashboard with a Testing arrow chip.") + return _ok(f"Test Agent spawned (session {sid[:8]}...). It runs the latest workflow on the dashboard with a Testing arrow chip. Call ReadTestTranscript once it finishes to see what it did.") + + +def handle_read_test_transcript(args: dict) -> dict: + wid = args.get("workflow_id") or "" + if not wid: + return _err("workflow_id is required.") + r = _call("GET", f"/{wid}/test-transcript") + if "_error" in r: + return _err(r["_error"]) + status = r.get("status") + if status == "none": + return _ok("No test has been run yet for this workflow. Call TestWorkflow first.") + if status == "unavailable": + return _ok("The most recent test session is no longer available. Run TestWorkflow again.") + transcript = r.get("transcript") or "(empty transcript)" + return _ok(f"Test Agent transcript (status: {status}):\n\n{transcript}") HANDLERS = { @@ -468,6 +504,7 @@ HANDLERS = { "AddWorkflowStep": handle_add_step, "DeleteWorkflowStep": handle_delete_step, "TestWorkflow": handle_test_workflow, + "ReadTestTranscript": handle_read_test_transcript, } diff --git a/backend/apps/workflows/models.py b/backend/apps/workflows/models.py index 50e2176b..5118b517 100644 --- a/backend/apps/workflows/models.py +++ b/backend/apps/workflows/models.py @@ -124,6 +124,12 @@ class Workflow(BaseModel): # Sticky session id for the embedded scheduling agent (the chat that # turns "every Wednesday at 1pm" into a permission-gated tool call). schedule_agent_session_id: Optional[str] = None + # Pending Edit-Agent draft of the steps. None = no draft in flight. Edits + # stage here and only land on `steps` when the user clicks Save; scheduled + # runs read `steps`, so a pending draft never affects a fire. + draft_steps: Optional[list[WorkflowStep]] = None + # Most recent Test Agent session for this workflow; read by ReadTestTranscript. + last_test_session_id: Optional[str] = None # Tool permissions the user answered once and we reuse on later runs so an # unattended scheduled fire doesn't stall waiting for someone to click. # tool_name -> decision. Only ordinary "ask" tools land here; sensitive diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index d097cccb..2ec39d92 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -12,6 +12,7 @@ from backend.apps.workflows.models import ( WorkflowCreate, WorkflowUpdate, WorkflowRun, + WorkflowStep, ) from backend.apps.workflows import storage, scheduler, executor, audit, escalation @@ -344,9 +345,52 @@ def _enriched(wf: Workflow) -> dict: "last_run_usd": round(last, 4), "fires_per_month": fires, } + base["has_draft"] = wf.draft_steps is not None return base +def p_render_test_transcript(messages: list, max_chars: int = 14000) -> str: + """Flatten a Test Agent's messages into a readable role-tagged transcript. + + Tail-biased cap so the end (where a run succeeds or blows up) always + survives, protecting the Edit Agent's context window. + """ + import json as json_mod + lines: list[str] = [] + for m in messages: + if getattr(m, "hidden", False): + continue + role = (getattr(m, "role", "") or "?").upper() + content = getattr(m, "content", "") + if isinstance(content, str): + text = content + elif isinstance(content, list): + parts: list[str] = [] + for b in content: + if not isinstance(b, dict): + parts.append(str(b)) + continue + kind = b.get("type") + if kind == "text": + parts.append(str(b.get("text") or "")) + elif kind == "tool_use": + parts.append(f"[tool {b.get('name')}] {json_mod.dumps(b.get('input') or {})[:300]}") + elif kind == "tool_result": + inner = b.get("content") + parts.append(f"[result] {inner if isinstance(inner, str) else json_mod.dumps(inner)[:300]}") + else: + parts.append(str(b)[:200]) + text = "\n".join(p for p in parts if p) + else: + text = "" + if text.strip(): + lines.append(f"{role}: {text.strip()}") + out = "\n\n".join(lines) + if len(out) > max_chars: + out = "...(earlier turns trimmed)...\n\n" + out[-max_chars:] + return out + + @workflows.router.get("/active") async def list_active_runs(): """Snapshot of currently-running workflow runs. Used by the tray and @@ -446,10 +490,37 @@ async def update_workflow( ) before = wf.model_dump(mode="json") data = body.model_dump(exclude_unset=True) + # 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 + # match is "steps present", not "steps only", so a mixed patch can never + # leak an edit onto the live steps (which commit would then clobber with + # 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: + 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) + wf.updated_at = datetime.now() + storage.save_workflow(wf) + enriched = _enriched(wf) + try: + from backend.apps.agents.core.ws_manager import ws_manager + await ws_manager.broadcast_global("workflow:updated", { + "workflow_id": wf.id, + "workflow": enriched, + }) + except Exception: + pass + return enriched for k, v in data.items(): setattr(wf, k, v) if "steps" in data: await p_relabel_changed_steps(wf, before.get("steps") or []) + p_prune_step_tool_usage(wf) wf.updated_at = datetime.now() if not wf.icon: wf.icon = _derive_icon(wf) @@ -486,120 +557,6 @@ async def delete_workflow(workflow_id: str): return {"ok": True} -@workflows.router.post("/{workflow_id}/propose-edit") -async def propose_edit(workflow_id: str, body: dict): - """Aux-LLM-propose a single-step edit from a natural-language request. - - Powers the Edit Agent chat (Image #38). Frontend hands us the user's - message, the current draft steps, optional failure-context (Fix-with- - Agent), AND the prior turns so the model has multi-turn memory. We - respond with a reply string PLUS, optionally, a `step_idx` + `new_text` - that the FE shows as a proposal card. - """ - wf = storage.get_workflow(workflow_id) - if not wf: - raise HTTPException(status_code=404, detail="Workflow not found") - message = (body or {}).get("message", "").strip() - steps_in = (body or {}).get("steps") or [] - context = (body or {}).get("context") or None - history = (body or {}).get("history") or [] - if not message or not isinstance(steps_in, list): - raise HTTPException(status_code=400, detail="Missing message or steps") - try: - from backend.apps.agents.providers.registry import resolve_aux_model - from backend.apps.settings.credentials import get_anthropic_client_for_model - from backend.apps.settings.settings import load_settings as _ls - except Exception: - raise HTTPException(status_code=500, detail="Aux model unavailable") - settings = _ls() - try: - aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku") - client = get_anthropic_client_for_model(settings, aux_model) - except Exception: - raise HTTPException(status_code=500, detail="Aux model unavailable") - import json, re - steps_lines = "\n".join( - f"{i+1}. {(s.get('label') or '').strip() or (s.get('text') or '')[:60]}: {(s.get('text') or '')}" - for i, s in enumerate(steps_in) - ) - fix_context = "" - if context and isinstance(context, dict): - fs = context.get("failed_step") - err = context.get("error") - if fs is not None and err: - fix_context = ( - f"\n\nFAILURE CONTEXT: Step {int(fs) + 1} failed on the most recent run. " - f"The error was: {err}\n" - f"Your proposed edit should specifically address that failure if possible." - ) - # Build history block so the model remembers prior turns. Each entry - # is {role, text}; we only carry assistant/user pairs (proposals get - # summarised inline so the assistant has context for follow-ups). - history_lines = [] - if isinstance(history, list): - for h in history[-12:]: - if not isinstance(h, dict): - continue - role = str(h.get("role") or "").strip().lower() - text = str(h.get("text") or "").strip() - if role in ("user", "assistant") and text: - history_lines.append(f"{role.capitalize()}: {text}") - history_block = ("\n\nPrior conversation:\n" + "\n".join(history_lines)) if history_lines else "" - - prompt = ( - "You are an Edit Agent helping the user iterate on a saved automation " - "workflow. The workflow's current steps are listed below. The user has " - "asked for a modification.\n\n" - "Respond with STRICT JSON, no prose, no fence. Schema:\n" - ' {"reply": string, ' - '"step_idx": int | null, ' - '"new_text": string | null, ' - '"explanation": string | null}\n\n' - "Rules:\n" - "- `reply` is a short conversational acknowledgement (1-2 sentences).\n" - "- If the user is asking a question or for clarification, set step_idx=null and new_text=null.\n" - "- If the user is asking to change a specific step, set step_idx (0-based) and new_text to the FULL replacement prompt for that step.\n" - "- `explanation` describes the change in user-facing terms.\n" - "- Never invent new steps. Never remove steps. Only edit existing ones.\n" - "- Use prior conversation context to disambiguate follow-ups (e.g. \"yes do that\" should reference the last proposal).\n\n" - f"Workflow steps:\n{steps_lines}{fix_context}{history_block}\n\n" - f"User: {message}" - ) - try: - resp = await client.messages.create( - model=aux_model, - max_tokens=400, - messages=[ - {"role": "user", "content": prompt}, - {"role": "assistant", "content": "{"}, - ], - ) - out = "" - if isinstance(resp.content, list): - for block in resp.content: - if getattr(block, "type", None) == "text": - out += getattr(block, "text", "") - raw = "{" + out.strip() if not out.strip().startswith("{") else out.strip() - m = re.search(r"\{.*\}", raw, flags=re.DOTALL) - if m: - raw = m.group(0) - data = json.loads(raw) - except Exception as e: - logger.warning("propose-edit: aux LLM failed: %s", e) - raise HTTPException(status_code=400, detail="Couldn't generate a proposal") - reply = str(data.get("reply") or "").strip()[:600] - step_idx = data.get("step_idx") - new_text = data.get("new_text") - explanation = str(data.get("explanation") or "").strip()[:600] - out: dict = {"reply": reply} - if isinstance(step_idx, int) and 0 <= step_idx < len(steps_in) and isinstance(new_text, str) and new_text.strip(): - out["step_idx"] = step_idx - out["new_text"] = new_text.strip() - if explanation: - out["explanation"] = explanation - return out - - @workflows.router.post("/{workflow_id}/edit-agent-session") async def edit_agent_session(workflow_id: str): """Create (or return existing) Edit Agent session for this workflow. @@ -617,19 +574,24 @@ async def edit_agent_session(workflow_id: str): wf = storage.get_workflow(workflow_id) if not wf: raise HTTPException(status_code=404, detail="Workflow not found") - # Track the edit-agent session id on the workflow record so the FE - # can find it after a reload. Persisted under a private namespace - # field added below; we attach it lazily so existing workflows don't - # need a migration. + # Reattach to an in-progress edit session (the user closed and reopened the + # card mid-edit): resume the existing draft, don't reset it. Save/Discard + # clear edit_agent_session_id, so once an edit is finished the next entry + # falls through to the fresh path below: a brand-new chat against the + # current committed workflow. existing_id = getattr(wf, "edit_agent_session_id", None) or None if existing_id: - from backend.apps.agents.agent_manager import agent_manager - if existing_id in agent_manager.sessions: - return {"session_id": existing_id} - # In-memory miss but on disk it's still valid; fall through to - # rehydrate via launch_agent OR return id for the FE to fetch. + if wf.draft_steps is None: + wf.draft_steps = list(wf.steps) + storage.save_workflow(wf) return {"session_id": existing_id} + # Fresh edit session: snapshot a clean draft from the current committed + # steps so the Edit Agent's edits stage there (never the live workflow) + # until the user clicks Save, and Discard reverts to exactly this. + wf.draft_steps = list(wf.steps) + storage.save_workflow(wf) + from backend.apps.agents.core.models import AgentConfig from backend.apps.agents.agent_manager import agent_manager steps_lines = "\n".join(f"{i+1}. {(s.label or '').strip() or (s.text or '')[:60]}\n Prompt: {s.text}" for i, s in enumerate(wf.steps)) @@ -652,8 +614,10 @@ async def edit_agent_session(workflow_id: str): "1. When the user describes a change, briefly confirm what you'll do.\n" "2. If you need to look at files / search / activate an MCP / etc. to " "verify your idea, use your tools.\n" - "3. To change the workflow's steps, call the matching tool; each " - "persists immediately and refreshes the user's card live:\n" + "3. To change the workflow's steps, call the matching tool. Your edits " + "STAGE to a pending draft and are fully reversible; nothing touches the " + "live workflow until the user clicks Save. The card shows your draft as " + "you go:\n" " - EditWorkflowStep(workflow_id, step_idx, new_text, new_label) to " "rewrite a step. ALWAYS pass new_label (a fresh 3-5 word summary) so " "the card reflects the change instead of the stale old label.\n" @@ -661,8 +625,11 @@ async def edit_agent_session(workflow_id: str): " - DeleteWorkflowStep(workflow_id, step_idx) to remove one.\n" " Confirm via AskUserQuestion FIRST if there's any ambiguity.\n" "4. Call TestWorkflow(workflow_id) to spawn a sibling Test Agent that " - "runs the latest version end-to-end. Use this after a change to verify " - "it works.\n\n" + "runs the current draft end-to-end. Use this after a change to verify " + "it works.\n" + "5. After a test finishes, call ReadTestTranscript(workflow_id) to read " + "the Test Agent's full transcript and diagnose what happened before " + "proposing further edits.\n\n" "Be brief in your replies. Don't restate the whole workflow back; the " "user can see it. Just confirm what changed and what you're doing.\n" "Write like a normal chat: plain conversational sentences. When you " @@ -689,6 +656,80 @@ async def edit_agent_session(workflow_id: str): return {"session_id": session.id} +async def p_end_edit_session(wf) -> None: + """End a workflow's Edit-Agent session (after Save or Discard) so the next + edit opens a brand-new chat against the current workflow instead of + resuming the old conversation that still references the dropped edits.""" + sid = getattr(wf, "edit_agent_session_id", None) + wf.edit_agent_session_id = None + if not sid: + return + try: + from backend.apps.agents.agent_manager import agent_manager + await agent_manager.close_session(sid) + except Exception: + logger.debug("could not close edit session %s", sid, exc_info=True) + + +@workflows.router.post("/{workflow_id}/draft/commit") +async def commit_draft(workflow_id: str): + """Commit the Edit-Agent draft: draft_steps become the live steps.""" + wf = storage.get_workflow(workflow_id) + if not wf: + raise HTTPException(status_code=404, detail="Workflow not found") + if wf.draft_steps is None: + await p_end_edit_session(wf) + storage.save_workflow(wf) + return _enriched(wf) + before = wf.model_dump(mode="json") + wf.steps = wf.draft_steps + wf.draft_steps = None + await p_relabel_changed_steps(wf, before.get("steps") or []) + p_prune_step_tool_usage(wf) + wf.updated_at = datetime.now() + if not wf.icon: + wf.icon = _derive_icon(wf) + wf.next_run_at = scheduler.compute_next_fire(wf) if wf.schedule.enabled else None + await p_end_edit_session(wf) + storage.save_workflow(wf) + audit.log_change(wf.id, "user", before, wf.model_dump(mode="json")) + scheduler.kick() + enriched = _enriched(wf) + try: + from backend.apps.agents.core.ws_manager import ws_manager + await ws_manager.broadcast_global("workflow:updated", { + "workflow_id": wf.id, + "workflow": enriched, + }) + except Exception: + pass + return enriched + + +@workflows.router.post("/{workflow_id}/draft/discard") +async def discard_draft(workflow_id: str): + """Throw away the Edit-Agent draft; the live workflow is untouched.""" + wf = storage.get_workflow(workflow_id) + if not wf: + raise HTTPException(status_code=404, detail="Workflow not found") + # Discard wipes the whole edit session: drop the draft AND end the chat, so + # reopening Edit is a fresh conversation against the current committed steps. + wf.draft_steps = None + await p_end_edit_session(wf) + p_prune_step_tool_usage(wf) + storage.save_workflow(wf) + enriched = _enriched(wf) + try: + from backend.apps.agents.core.ws_manager import ws_manager + await ws_manager.broadcast_global("workflow:updated", { + "workflow_id": wf.id, + "workflow": enriched, + }) + except Exception: + pass + return enriched + + @workflows.router.post("/{workflow_id}/test-run") async def test_run_workflow(workflow_id: str, body: dict): """Spawn a Test Agent session running the (possibly-unsaved) draft. @@ -705,16 +746,29 @@ async def test_run_workflow(workflow_id: str, body: dict): if not wf: raise HTTPException(status_code=404, detail="Workflow not found") draft_steps = (body or {}).get("steps") - steps_texts: list[str] + step_entries: list[WorkflowStep] if isinstance(draft_steps, list) and draft_steps: - steps_texts = [str(s.get("text") or "") for s in draft_steps if isinstance(s, dict) and s.get("text")] + step_entries = [ + WorkflowStep(**s) + for s in draft_steps + if isinstance(s, dict) and str(s.get("text") or "").strip() + ] else: - steps_texts = [s.text for s in wf.steps if s.text and s.text.strip()] - if not steps_texts: + # No explicit override: prefer the pending draft so a mid-edit + # TestWorkflow call (from the Edit Agent itself) tests the draft. + src = wf.draft_steps if wf.draft_steps is not None else wf.steps + step_entries = [s for s in src if s.text and s.text.strip()] + if not step_entries: raise HTTPException(status_code=400, detail="Workflow has no steps to test") from backend.apps.agents.core.models import AgentConfig - from backend.apps.agents.agent_manager import agent_manager + from backend.apps.agents.agent_manager import ( + agent_manager, + clear_workflow_approval_memory, + get_workflow_step_usage, + set_workflow_approval_memory, + set_workflow_approval_step, + ) from backend.apps.workflows import executor config = AgentConfig( @@ -729,22 +783,82 @@ async def test_run_workflow(workflow_id: str, body: dict): dashboard_id=wf.dashboard_id, ) session = await agent_manager.launch_agent(config) + session.workflow_test_state = "running" + set_workflow_approval_memory( + session.id, + decisions=dict(wf.remembered_approvals), + step_usage={sid: dict(tools) for sid, tools in wf.step_tool_usage.items()}, + remember=executor.p_make_remember_approval(wf.id), + ask_timeout=600.0, + ) + # Point the workflow at its latest test session so ReadTestTranscript can + # fetch the transcript on demand. + try: + wf.last_test_session_id = session.id + storage.save_workflow(wf) + except Exception: + logger.debug("could not persist last_test_session_id", exc_info=True) + + async def _set_test_state(state: str) -> None: + sess = agent_manager.sessions.get(session.id) + if sess is not None: + sess.workflow_test_state = state + try: + from backend.apps.agents.core.ws_manager import ws_manager + await ws_manager.broadcast_global("agent:test_state", { + "session_id": session.id, + "state": state, + }) + except Exception: + pass async def _drive_test() -> None: + final = "complete" try: - for step in steps_texts: - await agent_manager.send_message(session.id, step) + for step in step_entries: + set_workflow_approval_step(session.id, step.id) + await agent_manager.send_message(session.id, step.text) await executor._await_session_idle(session.id) sess_state = agent_manager.sessions.get(session.id) if sess_state is not None and getattr(sess_state, "status", None) == "error": + final = "error" return except Exception: logger.exception("test-run drive loop failed") + final = "error" + finally: + try: + executor.p_persist_step_tool_usage(wf.id, get_workflow_step_usage(session.id)) + except Exception: + logger.exception("test-run step usage persist failed") + set_workflow_approval_step(session.id, None) + clear_workflow_approval_memory(session.id) + await _set_test_state(final) asyncio.create_task(_drive_test()) return {"session_id": session.id} +@workflows.router.get("/{workflow_id}/test-transcript") +async def test_transcript(workflow_id: str): + """Full transcript of the workflow's most recent Test Agent session. + + Backs the Edit Agent's ReadTestTranscript tool: it needs the Test Agent's + entire chat history (not just a final output) to diagnose a run. + """ + wf = storage.get_workflow(workflow_id) + if not wf: + raise HTTPException(status_code=404, detail="Workflow not found") + if not wf.last_test_session_id: + return {"transcript": "", "status": "none"} + from backend.apps.agents.agent_manager import agent_manager + sess = agent_manager.sessions.get(wf.last_test_session_id) + if sess is None: + return {"transcript": "", "status": "unavailable"} + transcript = p_render_test_transcript(getattr(sess, "messages", []) or []) + return {"transcript": transcript, "status": getattr(sess, "status", "") or ""} + + @workflows.router.post("/{workflow_id}/schedule-agent-session") async def schedule_agent_session(workflow_id: str): """Create (or return existing) embedded scheduling-agent session. diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index b85750ab..3e05ccf4 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -55,11 +55,13 @@ import MessageActionBar from './shell/MessageActionBar'; import ToolCallBubble, { ToolPair } from './tool-bubbles/ToolCallBubble'; import ToolGroupBubble, { RenderItem, ToolGroup, isToolGroup, isToolPair } from './tool-bubbles/ToolGroupBubble'; import ApprovalBar, { BatchApprovalBar } from './shell/ApprovalBar'; +import ForceStopAgentBar from './ForceStopAgentBar'; import ChatInput, { ChatInputHandle } from './ChatInput'; import ContextDrawer from './shell/ContextDrawer'; import { ErrorSlime } from '@/app/components/feedback/ErrorSlime'; import { ContextPath } from '@/app/components/editor/DirectoryBrowser'; -import { setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards } from '@/shared/state/dashboardLayoutSlice'; +import { setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, removeCard } from '@/shared/state/dashboardLayoutSlice'; +import { setCardSidecar, commitDraft, updateWorkflowCard } from '@/shared/state/workflowsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; const CONTEXT_WINDOWS: Record = { @@ -255,6 +257,18 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose }; const { id: routeId } = useParams<{ id: string }>(); const id = sessionIdProp || routeId; + // A card linked as a workflow sidecar (Test Agent, or a watched run) swaps + // its composer for a Force Stop button: continuing the chat is meaningless, + // but killing the run is the common need. Once a Test Agent finishes, the + // button flips to a green "close" (see workflow_test_state + ForceStopAgentBar). + const linkedWorkflowId = useAppSelector((s) => { + const found = Object.values(s.workflows.openCards).find( + (cd) => cd.sidecarSessionId === id && (cd.sidecarKind === 'testing' || cd.sidecarKind === 'watching'), + ); + return found?.workflowId ?? null; + }); + const isStoppableSidecar = !!linkedWorkflowId; + const testState = useAppSelector((s) => (id ? s.agents.sessions[id]?.workflow_test_state : null) ?? null); const navigate = useNavigate(); const dispatch = useAppDispatch(); const session = useAppSelector((state) => (id ? state.agents.sessions[id] : undefined)); @@ -892,6 +906,23 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose dispatch(stopAgent({ sessionId: id })); }, [id, dispatch]); + // Finished Test Agent card: drop the tether + remove this card, and either + // commit the workflow draft (Save, same as the edit card's "save now") or + // leave the draft untouched so the user keeps editing. + const onTestContinueEditing = useCallback(() => { + if (linkedWorkflowId) dispatch(setCardSidecar({ workflowId: linkedWorkflowId, sessionId: null, kind: null })); + if (id) dispatch(removeCard(id)); + }, [linkedWorkflowId, id, dispatch]); + + const onTestSaveWorkflow = useCallback(() => { + if (linkedWorkflowId) { + dispatch(commitDraft(linkedWorkflowId)); + dispatch(updateWorkflowCard({ workflowId: linkedWorkflowId, patch: { view: 'saved' } })); + dispatch(setCardSidecar({ workflowId: linkedWorkflowId, sessionId: null, kind: null })); + } + if (id) dispatch(removeCard(id)); + }, [linkedWorkflowId, id, dispatch]); + const handleResume = useCallback(() => { if (!id) return; setShowResumeBubble(false); @@ -2172,24 +2203,28 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose ); })()} - + {isStoppableSidecar ? ( + + ) : ( + + )} )} diff --git a/frontend/src/app/pages/AgentChat/ForceStopAgentBar.tsx b/frontend/src/app/pages/AgentChat/ForceStopAgentBar.tsx new file mode 100644 index 00000000..c78ee544 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ForceStopAgentBar.tsx @@ -0,0 +1,79 @@ +// Footer for an agent card that's a workflow sidecar (Test Agent, or a +// watched run). It replaces the normal composer: while the agent runs you +// can't meaningfully chat, but you often want to kill it. Once a Test Agent +// finishes, the red "Force Stop" becomes the decision point: keep editing the +// workflow, or save the edits (which commits the draft and closes this card). + +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import StopCircleOutlined from '@mui/icons-material/StopCircleOutlined'; +import CheckCircleOutlineRounded from '@mui/icons-material/CheckCircleOutlineRounded'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +interface Props { + onStop: () => void; + onSaveWorkflow: () => void; + onContinueEditing: () => void; + // 'running' while a test drives the steps; 'complete'/'error' when done. + // null for a watched (non-test) run, which only ever offers Force Stop. + testState?: 'running' | 'complete' | 'error' | null; +} + +export default function ForceStopAgentBar({ onStop, onSaveWorkflow, onContinueEditing, testState }: Props) { + const c = useClaudeTokens(); + const done = testState === 'complete' || testState === 'error'; + + if (!done) { + return ( + + + + Force Stop Agent + + + ); + } + + const tone = testState === 'complete' ? c.status.success : c.status.error; + return ( + + + + {testState === 'complete' ? 'Test finished' : 'Test stopped'} + + + + Continue editing + + + Save workflow + + + + ); +} diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 6cdc1521..3bba341f 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -40,7 +40,7 @@ import { useDashboardActive } from '@/shared/hooks/useDashboardActive'; import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough'; import { useStreamingMessage } from '@/shared/state/streamingSlice'; import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canvasInteractionState'; -import { openWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice'; +import { createWorkflow, openWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice'; import { addWorkflowCard, setWorkflowCardPosition, setWorkflowCardSize } from '@/shared/state/dashboardLayoutSlice'; import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined'; import { getAgentWorkTime, fmtSeconds } from '@/shared/agentWorkTime'; @@ -252,6 +252,12 @@ const AgentCard: React.FC = ({ const hasApiKey = !!useAppSelector((s) => s.settings.data.anthropic_api_key); const modelsByProvider = useAppSelector((s) => s.models.byProvider); const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds); + // Convert-to-workflow now persists straight away (no preview interstitial), + // so the source chat's model/mode get overridden by the user's configured + // default the same way the old PreviewView did it. + const defaultModel = useAppSelector((s) => s.settings.data.default_model); + const defaultMode = useAppSelector((s) => s.settings.data.default_mode); + const [converting, setConverting] = useState(false); // Hide the "Convert to workflow" button when this chat is already // entangled with a workflow (Image #44 note). Two cases: // (a) The session is one of a workflow's runner sessions, OR @@ -261,6 +267,9 @@ const AgentCard: React.FC = ({ const workflowRunsMap = useAppSelector((s) => s.workflows.runs); const workflowItems = useAppSelector((s) => s.workflows.items); const isWorkflowRunnerSession = useMemo(() => { + // A Test Agent (spawned to validate a workflow draft) isn't a chat to + // convert; it carries workflow_test_state. + if (session.workflow_test_state) return true; for (const arr of Object.values(workflowRunsMap || {})) { for (const r of arr || []) { if (r.session_id === session.id) return true; @@ -270,7 +279,7 @@ const AgentCard: React.FC = ({ if (wf.source_session_id === session.id) return true; } return false; - }, [workflowRunsMap, workflowItems, session.id]); + }, [workflowRunsMap, workflowItems, session.id, session.workflow_test_state]); // Curated picker label with a tidy fallback for unknowns. const friendlyModelLabel = useMemo(() => { const value = session.model; @@ -854,44 +863,42 @@ const AgentCard: React.FC = ({ { + onClick={async (e) => { e.stopPropagation(); + if (converting) return; const steps = extractStepsFromSession(session); if (steps.length === 0) return; - const draft: Partial = { + setConverting(true); + // Persist up front while the chat card stays put, then + // swap this card's slot to the saved workflow. No preview + // interstitial: the steps already exist, so the saved card + // (with its one-time schedule nudge) is all we need. On a + // failed create the chat card stays so the user can retry. + const result = await dispatch(createWorkflow({ title: session.name || 'New workflow', description: '', steps, source_session_id: session.id, - dashboard_id: session.dashboard_id || null, - model: session.model, - mode: session.mode, - provider: session.provider, - }; - const tempId = `draft-${session.id}`; - // The OG chat card BECOMES the workflow card: capture - // its position + size, remove the chat card, and drop - // the workflow card in the same physical slot. The - // chat session itself stays accessible via History. - // Per Image #61 / #62: no tether arrow, no second - // card alongside. - // Capture this card's current position/size, drop the - // workflow card in the same slot, then remove the - // source chat card. - dispatch(addWorkflowCard({ - workflowId: tempId, - sourceSessionId: null, - expandedSessionIds, - })); - dispatch(setWorkflowCardPosition({ workflowId: tempId, x: cardX, y: cardY })); - dispatch(setWorkflowCardSize({ workflowId: tempId, width: cardWidth, height: cardHeight })); + use_synced_prompt: true, + model: defaultModel || session.model, + mode: defaultMode || session.mode, + } as Partial)); + if (!createWorkflow.fulfilled.match(result)) { + setConverting(false); + return; + } + const wf = result.payload; + // The OG chat card BECOMES the workflow card: same slot, + // same size, no tether arrow (Image #61 / #62). The chat + // session stays accessible via History. + dispatch(addWorkflowCard({ workflowId: wf.id, sourceSessionId: null, expandedSessionIds })); + dispatch(setWorkflowCardPosition({ workflowId: wf.id, x: cardX, y: cardY })); + dispatch(setWorkflowCardSize({ workflowId: wf.id, width: cardWidth, height: cardHeight })); dispatch(removeCard(session.id)); - dispatch(openWorkflowCard({ - workflowId: tempId, - sourceSessionId: null, - view: 'preview', - draft, - })); + // showScheduleNudge: the one-shot "Schedule this workflow?" + // prompt. "Not now" on it reopens this very chat (the + // session lives on via workflow.source_session_id). + dispatch(openWorkflowCard({ workflowId: wf.id, sourceSessionId: null, view: 'saved', draft: null, showScheduleNudge: true })); }} onMouseDown={(e) => e.stopPropagation()} sx={{ @@ -902,12 +909,13 @@ const AgentCard: React.FC = ({ fontSize: '0.78rem', fontWeight: 700, px: 1.1, py: 0.5, borderRadius: `${c.radius.md}px`, - cursor: 'pointer', + cursor: converting ? 'wait' : 'pointer', + opacity: converting ? 0.7 : 1, '&:hover': { filter: 'brightness(1.05)' }, }} > - Convert to workflow + {converting ? 'Converting…' : 'Convert to workflow'} )} diff --git a/frontend/src/app/pages/Workflows/EditAgentSavePopovers.tsx b/frontend/src/app/pages/Workflows/EditAgentSavePopovers.tsx new file mode 100644 index 00000000..9438f286 --- /dev/null +++ b/frontend/src/app/pages/Workflows/EditAgentSavePopovers.tsx @@ -0,0 +1,91 @@ +// The two popovers in the Edit Agent Save flow (Image #50): on Save we ask +// "test before finishing?"; once a test ends we ask "Confirm save". Both are +// presentational and reuse the ScheduleThisPopover Popover styling so the +// modify flow feels of a piece with scheduling. + +import React from 'react'; +import Popover from '@mui/material/Popover'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +interface OptionProps { + label: string; + hint: string; + onClick: () => void; + accent?: boolean; + danger?: boolean; +} + +function OptionRow({ label, hint, onClick, accent, danger }: OptionProps) { + const c = useClaudeTokens(); + const labelColor = danger ? c.status.error : accent ? c.accent.primary : c.text.primary; + return ( + + {label} + {hint} + + ); +} + +// 'testing' is a live state with no popover (the Test Agent card owns the +// post-test decision); the popover only shows for 'ask-test' and 'confirm-discard'. +export type SavePhase = 'idle' | 'ask-test' | 'testing' | 'confirm-discard'; + +interface Props { + phase: SavePhase; + anchorEl: HTMLElement | null; + onClose: () => void; + onSaveNow: () => void; + onRunTest: () => void; + onConfirmDiscard: () => void; +} + +export default function EditAgentSavePopovers({ + phase, anchorEl, onClose, onSaveNow, onRunTest, onConfirmDiscard, +}: Props) { + const c = useClaudeTokens(); + const heading = (text: string) => ( + + {text} + + ); + return ( + + {phase === 'ask-test' && ( + <> + {heading('BEFORE YOU SAVE')} + + Want to test the workflow before finishing your edits? + + + + + )} + {phase === 'confirm-discard' && ( + <> + {heading('DISCARD CHANGES')} + + Throw away every edit from this session? This can't be undone. + + + + + )} + + ); +} diff --git a/frontend/src/app/pages/Workflows/EditAgentView.tsx b/frontend/src/app/pages/Workflows/EditAgentView.tsx index 03f8038e..da5cf65f 100644 --- a/frontend/src/app/pages/Workflows/EditAgentView.tsx +++ b/frontend/src/app/pages/Workflows/EditAgentView.tsx @@ -6,18 +6,20 @@ // fills the rest. In fix mode (Image #48) the first message is a // failure-context prompt and a red prefix card renders above the chat. -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import BuildRounded from '@mui/icons-material/BuildRounded'; import KeyboardArrowDownRounded from '@mui/icons-material/KeyboardArrowDownRounded'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { clearFixSeed, type Workflow } from '@/shared/state/workflowsSlice'; +import { clearFixSeed, commitDraft, discardDraft, setCardSidecar, updateWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice'; import { fetchSession } from '@/shared/state/agentsSlice'; import { API_BASE, getAuthToken } from '@/shared/config'; import StepList from './StepList'; import AgentChat from '@/app/pages/AgentChat/AgentChat'; +import { useOpenSidecar } from './WorkflowCardLiveViews'; +import EditAgentSavePopovers, { type SavePhase } from './EditAgentSavePopovers'; interface Props { workflow: Workflow; @@ -43,9 +45,15 @@ export default function EditAgentView({ workflow, steps, isFixMode = false, onEd // (without going through Fix-with-Agent) doesn't re-show the prefix. useEffect(() => () => { dispatch(clearFixSeed(workflow.id)); }, [dispatch, workflow.id]); - // Spawn (or reattach to) the sticky Edit Agent session on mount. + // On entering edit, ALWAYS hit edit-agent-session once (not just when the + // session is missing): the call reattaches the sticky chat AND, on the + // backend, snapshots a fresh draft from the current committed steps. If we + // skipped it when a session already existed (re-edit), the draft would never + // be created and edits would leak onto the live workflow. + const didInit = useRef(false); useEffect(() => { - if (editSessionId) return; + if (didInit.current) return; + didInit.current = true; let alive = true; (async () => { try { @@ -63,7 +71,7 @@ export default function EditAgentView({ workflow, steps, isFixMode = false, onEd } catch { /* best-effort */ } })(); return () => { alive = false; }; - }, [editSessionId, workflow.id, dispatch]); + }, [workflow.id, dispatch]); // First-turn seed: post the hidden opener so the agent's first reply // is the friendly "How would you like to modify the workflow..." prompt @@ -96,30 +104,133 @@ export default function EditAgentView({ workflow, steps, isFixMode = false, onEd })(); }, [editSessionId, editSession, seedSent, isFixMode, fixSeed, steps.length]); + // Save flow: Save -> "test first?" popover -> optional test run -> "confirm + // save" popover. The step edits are staged in workflow.draft_steps; commit + // makes them live, discard throws them away. + const openSidecar = useOpenSidecar(workflow.id); + const [savePhase, setSavePhase] = useState('idle'); + const [saveAnchorEl, setSaveAnchorEl] = useState(null); + const [testSessionId, setTestSessionId] = useState(null); + const draftSteps = workflow.draft_steps ?? steps; + // A draft always exists in edit mode (we snapshot on entry), so only flag + // "unsaved" once the draft actually diverges from the committed steps. + const hasChanges = workflow.draft_steps != null && JSON.stringify(workflow.draft_steps) !== JSON.stringify(workflow.steps); + + const toSaved = useCallback(() => { + dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } })); + }, [dispatch, workflow.id]); + + const clearSidecar = useCallback(() => { + dispatch(setCardSidecar({ workflowId: workflow.id, sessionId: null, kind: null })); + }, [dispatch, workflow.id]); + + const stopTest = useCallback(async () => { + if (!testSessionId) return; + try { + const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + await fetch(`${API_BASE}/agents/sessions/${encodeURIComponent(testSessionId)}/stop`, { + method: 'POST', headers: tok ? { Authorization: `Bearer ${tok}` } : {}, + }); + } catch { /* best-effort */ } + }, [testSessionId]); + + const onSaveClick = useCallback((e: React.MouseEvent) => { + setSaveAnchorEl(e.currentTarget); + setSavePhase('ask-test'); + }, []); + + const onSaveNow = useCallback(async () => { + setSavePhase('idle'); + await dispatch(commitDraft(workflow.id)); + toSaved(); + }, [dispatch, workflow.id, toSaved]); + + const onRunTest = useCallback(async () => { + try { + const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflow.id)}/test-run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) }, + body: JSON.stringify({ steps: draftSteps }), + }); + if (!res.ok) { setSavePhase('idle'); return; } + const data = await res.json(); + const sid = data?.session_id as string | undefined; + if (!sid) { setSavePhase('idle'); return; } + setTestSessionId(sid); + // The Test Agent card now owns the post-test decision (Continue editing / + // Save workflow) in its own footer, so just close this popover. + setSavePhase('idle'); + await openSidecar(sid, 'testing'); + } catch { setSavePhase('idle'); } + }, [workflow.id, draftSteps, openSidecar]); + + const onDiscardClick = useCallback((e: React.MouseEvent) => { + setSaveAnchorEl(e.currentTarget); + setSavePhase('confirm-discard'); + }, []); + + const onConfirmDiscard = useCallback(async () => { + setSavePhase('idle'); + if (testSessionId) { await stopTest(); clearSidecar(); } + await dispatch(discardDraft(workflow.id)); + toSaved(); + }, [dispatch, workflow.id, testSessionId, stopTest, clearSidecar, toSaved]); + return ( {/* The "tab with the workflow inside": a collapsible strip that peeks at the live steps (they update as the agent edits) without leaving the chat. The header's Save Workflow button drops back to the card. */} - setStepsOpen((x) => !x)} - role="button" - sx={{ - display: 'inline-flex', alignItems: 'center', gap: 0.25, cursor: 'pointer', - fontSize: '0.82rem', fontWeight: 600, color: c.text.secondary, - '&:hover': { color: c.text.primary }, - }}> - - Workflow ({steps.length} step{steps.length === 1 ? '' : 's'}) + + setStepsOpen((x) => !x)} + role="button" + sx={{ + display: 'inline-flex', alignItems: 'center', gap: 0.25, cursor: 'pointer', + fontSize: '0.82rem', fontWeight: 600, color: c.text.secondary, + '&:hover': { color: c.text.primary }, + }}> + + Workflow ({draftSteps.length} step{draftSteps.length === 1 ? '' : 's'}) + + {hasChanges && ( + · unsaved + )} + + + Discard + + + Save + {stepsOpen && ( {isFixMode && fixSeed && setFixPrefixExpanded((x) => !x)} />} - + )} + setSavePhase('idle')} + onSaveNow={onSaveNow} + onRunTest={onRunTest} + onConfirmDiscard={onConfirmDiscard} + /> {/* The card IS the chat. AgentChat owns the composer + message list + tool-call cards. Negative margins cancel the card body's p:2 so the thread runs edge-to-edge like a normal chat (it supplies its own px). */} diff --git a/frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx index 5bec3060..9ff9543a 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx @@ -32,7 +32,7 @@ import StepList, { type StepStatus } from './StepList'; // Helper: open a session next to the workflow card AND mark the card as // sidecar-linked so the footer flips to Stop Watching/Viewing and the // dashboard draws an arrow chip between the two cards. -function useOpenSidecar(workflowId: string) { +export function useOpenSidecar(workflowId: string) { const dispatch = useAppDispatch(); const wfCardPos = useAppSelector((s) => s.dashboardLayout.workflowCards[workflowId]); const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds); @@ -158,7 +158,7 @@ export function RunningView({ workflow, steps, runs, mode = 'card' }: { const activeSubtitle = run?.last_tool_label || null; const activeDuration = formatLiveDuration(run); - const isLinked = mode === 'sidecar-linked' && card?.sidecarKind === 'watching'; + const isLinked = mode === 'sidecar-linked' && (card?.sidecarKind === 'watching' || card?.sidecarKind === 'testing'); const onStop = useCallback(async () => { if (!runId) return; diff --git a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx index edb7bc06..74f9df0a 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx @@ -10,6 +10,7 @@ import EditOutlined from '@mui/icons-material/EditOutlined'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { + closeWorkflowCard, createWorkflow, toggleExpandedStep, updateWorkflow, @@ -17,6 +18,8 @@ import { type Workflow, type WorkflowRun, } from '@/shared/state/workflowsSlice'; +import { placeCard, removeWorkflowCard } from '@/shared/state/dashboardLayoutSlice'; +import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice'; import { CostChip, humanDuration, routingFor, StreakBadge } from './workflowVisuals'; import StepList from './StepList'; @@ -146,9 +149,9 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, }, [dispatch, workflowId, liveDraft]); // Both buttons persist the workflow; the only difference is where they land. - // Ignore = save and show the saved card. Schedule = save then open the - // natural-language scheduling composer. (Ignore used to delete the card, - // which surprised people; the schedule prompt is optional, the workflow isn't.) + // "Not now" = save and show the saved card. Schedule = save then open the + // natural-language scheduling composer. (The schedule prompt is optional, + // the workflow isn't, so neither button discards anything.) const saveWorkflow = useCallback(async (): Promise => { const result = await dispatch(createWorkflow({ title, @@ -222,7 +225,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, opacity: busy ? 0.6 : 1, '&:hover': { color: c.text.primary }, }}> - Ignore + Not now { - dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'scheduling' } })); + dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'scheduling', showScheduleNudge: false } })); }, [dispatch, workflow.id]); const onToggleStep = useCallback((stepId: string) => { dispatch(toggleExpandedStep({ workflowId: workflow.id, stepId })); @@ -308,8 +311,31 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo } }, [deletingStepId, dispatch, workflow.id, workflow.steps, workflow.updated_at]); + // "Not now" on the post-convert nudge doesn't dump you on a near-identical + // saved card: the workflow is already saved (find it in the hub), so we drop + // its card and reopen the chat it came from, right in the same slot. + const wfCardPos = useAppSelector((s) => s.dashboardLayout.workflowCards[workflow.id]); + const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds); + const sourceId = workflow.source_session_id || null; + const sourceExists = useAppSelector((s) => (sourceId ? !!s.agents.sessions[sourceId] : false)); + const onNotNow = useCallback(() => { + if (sourceId && sourceExists && wfCardPos) { + const { x, y, width, height } = wfCardPos; + dispatch(removeWorkflowCard(workflow.id)); + dispatch(closeWorkflowCard(workflow.id)); + dispatch(placeCard({ sessionId: sourceId, x, y, width, height, expandedSessionIds })); + dispatch(setPendingFocusAgentId(sourceId)); + } else { + // No chat to fall back to (rare): just retire the prompt in place. + dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { showScheduleNudge: false } })); + } + }, [dispatch, sourceId, sourceExists, wfCardPos, expandedSessionIds, workflow.id]); + const scheduleLine = workflow.schedule.enabled ? describeSchedule(workflow) : 'Schedule this workflow'; const scheduleClickable = !workflow.schedule.enabled; + // One-shot prompt right after a convert; hub-opened cards never set the flag, + // so they fall straight to the quiet schedule line below. + const showNudge = !!card?.showScheduleNudge && !workflow.schedule.enabled; return ( @@ -322,7 +348,57 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo onDeleteStep={onDeleteStep} /> + {showNudge && ( + + + + + + + Schedule this workflow? + + + You can have workflows run on a recurring basis, automatically. + + + + Not now + + + Schedule Workflow + + + + + )} + {showNudge ? : ( {scheduleLine} + )} red Force Stop; complete/error -> green close). */ + workflow_test_state?: 'running' | 'complete' | 'error' | null; provider: string; model: string; mode: string; @@ -703,6 +706,14 @@ const agentsSlice = createSlice({ } }, + setSessionTestState( + state, + action: PayloadAction<{ sessionId: string; state: 'running' | 'complete' | 'error' }> + ) { + const session = state.sessions[action.payload.sessionId]; + if (session) session.workflow_test_state = action.payload.state; + }, + setSessionConnState( state, action: PayloadAction<{ sessionId: string; state: 'live' | 'reconnecting' }> @@ -1408,6 +1419,7 @@ export const { updateSession, updateSessionStatus, setSessionConnState, + setSessionTestState, addMessage, addOptimisticMessage, markOptimisticFailed, diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index 15d44048..b5f93da4 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -81,6 +81,12 @@ export interface Workflow { edit_agent_session_id?: string | null; /** Sticky session id for the embedded scheduling agent (cadence -> gated tool call). */ schedule_agent_session_id?: string | null; + /** Pending Edit-Agent draft of the steps; present only while editing. */ + draft_steps?: WorkflowStep[] | null; + /** True when a draft is staged (server-computed convenience flag). */ + has_draft?: boolean; + /** Most recent Test Agent session for this workflow. */ + last_test_session_id?: string | null; /** Tool permissions the user answered once and we reuse on later runs so an * unattended scheduled fire doesn't stall on a prompt. tool name -> answer. */ remembered_approvals?: Record; @@ -133,6 +139,9 @@ export interface OpenCard { sidecarKind?: 'watching' | 'viewing-completed' | 'viewing-error' | 'testing' | null; /** Per-step expand state for ExpandedView. Stores step ids. */ expandedStepIds?: string[]; + /** One-shot "Schedule this workflow?" prompt shown right after a convert. + * Transient: lives only on the just-created card, never on hub-opened ones. */ + showScheduleNudge?: boolean; /** Pre-seed message for the Fix-with-Agent flow so the EditAgent composer * knows which failure context to lead with. Cleared once consumed. */ fixSeed?: { runId: string; stepIdx: number; stepLabel: string; error: string } | null; @@ -210,6 +219,18 @@ export const updateWorkflow = createAsyncThunk< }, ); +export const commitDraft = createAsyncThunk('workflows/commitDraft', async (id: string) => { + const res = await fetch(`${API}/${id}/draft/commit`, { method: 'POST' }); + if (!res.ok) throw new Error(`commit failed ${res.status}`); + return (await res.json()) as Workflow; +}); + +export const discardDraft = createAsyncThunk('workflows/discardDraft', async (id: string) => { + const res = await fetch(`${API}/${id}/draft/discard`, { method: 'POST' }); + if (!res.ok) throw new Error(`discard failed ${res.status}`); + return (await res.json()) as Workflow; +}); + export const deleteWorkflow = createAsyncThunk('workflows/delete', async (id: string) => { await fetch(`${API}/${id}`, { method: 'DELETE' }); return id; @@ -364,6 +385,8 @@ const slice = createSlice({ .addCase(fetchWorkflows.rejected, (state) => { state.loading = false; state.loaded = true; }) .addCase(createWorkflow.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; }) .addCase(updateWorkflow.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; }) + .addCase(commitDraft.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; }) + .addCase(discardDraft.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; }) .addCase(deleteWorkflow.fulfilled, (state, action) => { delete state.items[action.payload]; delete state.runs[action.payload]; diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 8cf13035..c690f633 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -8,6 +8,7 @@ import { addApprovalRequest, removeApprovalRequest, updateSessionStatus, + setSessionTestState, updateSessionCost, updateSessionContext, setContextOverflow, @@ -444,6 +445,13 @@ class WebSocketManager { } switch (event) { + case 'agent:test_state': + // broadcast_global puts everything under data (no top-level session_id). + if (data.session_id && data.state) { + store.dispatch(setSessionTestState({ sessionId: data.session_id, state: data.state })); + } + break; + case 'agent:status': // Capture pre-transition status so we only fire a system notification // on a real running→terminal transition. Otherwise a session that