diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx index 5161103f4b..620c108388 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx @@ -50,6 +50,7 @@ function renderSegments( segments: RenderSegment[], messageID: string, onRetry?: () => void, + isLastMessage?: boolean, ): React.ReactNode[] { return segments.map((seg, segIdx) => { if (seg.kind === "collapsed-group") { @@ -62,6 +63,7 @@ function renderSegments( messageID={messageID} partIndex={seg.index} onRetry={onRetry} + isLastMessage={isLastMessage} /> ); }); @@ -372,6 +374,7 @@ export function ChatMessagesContainer({ responseSegments, message.id, isLastAssistant ? onRetry : undefined, + isLastAssistant, ) : message.parts.map((part, i) => ( ))} {isLastInTurn && !isCurrentlyStreaming && ( diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/MessagePartRenderer.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/MessagePartRenderer.tsx index 136192a7d3..b86f73d86b 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/MessagePartRenderer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/MessagePartRenderer.tsx @@ -94,6 +94,7 @@ interface Props { messageID: string; partIndex: number; onRetry?: () => void; + isLastMessage?: boolean; } export function MessagePartRenderer({ @@ -101,6 +102,7 @@ export function MessagePartRenderer({ messageID, partIndex, onRetry, + isLastMessage, }: Props) { const key = `${messageID}-${partIndex}`; @@ -169,7 +171,13 @@ export function MessagePartRenderer({ case "tool-schedule_agent": return ; case "tool-decompose_goal": - return ; + return ( + + ); case "tool-create_agent": return ; case "tool-edit_agent": diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/DecomposeGoal.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/DecomposeGoal.tsx index 18abeb9fba..fde9c67dc4 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/DecomposeGoal.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/DecomposeGoal.tsx @@ -1,13 +1,18 @@ "use client"; import { Button } from "@/components/atoms/Button/Button"; -import { CheckIcon, PencilSimpleIcon } from "@phosphor-icons/react"; +import { + CheckIcon, + PencilSimpleIcon, + PlusIcon, + TrashIcon, +} from "@phosphor-icons/react"; import type { ToolUIPart } from "ai"; +import { useEffect, useRef, useState } from "react"; import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions"; import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation"; import { ContentGrid, - ContentHint, ContentMessage, } from "../../components/ToolAccordion/AccordionContent"; import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion"; @@ -22,11 +27,24 @@ import { ToolIcon, } from "./helpers"; -interface Props { - part: ToolUIPart; +const COUNTDOWN_SECONDS = 99; +const RADIUS = 15; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +interface EditableStep { + step_id: string; + description: string; + action: string; + block_name?: string | null; + status: string; } -export function DecomposeGoalTool({ part }: Props) { +interface Props { + part: ToolUIPart; + isLastMessage?: boolean; +} + +export function DecomposeGoalTool({ part, isLastMessage }: Props) { const text = getAnimationText(part); const { onSend } = useCopilotChatActions(); @@ -34,20 +52,102 @@ export function DecomposeGoalTool({ part }: Props) { part.state === "input-streaming" || part.state === "input-available"; const output = getDecomposeGoalOutput(part); - const isError = part.state === "output-error" || (!!output && isErrorOutput(output)); - const isOperating = !output; - function handleApprove() { - onSend("Approved. Please build the agent."); + const showActions = + !!isLastMessage && + !!output && + isDecompositionOutput(output) && + output.requires_approval; + + const [secondsLeft, setSecondsLeft] = useState(COUNTDOWN_SECONDS); + // timerActive becomes false when the user clicks Modify — stops countdown and auto-approve. + const [timerActive, setTimerActive] = useState(true); + const [isEditing, setIsEditing] = useState(false); + const [editableSteps, setEditableSteps] = useState([]); + + const approvedRef = useRef(false); + const onSendRef = useRef(onSend); + const isEditingRef = useRef(isEditing); + const editableStepsRef = useRef(editableSteps); + onSendRef.current = onSend; + isEditingRef.current = isEditing; + editableStepsRef.current = editableSteps; + + function buildMessage() { + if (isEditingRef.current && editableStepsRef.current.length > 0) { + const list = editableStepsRef.current + .map((s, i) => `${i + 1}. ${s.description}`) + .join("; "); + return `Approved with modifications. Please build the agent following these steps: ${list}`; + } + return "Approved. Please build the agent."; + } + + function approve() { + if (approvedRef.current) return; + approvedRef.current = true; + setIsEditing(false); + onSendRef.current(buildMessage()); } function handleModify() { - onSend("I'd like to modify the plan. Here are my changes: "); + if (!output || !isDecompositionOutput(output)) return; + setTimerActive(false); + setIsEditing(true); + setEditableSteps(output.steps.map((s) => ({ ...s }))); } + function handleStepChange(index: number, description: string) { + setEditableSteps((prev) => + prev.map((s, i) => (i === index ? { ...s, description } : s)), + ); + } + + function handleStepDelete(index: number) { + setEditableSteps((prev) => prev.filter((_, i) => i !== index)); + } + + // Insert a blank step after the given index (-1 = prepend). + function handleStepInsert(afterIndex: number) { + setEditableSteps((prev) => { + const next = [...prev]; + next.splice(afterIndex + 1, 0, { + step_id: `step_new_${Date.now()}`, + description: "", + action: "add_block", + status: "pending", + }); + return next; + }); + } + + // Tick down only while the timer is active. + useEffect(() => { + if (!showActions || !timerActive) return; + const interval = setInterval(() => { + setSecondsLeft((s) => Math.max(0, s - 1)); + }, 1000); + return () => clearInterval(interval); + }, [showActions, timerActive, part.toolCallId]); + + // Auto-approve when countdown reaches 0 (only if timer is still active). + useEffect(() => { + if (secondsLeft === 0 && timerActive) approve(); + // approve is stable via ref — intentionally omitted + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [secondsLeft, timerActive]); + + const progress = secondsLeft / COUNTDOWN_SECONDS; + const dashOffset = CIRCUMFERENCE * (1 - progress); + const stepCount = isEditing + ? editableSteps.length + : output && isDecompositionOutput(output) + ? output.step_count + : 0; + return (
{isOperating && ( @@ -76,49 +176,149 @@ export function DecomposeGoalTool({ part }: Props) { {output && isDecompositionOutput(output) && ( } - title={`Build Plan — ${output.step_count} steps`} + title={`Build Plan — ${stepCount} steps`} description={output.goal} defaultExpanded > {output.message} -
- {output.steps.map((step, i) => ( - - ))} +
+ {isEditing ? ( +
+ {/* Insert before the first step */} + handleStepInsert(-1)} /> + + {editableSteps.map((step, i) => ( +
+
+ + {i + 1}. + + handleStepChange(i, e.target.value)} + className="flex-1 rounded border border-slate-200 px-2 py-1 text-sm focus:border-neutral-400 focus:outline-none" + placeholder="Step description" + /> + +
+ {/* Insert after each step */} + handleStepInsert(i)} /> +
+ ))} +
+ ) : ( +
+ {output.steps.map((step, i) => ( + + ))} +
+ )}
- {output.requires_approval && ( + {showActions && (
- - + {isEditing ? ( + + ) : ( + <> + {/* Timer button — same ghost style as Modify, ring wraps the number inline */} + + | + + + )}
)} - - - Review the plan above and approve to start building. - )}
); } + +function InsertButton({ onClick }: { onClick: () => void }) { + return ( +
+
+ +
+
+ ); +}