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 ffe55659db55..df253468ef87 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 @@ -5,15 +5,19 @@ import { } from "@/components/ai-elements/conversation"; import { Message, MessageContent } from "@/components/ai-elements/message"; import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner"; -import { FileUIPart, UIDataTypes, UIMessage, UITools } from "ai"; +import { FileUIPart, ToolUIPart, UIDataTypes, UIMessage, UITools } from "ai"; import { TOOL_PART_PREFIX } from "../JobStatsBar/constants"; import { TurnStatsBar } from "../JobStatsBar/TurnStatsBar"; import { parseSpecialMarkers } from "./helpers"; import { AssistantMessageActions } from "./components/AssistantMessageActions"; +import { CollapsedToolGroup } from "./components/CollapsedToolGroup"; import { MessageAttachments } from "./components/MessageAttachments"; import { MessagePartRenderer } from "./components/MessagePartRenderer"; +import { ReasoningCollapse } from "./components/ReasoningCollapse"; import { ThinkingIndicator } from "./components/ThinkingIndicator"; +type MessagePart = UIMessage["parts"][number]; + interface Props { messages: UIMessage[]; status: string; @@ -23,6 +27,132 @@ interface Props { sessionID?: string | null; } +function isCompletedToolPart(part: MessagePart): part is ToolUIPart { + return ( + part.type.startsWith("tool-") && + "state" in part && + (part.state === "output-available" || part.state === "output-error") + ); +} + +type RenderSegment = + | { kind: "part"; part: MessagePart; index: number } + | { kind: "collapsed-group"; parts: ToolUIPart[] }; + +// Tool types that have custom renderers and should NOT be collapsed +const CUSTOM_TOOL_TYPES = new Set([ + "tool-find_block", + "tool-find_agent", + "tool-find_library_agent", + "tool-search_docs", + "tool-get_doc_page", + "tool-run_block", + "tool-run_mcp_tool", + "tool-run_agent", + "tool-schedule_agent", + "tool-create_agent", + "tool-edit_agent", + "tool-view_agent_output", + "tool-search_feature_requests", + "tool-create_feature_request", +]); + +/** + * Groups consecutive completed generic tool parts into collapsed segments. + * Non-generic tools (those with custom renderers) and active/streaming tools + * are left as individual parts. + */ +function buildRenderSegments( + parts: MessagePart[], + baseIndex = 0, +): RenderSegment[] { + const segments: RenderSegment[] = []; + let pendingGroup: Array<{ part: ToolUIPart; index: number }> | null = null; + + function flushGroup() { + if (!pendingGroup) return; + if (pendingGroup.length >= 2) { + segments.push({ + kind: "collapsed-group", + parts: pendingGroup.map((p) => p.part), + }); + } else { + for (const p of pendingGroup) { + segments.push({ kind: "part", part: p.part, index: p.index }); + } + } + pendingGroup = null; + } + + parts.forEach((part, i) => { + const absoluteIndex = baseIndex + i; + const isGenericCompletedTool = + isCompletedToolPart(part) && !CUSTOM_TOOL_TYPES.has(part.type); + + if (isGenericCompletedTool) { + if (!pendingGroup) pendingGroup = []; + pendingGroup.push({ part: part as ToolUIPart, index: absoluteIndex }); + } else { + flushGroup(); + segments.push({ kind: "part", part, index: absoluteIndex }); + } + }); + + flushGroup(); + return segments; +} + +/** + * For finalized assistant messages, split parts into "reasoning" (intermediate + * text + tools before the final response) and "response" (final text after the + * last tool). If there are no tools, everything is response. + */ +function splitReasoningAndResponse(parts: MessagePart[]): { + reasoning: MessagePart[]; + response: MessagePart[]; +} { + const lastToolIndex = parts.findLastIndex((p) => p.type.startsWith("tool-")); + + // No tools → everything is response + if (lastToolIndex === -1) { + return { reasoning: [], response: parts }; + } + + // Check if there's any text after the last tool + const hasResponseAfterTools = parts + .slice(lastToolIndex + 1) + .some((p) => p.type === "text"); + + if (!hasResponseAfterTools) { + // No final text response → don't collapse anything + return { reasoning: [], response: parts }; + } + + return { + reasoning: parts.slice(0, lastToolIndex + 1), + response: parts.slice(lastToolIndex + 1), + }; +} + +function renderSegments( + segments: RenderSegment[], + messageID: string, +): React.ReactNode[] { + return segments.map((seg, segIdx) => { + if (seg.kind === "collapsed-group") { + return ; + } + return ( + + ); + }); +} + /** Collect all messages belonging to a turn: the user message + every * assistant message up to (but not including) the next user message. */ function getTurnMessages( @@ -119,6 +249,24 @@ export function ChatMessagesContainer({ (p): p is FileUIPart => p.type === "file", ); + // For finalized assistant messages, split into reasoning + response. + // During streaming, show everything normally with tool collapsing. + const isFinalized = + message.role === "assistant" && !isCurrentlyStreaming; + const { reasoning, response } = isFinalized + ? splitReasoningAndResponse(message.parts) + : { reasoning: [] as MessagePart[], response: message.parts }; + const hasReasoning = reasoning.length > 0; + + const responseStartIndex = message.parts.length - response.length; + const responseSegments = + message.role === "assistant" + ? buildRenderSegments(response, responseStartIndex) + : null; + const reasoningSegments = hasReasoning + ? buildRenderSegments(reasoning, 0) + : null; + return ( - {message.parts.map((part, i) => ( - - ))} + {hasReasoning && reasoningSegments && ( + + {renderSegments(reasoningSegments, message.id)} + + )} + {responseSegments + ? renderSegments(responseSegments, message.id) + : message.parts.map((part, i) => ( + + ))} {isLastInTurn && !isCurrentlyStreaming && ( + ); + } + + const iconClass = "text-green-500"; + switch (category) { + case "bash": + return ; + case "web": + return ; + case "browser": + return ; + case "file-read": + case "file-write": + return ; + case "file-delete": + return ; + case "file-list": + return ; + case "search": + return ( + + ); + case "edit": + return ( + + ); + case "todo": + return ( + + ); + case "compaction": + return ( + + ); + default: + return ; + } +} + +export function CollapsedToolGroup({ parts }: Props) { + const [expanded, setExpanded] = useState(false); + const panelId = useId(); + + const errorCount = parts.filter((p) => p.state === "output-error").length; + const label = + errorCount > 0 + ? `${parts.length} tool calls (${errorCount} failed)` + : `${parts.length} tool calls completed`; + + return ( +
+ + + {expanded && ( +
+ {parts.map((part) => { + const toolName = extractToolName(part); + const category = getToolCategory(toolName); + const text = getAnimationText(part, category); + const isError = part.state === "output-error"; + + return ( +
+ + {text} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/FeedbackModal.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/FeedbackModal.tsx index ad9bda11eee3..fe0e47de107d 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/FeedbackModal.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/FeedbackModal.tsx @@ -15,6 +15,7 @@ export function FeedbackModal({ isOpen, onSubmit, onCancel }: Props) { const [comment, setComment] = useState(""); function handleSubmit() { + if (!comment.trim()) return; onSubmit(comment); setComment(""); } @@ -36,7 +37,7 @@ export function FeedbackModal({ isOpen, onSubmit, onCancel }: Props) { >
-

+

Your feedback helps us improve. Share details below.