mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[aidan] feat/workflow-edit: add draft testing save flow
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+248
-134
@@ -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.
|
||||
|
||||
@@ -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<string, number> = {
|
||||
@@ -255,6 +257,18 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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<AgentChatProps> = ({ 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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSend={handleSend}
|
||||
disabled={false}
|
||||
mode={mode}
|
||||
onModeChange={handleModeChange}
|
||||
model={model}
|
||||
onModelChange={handleModelChange}
|
||||
isRunning={agentBusy}
|
||||
onStop={handleStop}
|
||||
queueLength={queueLength}
|
||||
contextEstimate={contextEstimate}
|
||||
sessionId={id}
|
||||
autoFocus={autoFocus}
|
||||
thinkingLevel={session?.thinking_level ?? 'auto'}
|
||||
onThinkingLevelChange={handleThinkingLevelChange}
|
||||
onActivityLabelChange={setPreSendActivityLabel}
|
||||
/>
|
||||
{isStoppableSidecar ? (
|
||||
<ForceStopAgentBar onStop={handleStop} onSaveWorkflow={onTestSaveWorkflow} onContinueEditing={onTestContinueEditing} testState={testState} />
|
||||
) : (
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSend={handleSend}
|
||||
disabled={false}
|
||||
mode={mode}
|
||||
onModeChange={handleModeChange}
|
||||
model={model}
|
||||
onModelChange={handleModelChange}
|
||||
isRunning={agentBusy}
|
||||
onStop={handleStop}
|
||||
queueLength={queueLength}
|
||||
contextEstimate={contextEstimate}
|
||||
sessionId={id}
|
||||
autoFocus={autoFocus}
|
||||
thinkingLevel={session?.thinking_level ?? 'auto'}
|
||||
onThinkingLevelChange={handleThinkingLevelChange}
|
||||
onActivityLabelChange={setPreSendActivityLabel}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ClickAwayListener>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<Box sx={{ px: 2, py: 1.5 }}>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onStop}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.6,
|
||||
width: '100%', py: 0.9, borderRadius: 999, cursor: 'pointer',
|
||||
fontSize: '0.85rem', fontWeight: 700,
|
||||
color: c.status.error, bgcolor: c.status.error + '14', border: `1px solid ${c.status.error}55`,
|
||||
'&:hover': { bgcolor: c.status.error + '22' },
|
||||
}}>
|
||||
<StopCircleOutlined sx={{ fontSize: 17 }} />
|
||||
Force Stop Agent
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const tone = testState === 'complete' ? c.status.success : c.status.error;
|
||||
return (
|
||||
<Box sx={{ px: 2, py: 1.5, display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: tone, fontSize: '0.8rem', fontWeight: 600 }}>
|
||||
<CheckCircleOutlineRounded sx={{ fontSize: 16 }} />
|
||||
{testState === 'complete' ? 'Test finished' : 'Test stopped'}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onContinueEditing}
|
||||
sx={{
|
||||
flex: 1, textAlign: 'center', py: 0.8, borderRadius: 999, cursor: 'pointer',
|
||||
fontSize: '0.82rem', fontWeight: 600, color: c.text.secondary,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
'&:hover': { bgcolor: c.bg.elevated, color: c.text.primary },
|
||||
}}>
|
||||
Continue editing
|
||||
</Box>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onSaveWorkflow}
|
||||
sx={{
|
||||
flex: 1, textAlign: 'center', py: 0.8, borderRadius: 999, cursor: 'pointer',
|
||||
fontSize: '0.82rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary,
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
Save workflow
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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<Props> = ({
|
||||
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<Props> = ({
|
||||
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<Props> = ({
|
||||
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<Props> = ({
|
||||
<Tooltip title="Turn this chat into a reusable, schedulable workflow">
|
||||
<Box
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (converting) return;
|
||||
const steps = extractStepsFromSession(session);
|
||||
if (steps.length === 0) return;
|
||||
const draft: Partial<Workflow> = {
|
||||
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<Workflow>));
|
||||
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<Props> = ({
|
||||
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)' },
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeOutlinedIcon sx={{ fontSize: 14 }} />
|
||||
Convert to workflow
|
||||
{converting ? 'Converting…' : 'Convert to workflow'}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'flex-start',
|
||||
px: 1, py: 0.7, borderRadius: `${c.radius.md}px`, cursor: 'pointer',
|
||||
'&:hover': { bgcolor: c.bg.elevated },
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '0.86rem', fontWeight: 600, color: labelColor }}>{label}</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>{hint}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// '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) => (
|
||||
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.75 }}>
|
||||
{text}
|
||||
</Typography>
|
||||
);
|
||||
return (
|
||||
<Popover
|
||||
open={phase === 'ask-test' || phase === 'confirm-discard'}
|
||||
anchorEl={anchorEl}
|
||||
onClose={onClose}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
slotProps={{ paper: { sx: { width: 300, p: 1.25 } } }}
|
||||
>
|
||||
{phase === 'ask-test' && (
|
||||
<>
|
||||
{heading('BEFORE YOU SAVE')}
|
||||
<Typography sx={{ fontSize: '0.84rem', color: c.text.secondary, mb: 0.75, lineHeight: 1.4 }}>
|
||||
Want to test the workflow before finishing your edits?
|
||||
</Typography>
|
||||
<OptionRow label="Yes, test it" hint="Run the draft once so you can watch it work" onClick={onRunTest} accent />
|
||||
<OptionRow label="No, save now" hint="Commit your edits without a test run" onClick={onSaveNow} />
|
||||
</>
|
||||
)}
|
||||
{phase === 'confirm-discard' && (
|
||||
<>
|
||||
{heading('DISCARD CHANGES')}
|
||||
<Typography sx={{ fontSize: '0.84rem', color: c.text.secondary, mb: 0.75, lineHeight: 1.4 }}>
|
||||
Throw away every edit from this session? This can't be undone.
|
||||
</Typography>
|
||||
<OptionRow label="Discard changes" hint="Revert to the saved workflow" onClick={onConfirmDiscard} danger />
|
||||
<OptionRow label="Keep editing" hint="Go back and keep your changes" onClick={onClose} />
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -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<SavePhase>('idle');
|
||||
const [saveAnchorEl, setSaveAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [testSessionId, setTestSessionId] = useState<string | null>(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<HTMLElement>) => {
|
||||
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<HTMLElement>) => {
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||
{/* 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. */}
|
||||
<Box sx={{ flexShrink: 0, mb: 1 }}>
|
||||
<Box
|
||||
onClick={() => 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 },
|
||||
}}>
|
||||
<KeyboardArrowDownRounded sx={{ fontSize: 16, transform: stepsOpen ? 'none' : 'rotate(-90deg)', transition: 'transform 0.15s ease' }} />
|
||||
Workflow ({steps.length} step{steps.length === 1 ? '' : 's'})
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||
<Box
|
||||
onClick={() => 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 },
|
||||
}}>
|
||||
<KeyboardArrowDownRounded sx={{ fontSize: 16, transform: stepsOpen ? 'none' : 'rotate(-90deg)', transition: 'transform 0.15s ease' }} />
|
||||
Workflow ({draftSteps.length} step{draftSteps.length === 1 ? '' : 's'})
|
||||
</Box>
|
||||
{hasChanges && (
|
||||
<Typography sx={{ fontSize: '0.74rem', color: c.text.muted }}>· unsaved</Typography>
|
||||
)}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box
|
||||
onClick={onDiscardClick}
|
||||
role="button"
|
||||
sx={{ fontSize: '0.8rem', fontWeight: 600, color: c.text.muted, cursor: 'pointer', mr: 1, '&:hover': { color: c.status.error } }}>
|
||||
Discard
|
||||
</Box>
|
||||
<Box
|
||||
onClick={onSaveClick}
|
||||
role="button"
|
||||
sx={{
|
||||
fontSize: '0.8rem', fontWeight: 700, color: '#fff', bgcolor: c.accent.primary,
|
||||
px: 1.2, py: 0.35, borderRadius: 999, cursor: 'pointer',
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
Save
|
||||
</Box>
|
||||
</Box>
|
||||
{stepsOpen && (
|
||||
<Box sx={{ mt: 0.75 }}>
|
||||
{isFixMode && fixSeed && <FixPrefixCard seed={fixSeed} expanded={fixPrefixExpanded} onToggle={() => setFixPrefixExpanded((x) => !x)} />}
|
||||
<StepList steps={steps} />
|
||||
<StepList steps={draftSteps} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<EditAgentSavePopovers
|
||||
phase={savePhase}
|
||||
anchorEl={saveAnchorEl}
|
||||
onClose={() => 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). */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Workflow | null> => {
|
||||
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
|
||||
</Box>
|
||||
<Box
|
||||
onClick={onSaveThenSchedule}
|
||||
@@ -289,7 +292,7 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'edit_agent' } }));
|
||||
}, [dispatch, workflow.id]);
|
||||
const openScheduling = useCallback(() => {
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
@@ -322,7 +348,57 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo
|
||||
onDeleteStep={onDeleteStep}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{showNudge && (
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 1.25,
|
||||
p: 1.5, borderRadius: `${c.radius.lg}px`,
|
||||
bgcolor: c.accent.primary + '10',
|
||||
border: `1px solid ${c.accent.primary}30`,
|
||||
}}>
|
||||
<Box sx={{
|
||||
width: 32, height: 32, borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: c.accent.primary + '22', color: c.accent.primary,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<CalendarMonthRounded sx={{ fontSize: 18 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.95rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.3 }}>
|
||||
Schedule this workflow?
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, mt: 0.25, lineHeight: 1.45 }}>
|
||||
You can have workflows run on a recurring basis, automatically.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 1.5, mt: 1.25 }}>
|
||||
<Box
|
||||
onClick={onNotNow}
|
||||
role="button"
|
||||
sx={{
|
||||
fontSize: '0.86rem', fontWeight: 500, color: c.text.secondary,
|
||||
cursor: 'pointer', px: 0.75, py: 0.5,
|
||||
'&:hover': { color: c.text.primary },
|
||||
}}>
|
||||
Not now
|
||||
</Box>
|
||||
<Box
|
||||
onClick={openScheduling}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
fontSize: '0.88rem', fontWeight: 700,
|
||||
px: 1.75, py: 0.6, borderRadius: 999,
|
||||
color: '#fff', bgcolor: c.accent.primary,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: c.accent.primary, filter: 'brightness(1.06)' },
|
||||
}}>
|
||||
Schedule Workflow
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
{showNudge ? <Box /> : (
|
||||
<Box
|
||||
onClick={scheduleClickable ? openScheduling : undefined}
|
||||
role={scheduleClickable ? 'button' : undefined}
|
||||
@@ -335,6 +411,7 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo
|
||||
<CalendarMonthRounded sx={{ fontSize: 16, color: c.text.muted, flexShrink: 0 }} />
|
||||
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{scheduleLine}</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
onClick={openEditAgent}
|
||||
role="button"
|
||||
|
||||
@@ -68,6 +68,9 @@ export interface AgentSession {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'draft' | 'running' | 'waiting_approval' | 'completed' | 'error' | 'stopped';
|
||||
/** For workflow Test Agent sessions: drives the test card's footer
|
||||
* (running -> 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,
|
||||
|
||||
@@ -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<string, 'allow' | 'deny'>;
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user