From 2d5fd08be2c9b46ee68995ef5a707df122364c9d Mon Sep 17 00:00:00 2001 From: mohd-talib0 Date: Mon, 24 Nov 2025 19:14:29 +0530 Subject: [PATCH 1/2] fix(docker): support Windows by using bridge networking and service names - Replace network_mode: host with port mappings in docker-compose.yaml - Change localhost:8000 to agentic_browser:8000 in web_surfer.py and orchestrator_agent.py Fixes inter-container communication on Windows Docker Desktop. --- cortex_on/agents/orchestrator_agent.py | 2 +- cortex_on/agents/web_surfer.py | 2 +- docker-compose.yaml | 9 ++++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cortex_on/agents/orchestrator_agent.py b/cortex_on/agents/orchestrator_agent.py index 6b001ba..2e9fc94 100644 --- a/cortex_on/agents/orchestrator_agent.py +++ b/cortex_on/agents/orchestrator_agent.py @@ -308,7 +308,7 @@ async def web_surfer_task(ctx: RunContext[orchestrator_deps], task: str) -> str: await _safe_websocket_send(ctx.deps.websocket, web_surfer_stream_output) # 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( 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/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" From a493a42c0cbc5105c29affc590055d031c13b682 Mon Sep 17 00:00:00 2001 From: mohd-talib0 Date: Wed, 3 Dec 2025 19:24:13 +0530 Subject: [PATCH 2/2] feat(orchestrator): implement plan approval workflow with feedback loop Add comprehensive plan review system allowing users to control task execution before agents begin work. Plans now require explicit user approval. Plan Approval Features: - Users can approve plans to start execution ("Approve & Run") - Users can request modifications via text feedback ("Request Changes") - Users can cancel plans gracefully ("Cancel Run") - Feedback loop regenerates plan with AI incorporating user input - Maximum 5 regeneration attempts to prevent infinite loops - Plan display is read-only (modifications only via feedback) Step-by-Step Approval: - Optional checkbox: "Require approval before each web action" - Optional checkbox: "Require approval before each coding step" - Individual step approval/rejection with optional notes Graceful Cancellation: - Add UserCancellationError custom exception - Display friendly message: "Task cancelled by user. No changes were made." - No Python tracebacks shown to users Backend Changes: - orchestrator_agent.py: - Add UserCancellationError exception class - Extend orchestrator_deps with approval state fields - Add _request_plan_approval() for plan approval handling - Add _ensure_step_approval() for step-level approvals - Modify plan_task with approval loop and retry logic - Update system prompt with plan execution guidelines - instructor.py: - Import and handle UserCancellationError - Return friendly cancellation message - stream_response_format.py: - Add metadata field for approval state Frontend Changes: - ChatList.tsx: - Add PlanApprovalState and StepApprovalState types - Add state variables for approval workflow - Add handlePlanApprovalSubmit() for approve/cancel - Add handlePlanFeedbackSubmit() for feedback submission - Add handleStepApprovalDecision() for step approvals - Add Plan Approval UI with buttons and checkboxes - Add Step Approval UI with rejection notes - Plan display uses read-only Markdown component - chatTypes.ts: - Add metadata field to SystemMessage interface WebSocket Message Types: - plan_approval: approve or cancel plan - plan_feedback: request changes with feedback text - step_approval: approve or reject individual steps Infra: - ta-browser/Dockerfile: Update base to python:3.11-bookworm --- cortex_on/agents/orchestrator_agent.py | 367 ++++++++- cortex_on/instructor.py | 15 +- cortex_on/utils/stream_response_format.py | 1 + frontend/src/components/home/ChatList.tsx | 890 ++++++++++++++++------ frontend/src/types/chatTypes.ts | 1 + ta-browser/Dockerfile | 2 +- 6 files changed, 1020 insertions(+), 256 deletions(-) diff --git a/cortex_on/agents/orchestrator_agent.py b/cortex_on/agents/orchestrator_agent.py index 2e9fc94..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,6 +455,20 @@ 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://agentic_browser:8000/api/v1/web/stream") @@ -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/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/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 ? ( + <> +
    + +