From f467ead8552f258ece1765c96356e9052977b071 Mon Sep 17 00:00:00 2001 From: anvyle Date: Fri, 10 Apr 2026 18:49:17 +0200 Subject: [PATCH] fix(copilot): disable decompose_goal Approve/Modify while message is streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the build plan box appears, the assistant continues streaming a short summary text. Clicking Approve or Modify in that 1-2s window failed because the chat session is locked to the in-flight turn — sending a new user message gets rejected. - ChatMessagesContainer now forwards isCurrentlyStreaming through renderSegments → MessagePartRenderer → DecomposeGoalTool. - DecomposeGoalTool computes actionsEnabled = showActions && !streaming and uses it to (a) disable the Approve, Modify, and timer buttons and (b) gate the auto-approve effect so the timer can hit 0 mid-stream without firing — the effect re-runs and approves once streaming ends. - The countdown ring keeps ticking during streaming so it stays in sync with the server-side timer. Co-Authored-By: Claude Sonnet 4.6 --- .../ChatMessagesContainer.tsx | 4 ++ .../components/MessagePartRenderer.tsx | 3 ++ .../tools/DecomposeGoal/DecomposeGoal.tsx | 46 +++++++++++++++---- 3 files changed, 45 insertions(+), 8 deletions(-) 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 620c108388..f2c0d9e442 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 @@ -51,6 +51,7 @@ function renderSegments( messageID: string, onRetry?: () => void, isLastMessage?: boolean, + isMessageStreaming?: boolean, ): React.ReactNode[] { return segments.map((seg, segIdx) => { if (seg.kind === "collapsed-group") { @@ -64,6 +65,7 @@ function renderSegments( partIndex={seg.index} onRetry={onRetry} isLastMessage={isLastMessage} + isMessageStreaming={isMessageStreaming} /> ); }); @@ -375,6 +377,7 @@ export function ChatMessagesContainer({ message.id, isLastAssistant ? onRetry : undefined, isLastAssistant, + isCurrentlyStreaming, ) : 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 b86f73d86b..a4dbfb04be 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 @@ -95,6 +95,7 @@ interface Props { partIndex: number; onRetry?: () => void; isLastMessage?: boolean; + isMessageStreaming?: boolean; } export function MessagePartRenderer({ @@ -103,6 +104,7 @@ export function MessagePartRenderer({ partIndex, onRetry, isLastMessage, + isMessageStreaming, }: Props) { const key = `${messageID}-${partIndex}`; @@ -176,6 +178,7 @@ export function MessagePartRenderer({ key={key} part={part as ToolUIPart} isLastMessage={isLastMessage} + isMessageStreaming={isMessageStreaming} /> ); case "tool-create_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 53992d9e13..33b7e5939c 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 @@ -68,9 +68,17 @@ interface EditableStep { interface Props { part: ToolUIPart; isLastMessage?: boolean; + // True while the parent assistant message is still streaming. We disable + // Approve/Modify in this window because the chat session is locked to + // the in-flight turn — sending a new user message would fail. + isMessageStreaming?: boolean; } -export function DecomposeGoalTool({ part, isLastMessage }: Props) { +export function DecomposeGoalTool({ + part, + isLastMessage, + isMessageStreaming, +}: Props) { const text = getAnimationText(part); const { onSend } = useCopilotChatActions(); @@ -88,6 +96,12 @@ export function DecomposeGoalTool({ part, isLastMessage }: Props) { isDecompositionOutput(output) && output.requires_approval; + // The Approve/Modify buttons are visible (so the user knows what's + // coming) but click-disabled while the assistant is still streaming + // its summary text after the tool call. The countdown ring keeps + // ticking so it stays in sync with the server-side timer. + const actionsEnabled = showActions && !isMessageStreaming; + // Authoritative countdown comes from the backend tool response so the // server-side fallback timer and the client are guaranteed to agree. const countdownSeconds = @@ -181,14 +195,16 @@ export function DecomposeGoalTool({ part, isLastMessage }: Props) { return () => clearInterval(interval); }, [showActions, timerActive, part.toolCallId]); - // Auto-approve when countdown reaches 0 (only if timer is still active and actions visible). - // showActions prevents firing after isLastMessage changes (race condition guard). + // Auto-approve when countdown reaches 0 — but only after the assistant + // has finished streaming its summary text. Firing during streaming would + // hit the same locked-session failure as a manual click. If the timer + // hits 0 mid-stream, this effect re-runs when actionsEnabled flips true. // approve() is stable via approvedRef — safe to omit from deps. useEffect(() => { - if (secondsLeft === 0 && timerActive && showActions) { + if (secondsLeft === 0 && timerActive && actionsEnabled) { approve(); } - }, [secondsLeft, timerActive, showActions]); // approve reads refs only — safe to omit + }, [secondsLeft, timerActive, actionsEnabled]); // approve reads refs only — safe to omit const progress = secondsLeft / countdownSeconds; const dashOffset = CIRCUMFERENCE * (1 - progress); @@ -292,7 +308,11 @@ export function DecomposeGoalTool({ part, isLastMessage }: Props) { {showActions && (
{isEditing ? ( - -