diff --git a/cortex_on/agents/orchestrator_agent.py b/cortex_on/agents/orchestrator_agent.py index 6b001ba..822241d 100644 --- a/cortex_on/agents/orchestrator_agent.py +++ b/cortex_on/agents/orchestrator_agent.py @@ -1,6 +1,7 @@ import os import json import traceback +import uuid from typing import List, Optional, Dict, Any, Union, Tuple from datetime import datetime from pydantic import BaseModel @@ -16,12 +17,21 @@ from agents.code_agent import coder_agent, CoderAgentDeps from utils.ant_client import get_client + +class UserCancellationError(Exception): + """Custom exception for user-initiated cancellations""" + pass + @dataclass class orchestrator_deps: websocket: Optional[WebSocket] = None stream_output: Optional[StreamResponse] = None # Add a collection to track agent-specific streams agent_responses: Optional[List[StreamResponse]] = None + plan_approved: bool = False + approved_plan_text: Optional[str] = None + require_browser_approval: bool = False + require_coder_approval: bool = False orchestrator_system_prompt = """You are an AI orchestrator that manages a team of agents to solve tasks. You have access to tools for coordinating the agents and managing the task flow. @@ -110,6 +120,13 @@ class orchestrator_deps: - Suggest manual alternatives - Block credential access +[SECTION EXECUTION ORDER - CRITICAL] +- Plans have numbered sections (## 1., ## 2., etc.). Execute them IN ORDER: Section 1 → 2 → 3. + +[USING APPROVED PLAN - CRITICAL - READ THIS CAREFULLY] +- Once plan_task returns an approved plan, FORGET the original user query. +- The approved plan IS your new source of truth. The user may have MODIFIED it. + Basic workflow: 1. Receive a task from the user. 2. Plan the task by calling the planner agent through plan_task @@ -173,6 +190,8 @@ class orchestrator_deps: @orchestrator_agent.tool async def plan_task(ctx: RunContext[orchestrator_deps], task: str) -> str: """Plans the task and assigns it to the appropriate agents""" + planner_stream_output = None + MAX_PLAN_RETRIES = 5 # Maximum number of plan regeneration attempts try: logfire.info(f"Planning task: {task}") @@ -191,25 +210,142 @@ async def plan_task(ctx: RunContext[orchestrator_deps], task: str) -> str: await _safe_websocket_send(ctx.deps.websocket, planner_stream_output) - # Update planner stream - planner_stream_output.steps.append("Planning task...") - await _safe_websocket_send(ctx.deps.websocket, planner_stream_output) - - # Run planner agent - planner_response = await planner_agent.run(user_prompt=task) - - # Update planner stream with results - plan_text = planner_response.data.plan - planner_stream_output.steps.append("Task planned successfully") - planner_stream_output.output = plan_text - planner_stream_output.status_code = 200 - await _safe_websocket_send(ctx.deps.websocket, planner_stream_output) - - # Also update orchestrator stream - ctx.deps.stream_output.steps.append("Task planned successfully") - await _safe_websocket_send(ctx.deps.websocket, ctx.deps.stream_output) + # Loop until plan is approved or cancelled + feedback = None + retry_count = 0 + while retry_count < MAX_PLAN_RETRIES: + # Update planner stream + if feedback: + planner_stream_output.steps.append(f"Regenerating plan based on feedback: {feedback}") + else: + planner_stream_output.steps.append("Planning task...") + await _safe_websocket_send(ctx.deps.websocket, planner_stream_output) + + # Build prompt with feedback if this is a retry + planner_prompt = task + if feedback: + planner_prompt = f"{task}\n\nUser feedback on previous plan: {feedback}\n\nPlease regenerate the plan incorporating this feedback." + + # Run planner agent + planner_response = await planner_agent.run(user_prompt=planner_prompt) + + # Update planner stream with results + plan_text = planner_response.data.plan + + # Request approval (returns dict with status and optional feedback) + approval_result = await _request_plan_approval(ctx, planner_stream_output, plan_text) + + if approval_result["status"] == "approved": + # Plan approved, break out of loop + approved_plan_text = approval_result.get("plan", plan_text) + + # Save the approved (potentially modified) plan to todo.md + base_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) + planner_dir = os.path.join(base_dir, "agents", "planner") + todo_path = os.path.join(planner_dir, "todo.md") + os.makedirs(planner_dir, exist_ok=True) + + # Write the approved plan to todo.md so orchestrator uses the correct plan + with open(todo_path, "w", encoding="utf-8") as file: + file.write(approved_plan_text) + + planner_stream_output.steps.append("Task planned successfully") + planner_stream_output.steps.append("Approved plan saved to todo.md") + planner_stream_output.output = approved_plan_text + planner_stream_output.status_code = 200 + await _safe_websocket_send(ctx.deps.websocket, planner_stream_output) + + # Also update orchestrator stream + ctx.deps.stream_output.steps.append("Task planned successfully") + await _safe_websocket_send(ctx.deps.websocket, ctx.deps.stream_output) + + # Return with explicit instructions to use the approved plan, not the original query + return f"""PLAN APPROVED AND SAVED + +CRITICAL: The approved plan below is now your SOURCE OF TRUTH. +IGNORE the original user query. Use ONLY the task descriptions from this approved plan. +The user may have modified the plan - use the EXACT text from the plan below. + +=== APPROVED PLAN (USE THIS) === +{approved_plan_text} +=== END OF APPROVED PLAN === +""" + + elif approval_result["status"] == "retry": + # User requested changes, loop back with feedback + retry_count += 1 + feedback = approval_result.get("feedback", "") + + if retry_count >= MAX_PLAN_RETRIES: + # Maximum retries reached, force approval or cancellation + planner_stream_output.steps.append( + f"Maximum plan regeneration limit ({MAX_PLAN_RETRIES}) reached. " + "Please approve the current plan or cancel the task." + ) + planner_stream_output.status_code = 102 + await _safe_websocket_send(ctx.deps.websocket, planner_stream_output) + + # Request final approval (user can only approve or cancel now) + final_approval = await _request_plan_approval(ctx, planner_stream_output, plan_text) + if final_approval["status"] == "approved": + approved_plan_text = final_approval.get("plan", plan_text) + # Save and return as normal + base_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) + planner_dir = os.path.join(base_dir, "agents", "planner") + todo_path = os.path.join(planner_dir, "todo.md") + os.makedirs(planner_dir, exist_ok=True) + with open(todo_path, "w", encoding="utf-8") as file: + file.write(approved_plan_text) + planner_stream_output.steps.append("Task planned successfully") + planner_stream_output.steps.append("Approved plan saved to todo.md") + planner_stream_output.output = approved_plan_text + planner_stream_output.status_code = 200 + await _safe_websocket_send(ctx.deps.websocket, planner_stream_output) + ctx.deps.stream_output.steps.append("Task planned successfully") + await _safe_websocket_send(ctx.deps.websocket, ctx.deps.stream_output) + # Return with explicit instructions to use the approved plan + return f"""PLAN APPROVED AND SAVED + +CRITICAL: The approved plan below is now your SOURCE OF TRUTH. +IGNORE the original user query. Use ONLY the task descriptions from this approved plan. +The user may have modified the plan - use the EXACT text from the plan below. + +=== APPROVED PLAN (USE THIS) === +{approved_plan_text} +=== END OF APPROVED PLAN === +""" + else: + # Cancelled + planner_stream_output.steps.append("Plan execution cancelled by user.") + planner_stream_output.status_code = 400 + await _safe_websocket_send(ctx.deps.websocket, planner_stream_output) + raise UserCancellationError("Plan execution cancelled by user.") + + planner_stream_output.steps.append( + f"User requested changes ({retry_count}/{MAX_PLAN_RETRIES}). Feedback: {feedback}" + ) + planner_stream_output.status_code = 102 + await _safe_websocket_send(ctx.deps.websocket, planner_stream_output) + # Continue loop to regenerate plan + continue + + elif approval_result["status"] == "cancelled": + # User cancelled, raise exception + planner_stream_output.steps.append("Plan execution cancelled by user.") + planner_stream_output.status_code = 400 + await _safe_websocket_send(ctx.deps.websocket, planner_stream_output) + raise UserCancellationError("Plan execution cancelled by user.") - return f"Task planned successfully\nTask: {plan_text}" + # If we exit the loop without approval (shouldn't happen, but safety check) + if retry_count >= MAX_PLAN_RETRIES: + planner_stream_output.steps.append("Maximum retry limit reached. Plan execution terminated.") + planner_stream_output.status_code = 400 + await _safe_websocket_send(ctx.deps.websocket, planner_stream_output) + raise RuntimeError("Maximum plan regeneration limit reached. Please try again with a clearer task description.") + + except UserCancellationError: + # Re-raise cancellation errors to be handled gracefully at the top level + raise except Exception as e: error_msg = f"Error planning task: {str(e)}" logfire.error(error_msg, exc_info=True) @@ -255,6 +391,19 @@ async def coder_task(ctx: RunContext[orchestrator_deps], task: str) -> str: stream_output=coder_stream_output ) + try: + await _ensure_step_approval( + ctx, + channel="code", + task_description=task, + prompt="Approve this coding step before it runs." + ) + except RuntimeError as rejection_error: + coder_stream_output.steps.append(str(rejection_error)) + coder_stream_output.status_code = 400 + await _safe_websocket_send(ctx.deps.websocket, coder_stream_output) + return str(rejection_error) + # Run coder agent coder_response = await coder_agent.run( user_prompt=task, @@ -306,9 +455,23 @@ async def web_surfer_task(ctx: RunContext[orchestrator_deps], task: str) -> str: ctx.deps.agent_responses.append(web_surfer_stream_output) await _safe_websocket_send(ctx.deps.websocket, web_surfer_stream_output) + + try: + await _ensure_step_approval( + ctx, + channel="web", + task_description=task, + prompt="Approve this web automation step before it executes." + ) + except RuntimeError as rejection_error: + web_surfer_stream_output.steps.append(str(rejection_error)) + web_surfer_stream_output.status_code = 400 + web_surfer_stream_output.output = str(rejection_error) + await _safe_websocket_send(ctx.deps.websocket, web_surfer_stream_output) + return str(rejection_error) # Initialize WebSurfer agent - web_surfer_agent = WebSurfer(api_url="http://localhost:8000/api/v1/web/stream") + web_surfer_agent = WebSurfer(api_url="http://agentic_browser:8000/api/v1/web/stream") # Run WebSurfer with its own stream_output success, message, messages = await web_surfer_agent.generate_reply( @@ -484,7 +647,7 @@ async def planner_agent_update(ctx: RunContext[orchestrator_deps], completed_tas logfire.error(error_msg, exc_info=True) planner_stream_output.steps.append(f"Plan update failed: {str(e)}") - planner_stream_output.status_code = a500 + planner_stream_output.status_code = 500 await _safe_websocket_send(ctx.deps.websocket, planner_stream_output) return f"Failed to update the plan: {error_msg}" @@ -500,6 +663,172 @@ async def planner_agent_update(ctx: RunContext[orchestrator_deps], completed_tas return f"Failed to update plan: {error_msg}" +# Approval helpers +async def _request_plan_approval( + ctx: RunContext[orchestrator_deps], + planner_stream_output: StreamResponse, + plan_text: str, +) -> Dict[str, Any]: + """ + Pause execution until the user approves, requests changes, or cancels the generated plan. + Returns a dict with: + - status: "approved" | "retry" | "cancelled" + - plan: updated plan text (if approved) + - feedback: user feedback (if retry) + """ + if ctx.deps.plan_approved: + if ctx.deps.approved_plan_text == plan_text: + return { + "status": "approved", + "plan": plan_text + } + ctx.deps.plan_approved = False + + if not ctx.deps.websocket: + ctx.deps.plan_approved = True + ctx.deps.approved_plan_text = plan_text + return { + "status": "approved", + "plan": plan_text + } + + planner_stream_output.steps.append("Plan ready for review") + await _safe_websocket_send(ctx.deps.websocket, planner_stream_output) + + approval_id = str(uuid.uuid4()) + approval_stream = StreamResponse( + agent_name="Plan Approval", + instructions="Review the generated plan. You can approve it, request modifications via feedback, or cancel.", + steps=[], + output=plan_text, + status_code=102, + metadata={ + "approval_id": approval_id, + "require_browser_approval": ctx.deps.require_browser_approval, + "require_coder_approval": ctx.deps.require_coder_approval, + }, + ) + await _safe_websocket_send(ctx.deps.websocket, approval_stream) + + while True: + raw_response = await ctx.deps.websocket.receive_text() + try: + payload = json.loads(raw_response) + except json.JSONDecodeError: + approval_stream.steps.append("Invalid response. Please use the approval controls.") + await _safe_websocket_send(ctx.deps.websocket, approval_stream) + continue + + # Handle plan feedback (request changes) + if payload.get("type") == "plan_feedback": + if payload.get("approval_id") and payload["approval_id"] != approval_id: + continue + feedback = payload.get("feedback", "").strip() + if not feedback: + approval_stream.steps.append("Please provide feedback when requesting changes.") + await _safe_websocket_send(ctx.deps.websocket, approval_stream) + continue + + approval_stream.steps.append(f"Feedback received: {feedback}") + approval_stream.status_code = 102 + await _safe_websocket_send(ctx.deps.websocket, approval_stream) + return { + "status": "retry", + "feedback": feedback + } + + # Handle plan approval + if payload.get("type") == "plan_approval": + if payload.get("approval_id") and payload["approval_id"] != approval_id: + continue + + if payload.get("approved", True) is False: + approval_stream.steps.append("Plan rejected by user.") + approval_stream.status_code = 400 + await _safe_websocket_send(ctx.deps.websocket, approval_stream) + return { + "status": "cancelled" + } + + # Always use the original plan_text - manual edits are not allowed + # Users can only approve, request modifications via feedback, or cancel + ctx.deps.require_browser_approval = bool(payload.get("require_browser_approval")) + ctx.deps.require_coder_approval = bool(payload.get("require_coder_approval")) + ctx.deps.plan_approved = True + ctx.deps.approved_plan_text = plan_text + + approval_stream.steps.append("Plan approved by user.") + approval_stream.status_code = 200 + approval_stream.output = plan_text + approval_stream.metadata = { + "approval_id": approval_id, + "require_browser_approval": ctx.deps.require_browser_approval, + "require_coder_approval": ctx.deps.require_coder_approval, + } + await _safe_websocket_send(ctx.deps.websocket, approval_stream) + return { + "status": "approved", + "plan": plan_text + } + + # Ignore other message types + approval_stream.steps.append("Waiting for plan approval response...") + await _safe_websocket_send(ctx.deps.websocket, approval_stream) + + +async def _ensure_step_approval( + ctx: RunContext[orchestrator_deps], + *, + channel: str, + task_description: str, + prompt: str, +) -> None: + """Request user approval before executing a sensitive step.""" + requires_approval = ( + ctx.deps.require_browser_approval if channel == "web" else ctx.deps.require_coder_approval + ) + if not requires_approval or not ctx.deps.websocket: + return + + approval_id = str(uuid.uuid4()) + approval_stream = StreamResponse( + agent_name="Step Approval", + instructions=prompt, + steps=[], + status_code=102, + output=task_description, + metadata={"approval_id": approval_id, "channel": channel}, + ) + await _safe_websocket_send(ctx.deps.websocket, approval_stream) + + while True: + raw_response = await ctx.deps.websocket.receive_text() + try: + payload = json.loads(raw_response) + except json.JSONDecodeError: + continue + + if payload.get("type") != "step_approval": + continue + + if payload.get("approval_id") and payload["approval_id"] != approval_id: + continue + + if payload.get("approved", True): + approval_stream.steps.append("Step approved by user.") + approval_stream.status_code = 200 + await _safe_websocket_send(ctx.deps.websocket, approval_stream) + return + + reason = (payload.get("reason") or "").strip() + approval_stream.steps.append("Step rejected by user.") + if reason: + approval_stream.output = f"{task_description}\n\nUser note: {reason}" + approval_stream.status_code = 400 + await _safe_websocket_send(ctx.deps.websocket, approval_stream) + raise RuntimeError(f"User rejected {channel} step{': ' + reason if reason else ''}") + + # Helper function for sending WebSocket messages async def _safe_websocket_send(websocket: Optional[WebSocket], message: Any) -> bool: """Safely send message through websocket with error handling""" diff --git a/cortex_on/agents/web_surfer.py b/cortex_on/agents/web_surfer.py index 34e2cfd..8a1e4ca 100644 --- a/cortex_on/agents/web_surfer.py +++ b/cortex_on/agents/web_surfer.py @@ -29,7 +29,7 @@ TIMEOUT = 9999999999999999999999999999999999999999999 class WebSurfer: - def __init__(self, api_url: str = "http://localhost:8000/api/v1/web/stream"): + def __init__(self, api_url: str = "http://agentic_browser:8000/api/v1/web/stream"): self.api_url = api_url self.name = "Web Surfer Agent" self.description = "An agent that is a websurfer and a webscraper that can access any web-page to extract information or perform actions." diff --git a/cortex_on/instructor.py b/cortex_on/instructor.py index b4f0efb..b21e335 100644 --- a/cortex_on/instructor.py +++ b/cortex_on/instructor.py @@ -17,7 +17,7 @@ # Local application imports from agents.code_agent import coder_agent -from agents.orchestrator_agent import orchestrator_agent, orchestrator_deps +from agents.orchestrator_agent import orchestrator_agent, orchestrator_deps, UserCancellationError from agents.planner_agent import planner_agent from agents.web_surfer import WebSurfer from utils.ant_client import get_client @@ -101,6 +101,19 @@ async def run(self, task: str, websocket: WebSocket) -> List[Dict[str, Any]]: logfire.info("Task completed successfully") return [json.loads(json.dumps(asdict(i), cls=DateTimeEncoder)) for i in self.orchestrator_response] + except UserCancellationError: + # User-initiated cancellation - show friendly message + friendly_msg = "Task cancelled by user. No changes were made." + logfire.info(friendly_msg) + + if stream_output: + stream_output.output = friendly_msg + stream_output.status_code = 200 # Use 200 to indicate successful cancellation + stream_output.steps.append("Task cancelled successfully") + await self._safe_websocket_send(stream_output) + + return [json.loads(json.dumps(asdict(i), cls=DateTimeEncoder)) for i in self.orchestrator_response] + except Exception as e: error_msg = f"Critical orchestration error: {str(e)}\n{traceback.format_exc()}" logfire.error(error_msg) diff --git a/cortex_on/utils/stream_response_format.py b/cortex_on/utils/stream_response_format.py index d99ac9a..3a24397 100644 --- a/cortex_on/utils/stream_response_format.py +++ b/cortex_on/utils/stream_response_format.py @@ -9,3 +9,4 @@ class StreamResponse: status_code: int output: str live_url: Optional[str] = None + metadata: Optional[dict] = None diff --git a/docker-compose.yaml b/docker-compose.yaml index e9f951a..9b4ac26 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,7 +10,8 @@ services: env_file: - .env restart: always - network_mode: host + ports: + - "8081:8081" agentic_browser: build: @@ -21,7 +22,8 @@ services: env_file: - .env restart: always - network_mode: host + ports: + - "8000:8000" frontend: build: @@ -36,4 +38,5 @@ services: - cortex_on - agentic_browser restart: always - network_mode: host + ports: + - "3000:3000" diff --git a/frontend/src/components/home/ChatList.tsx b/frontend/src/components/home/ChatList.tsx index 9d8cb21..59e8e25 100644 --- a/frontend/src/components/home/ChatList.tsx +++ b/frontend/src/components/home/ChatList.tsx @@ -9,32 +9,45 @@ import { SquareSlash, X, } from "lucide-react"; -import {useEffect, useRef, useState} from "react"; +import { useEffect, useRef, useState } from "react"; import favicon from "../../assets/Favicon-contexton.svg"; -import {ScrollArea} from "../ui/scroll-area"; +import { ScrollArea } from "../ui/scroll-area"; import Markdown from "react-markdown"; import rehypeRaw from "rehype-raw"; import remarkBreaks from "remark-breaks"; -import {Skeleton} from "../ui/skeleton"; - -import {setMessages} from "@/dataStore/messagesSlice"; -import {RootState} from "@/dataStore/store"; -import {getTimeAgo} from "@/lib/utils"; -import {AgentOutput, ChatListPageProps, SystemMessage} from "@/types/chatTypes"; -import {useDispatch, useSelector} from "react-redux"; -import useWebSocket, {ReadyState} from "react-use-websocket"; -import {Button} from "../ui/button"; -import {Card} from "../ui/card"; -import {Textarea} from "../ui/textarea"; -import {CodeBlock} from "./CodeBlock"; -import {ErrorAlert} from "./ErrorAlert"; +import { Skeleton } from "../ui/skeleton"; + +import { setMessages } from "@/dataStore/messagesSlice"; +import { RootState } from "@/dataStore/store"; +import { getTimeAgo } from "@/lib/utils"; +import { AgentOutput, ChatListPageProps, SystemMessage } from "@/types/chatTypes"; +import { useDispatch, useSelector } from "react-redux"; +import useWebSocket, { ReadyState } from "react-use-websocket"; +import { Button } from "../ui/button"; +import { Card } from "../ui/card"; +import { Textarea } from "../ui/textarea"; +import { CodeBlock } from "./CodeBlock"; +import { ErrorAlert } from "./ErrorAlert"; import LoadingView from "./Loading"; -import {TerminalBlock} from "./TerminalBlock"; +import { TerminalBlock } from "./TerminalBlock"; -const {VITE_WEBSOCKET_URL} = import.meta.env; +type PlanApprovalState = { + id: string; + instructions: string; +}; + +type StepApprovalState = { + id: string; + instructions: string; + detail: string; + channel?: string; + note: string; +}; -const ChatList = ({isLoading, setIsLoading}: ChatListPageProps) => { +const { VITE_WEBSOCKET_URL } = import.meta.env; + +const ChatList = ({ isLoading, setIsLoading }: ChatListPageProps) => { const [isHovering, setIsHovering] = useState(false); const [isIframeLoading, setIsIframeLoading] = useState(true); const [liveUrl, setLiveUrl] = useState(""); @@ -46,6 +59,18 @@ const ChatList = ({isLoading, setIsLoading}: ChatListPageProps) => { const [animateSubmit, setAnimateSubmit] = useState(false); const [humanInputValue, setHumanInputValue] = useState(""); + const [planApprovalRequest, setPlanApprovalRequest] = + useState(null); + const [planDraft, setPlanDraft] = useState(""); + const [planFeedback, setPlanFeedback] = useState(""); + const [showPlanFeedback, setShowPlanFeedback] = useState(false); + const [requireBrowserApproval, setRequireBrowserApproval] = + useState(false); + const [requireCoderApproval, setRequireCoderApproval] = + useState(false); + const [stepApprovals, setStepApprovals] = useState< + Record + >({}); const textareaRef = useRef(null); @@ -76,7 +101,7 @@ const ChatList = ({isLoading, setIsLoading}: ChatListPageProps) => { setRows(newRows); }; - const {sendMessage, lastJsonMessage, readyState} = useWebSocket( + const { sendMessage, lastJsonMessage, readyState } = useWebSocket( VITE_WEBSOCKET_URL, { onOpen: () => { @@ -130,8 +155,15 @@ const ChatList = ({isLoading, setIsLoading}: ChatListPageProps) => { setIsLoading(true); const lastMessageData = lastMessage.data || []; - const {agent_name, instructions, steps, output, status_code, live_url} = - lastJsonMessage as SystemMessage; + const { + agent_name, + instructions, + steps, + output, + status_code, + live_url, + metadata, + } = lastJsonMessage as SystemMessage; console.log(lastJsonMessage); @@ -146,6 +178,65 @@ const ChatList = ({isLoading, setIsLoading}: ChatListPageProps) => { setLiveUrl(""); } + if (agent_name === "Plan Approval") { + if (status_code === 102) { + const approvalId = + (metadata?.approval_id as string) ?? `plan-${Date.now()}`; + setPlanApprovalRequest((current) => { + if (current && current.id === approvalId) { + return current; + } + setPlanDraft(output || ""); + setRequireBrowserApproval( + Boolean(metadata?.require_browser_approval) + ); + setRequireCoderApproval(Boolean(metadata?.require_coder_approval)); + // Reset feedback state when new plan arrives + setPlanFeedback(""); + setShowPlanFeedback(false); + return { id: approvalId, instructions }; + }); + } else if (metadata?.approval_id) { + setPlanApprovalRequest((current) => { + if (!current || current.id !== metadata.approval_id) { + return current; + } + return null; + }); + } + } + + if (agent_name === "Step Approval") { + const approvalId = + (metadata?.approval_id as string) ?? `step-${Date.now()}`; + if (status_code === 102) { + setStepApprovals((prev) => { + if (prev[approvalId]) { + return prev; + } + return { + ...prev, + [approvalId]: { + id: approvalId, + instructions, + detail: output, + channel: (metadata?.channel as string) || undefined, + note: "", + }, + }; + }); + } else if (metadata?.approval_id) { + setStepApprovals((prev) => { + if (!prev[approvalId]) { + return prev; + } + const updated = { ...prev }; + delete updated[approvalId]; + return updated; + }); + } + } + const agentIndex = lastMessageData.findIndex( (agent: SystemMessage) => agent.agent_name === agent_name ); @@ -157,9 +248,9 @@ const ChatList = ({isLoading, setIsLoading}: ChatListPageProps) => { const plannerStep = steps.find((step) => step.startsWith("Plan")); filteredSteps = plannerStep ? [ - plannerStep, - ...steps.filter((step) => step.startsWith("Current")), - ] + plannerStep, + ...steps.filter((step) => step.startsWith("Current")), + ] : steps.filter((step) => step.startsWith("Current")); } updatedLastMessageData = [...lastMessageData]; @@ -170,6 +261,7 @@ const ChatList = ({isLoading, setIsLoading}: ChatListPageProps) => { output, status_code, live_url, + metadata, }; } else { updatedLastMessageData = [ @@ -181,6 +273,7 @@ const ChatList = ({isLoading, setIsLoading}: ChatListPageProps) => { output, status_code, live_url, + metadata, }, ]; } @@ -195,7 +288,7 @@ const ChatList = ({isLoading, setIsLoading}: ChatListPageProps) => { setIsLoading(false); } - if (status_code === 200) { + if (status_code === 200 || (agent_name === "Plan Approval" && status_code === 102)) { setOutputsList((prevList) => { const existingIndex = prevList.findIndex( (item) => item.agent === agent_name @@ -206,10 +299,10 @@ const ChatList = ({isLoading, setIsLoading}: ChatListPageProps) => { if (existingIndex >= 0) { newList = [...prevList]; - newList[existingIndex] = {agent: agent_name, output}; + newList[existingIndex] = { agent: agent_name, output }; newOutputIndex = existingIndex; } else { - newList = [...prevList, {agent: agent_name, output}]; + newList = [...prevList, { agent: agent_name, output }]; newOutputIndex = newList.length - 1; } @@ -260,6 +353,76 @@ const ChatList = ({isLoading, setIsLoading}: ChatListPageProps) => { return ; case "Executor Agent": return ; + case "Plan Approval": + if (planApprovalRequest && planApprovalRequest.id === (planApprovalRequest.id || "")) { + return ( +
+
+ + {planDraft} + +
+
+ + +
+
+ + +
+
+ ); + } + return ( +
+ + {output} + +
+ ); default: return (
@@ -267,7 +430,7 @@ const ChatList = ({isLoading, setIsLoading}: ChatListPageProps) => { remarkPlugins={[remarkBreaks]} rehypePlugins={[rehypeRaw]} components={{ - code({className, children, ...props}) { + code({ className, children, ...props }) { return (
                       
@@ -276,26 +439,26 @@ const ChatList = ({isLoading, setIsLoading}: ChatListPageProps) => {
                     
); }, - h1: ({children}) => ( + h1: ({ children }) => (

{children}

), - h2: ({children}) => ( + h2: ({ children }) => (

{children}

), - h3: ({children}) => ( + h3: ({ children }) => (

{children}

), - h4: ({children}) => ( + h4: ({ children }) => (

{children}

), - h5: ({children}) => ( + h5: ({ children }) => (
{children}
), - h6: ({children}) => ( + h6: ({ children }) => (
{children}
), - p: ({children}) =>

{children}

, - a: ({href, children}) => ( + p: ({ children }) =>

{children}

, + a: ({ href, children }) => ( { {children} ), - ul: ({children}) => ( + ul: ({ children }) => (
    {children}
), - ol: ({children}) => ( + ol: ({ children }) => (
    {children}
), - li: ({children}) =>
  • {children}
  • , + li: ({ children }) =>
  • {children}
  • , }} > {output} @@ -503,11 +666,10 @@ const ChatList = ({isLoading, setIsLoading}: ChatListPageProps) => { const chatContainerWidth = liveUrl || currentOutput !== null ? "50%" : "65%"; - const outputPanelClasses = `border-2 rounded-xl w-[50%] flex flex-col h-[95%] justify-between items-center transition-all duration-700 ease-in-out ${ - animateOutputEntry - ? "opacity-100 translate-x-0 animate-fade-in animate-once animate-duration-1000" - : "opacity-0 translate-x-2" - }`; + const outputPanelClasses = `border-2 rounded-xl w-[50%] flex flex-col h-[95%] justify-between items-center transition-all duration-700 ease-in-out ${animateOutputEntry + ? "opacity-100 translate-x-0 animate-fade-in animate-once animate-duration-1000" + : "opacity-0 translate-x-2" + }`; const handleHumanInputSubmit = () => { if (humanInputValue.trim()) { @@ -518,11 +680,81 @@ const ChatList = ({isLoading, setIsLoading}: ChatListPageProps) => { } }; + const handlePlanApprovalSubmit = (approved: boolean) => { + if (!planApprovalRequest) return; + const payload: Record = { + type: "plan_approval", + approval_id: planApprovalRequest.id, + approved, + // Note: plan field removed - users cannot manually edit plans + // They can only approve, request modifications via feedback, or cancel + require_browser_approval: requireBrowserApproval, + require_coder_approval: requireCoderApproval, + }; + sendMessage(JSON.stringify(payload)); + setPlanApprovalRequest(null); + setPlanDraft(""); + setPlanFeedback(""); + setShowPlanFeedback(false); + setRequireBrowserApproval(false); + setRequireCoderApproval(false); + // Reset loading state when cancelling + if (!approved) { + setIsLoading(false); + } + }; + + const handlePlanFeedbackSubmit = () => { + if (!planApprovalRequest || !planFeedback.trim()) return; + const payload: Record = { + type: "plan_feedback", + approval_id: planApprovalRequest.id, + feedback: planFeedback.trim(), + }; + sendMessage(JSON.stringify(payload)); + setPlanFeedback(""); + setShowPlanFeedback(false); + // Clear the plan approval request and draft while new plan generates + // The new plan will set these again when it arrives + setPlanApprovalRequest(null); + setPlanDraft(""); + setIsLoading(true); + }; + + const handleStepNoteChange = (id: string, value: string) => { + setStepApprovals((prev) => { + if (!prev[id]) return prev; + return { + ...prev, + [id]: { ...prev[id], note: value }, + }; + }); + }; + + const handleStepApprovalDecision = (id: string, approved: boolean) => { + const state = stepApprovals[id]; + if (!state) return; + const payload: Record = { + type: "step_approval", + approval_id: id, + approved, + }; + if (!approved && state.note.trim().length > 0) { + payload.reason = state.note.trim(); + } + sendMessage(JSON.stringify(payload)); + setStepApprovals((prev) => { + const updated = { ...prev }; + delete updated[id]; + return updated; + }); + }; + return (
    @@ -536,11 +768,10 @@ const ChatList = ({isLoading, setIsLoading}: ChatListPageProps) => { > {message.sent_at && message.sent_at.length > 0 && (

    {getTimeAgo(message.sent_at)}

    @@ -576,194 +807,387 @@ const ChatList = ({isLoading, setIsLoading}: ChatListPageProps) => {

    - {message.data?.map((systemMessage, index) => - systemMessage.agent_name === "Orchestrator" ? ( -
    -
    - {systemMessage.steps && - systemMessage.steps.map((text, i) => ( + {message.data?.map((systemMessage, index) => { + if (systemMessage.agent_name === "Orchestrator") { + return ( +
    +
    + {systemMessage.steps && + systemMessage.steps.map((text, i) => ( +
    +
    + +
    + + {text} + +
    + ))} +
    +
    + ); + } + + if (systemMessage.agent_name === "Plan Approval") { + const approvalId = + (systemMessage.metadata?.approval_id as + | string + | undefined) ?? + planApprovalRequest?.id ?? + ""; + const showForm = + !!planApprovalRequest && + approvalId.length > 0 && + planApprovalRequest.id === approvalId; + return ( +
    +
    + + {systemMessage.instructions} + +
    + {showForm ? ( + <>
    + handleOutputSelection( + outputsList.findIndex( + (item) => item.agent === "Plan Approval" + ) + ) + } + className="rounded-md py-2 px-4 bg-secondary text-secondary-foreground flex items-center justify-between cursor-pointer transition-all hover:shadow-md hover:scale-102 duration-300 mb-3" > -
    - -
    - - {text} - + 📋 Click to view plan +
    - ))} + {showPlanFeedback ? ( + <> +
    + +