[aidan] feat/workflow-edit: add draft testing save flow

This commit is contained in:
abccodes
2026-06-17 00:33:27 -07:00
parent d3532f7a26
commit 5aeef89206
14 changed files with 825 additions and 218 deletions
+6
View File
@@ -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
+45 -8
View File
@@ -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,
}
+6
View File
@@ -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
View File
@@ -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.
+54 -19
View File
@@ -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&apos;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"
+12
View File
@@ -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