Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 80 additions & 17 deletions ui/src/components/chat/AgentCallDisplay.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { createContext, useContext, useMemo, useState, useEffect } from "react";
import { FunctionCall, TokenStats } from "@/types";
import { createContext, useContext, useMemo, useState, useEffect, useCallback } from "react";
import { FunctionCall, TokenStats, AgentResponse } from "@/types";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { convertToUserFriendlyName } from "@/lib/utils";
import { convertToUserFriendlyName, isAgentToolName } from "@/lib/utils";
import { ChevronDown, ChevronUp, MessageSquare, Loader2, AlertCircle, CheckCircle, Activity } from "lucide-react";
import KagentLogo from "../kagent-logo";
import TokenStatsTooltip from "@/components/chat/TokenStatsTooltip";
import { getSubagentSessionWithEvents } from "@/app/actions/sessions";
import { getAgent } from "@/app/actions/agents";
import { Message, Task } from "@a2a-js/sdk";
import { extractMessagesFromTasks } from "@/lib/messageHandlers";
import ChatMessage from "@/components/chat/ChatMessage";
import ToolCallGroup, { groupToolCallMessages, buildToolCallResultsIndex, collectPendingApprovalIds } from "@/components/chat/ToolCallGroup";
import { ChatMcpAppsProvider, useChatMcpApps } from "@/components/chat/ChatMcpAppsContext";

// Track and avoid too deep nested agent viewing to avoid UI issues
// In theory this works for infinite depth
Expand All @@ -32,12 +35,80 @@ interface AgentCallDisplayProps {
interface SubagentActivityPanelProps {
sessionId: string;
isComplete: boolean;
/** The agent tool name (namespace__NS__agent_name) used to resolve the subagent's MCP apps. */
agentToolName: string;
}

function SubagentActivityPanel({ sessionId, isComplete }: SubagentActivityPanelProps) {
/**
* Renders the subagent transcript with the MCP apps registry from the
* ENCLOSING provider — the panel nests a ChatMcpAppsProvider scoped to the
* subagent so its MCP app tool calls (which the parent agent doesn't know
* about) resolve to their interactive UI. Tool-call runs fold into the same
* collapsible groups as the main chat.
*/
function SubagentMessageList({ messages }: { messages: Message[] }) {
const { getMcpAppForTool } = useChatMcpApps();

const isStandaloneToolName = useCallback(
(toolName: string) => isAgentToolName(toolName) || !!getMcpAppForTool(toolName),
[getMcpAppForTool],
);
const renderItems = useMemo(() => {
const pendingApprovalIds = collectPendingApprovalIds(messages);
return groupToolCallMessages(messages, { isStandaloneToolName, pendingApprovalIds });
}, [messages, isStandaloneToolName]);
const resultsByCallId = useMemo(() => buildToolCallResultsIndex(messages), [messages]);

const renderMsg = (msg: Message) => (
<ChatMessage
key={msg.messageId}
message={msg}
allMessages={messages}
getMcpAppForTool={getMcpAppForTool}
// Read-only: no approve/reject/ask-user/app-send callbacks
/>
);

return (
<div className="space-y-1 mt-1">
{renderItems.map((item) =>
item.kind === "group" ? (
<ToolCallGroup key={`subagent-group-${item.startIndex}`} messages={item.messages} resultsByCallId={resultsByCallId}>
{item.messages.map(renderMsg)}
</ToolCallGroup>
) : (
renderMsg(item.message)
),
)}
</div>
);
}

function SubagentActivityPanel({ sessionId, isComplete, agentToolName }: SubagentActivityPanelProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [error, setError] = useState<string | null>(null);
const [waiting, setWaiting] = useState(true);
const [subagent, setSubagent] = useState<AgentResponse | null>(null);

// Resolve the subagent so its MCP app tools render their interactive UI.
// Tool names encode namespace/name as namespace__NS__name with hyphens
// replaced by underscores; convertToUserFriendlyName reverses both.
useEffect(() => {
if (!isAgentToolName(agentToolName)) return;
const [namespace, name] = convertToUserFriendlyName(agentToolName).split("/");
if (!namespace || !name) return;
let cancelled = false;
getAgent(name, namespace)
.then((resp) => {
if (!cancelled && resp.data) setSubagent(resp.data);
})
.catch(() => {
// Fall back to the parent chat's app registry.
});
return () => {
cancelled = true;
};
}, [agentToolName]);

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -110,18 +181,10 @@ function SubagentActivityPanel({ sessionId, isComplete }: SubagentActivityPanelP
);
}

return (
<div className="space-y-1 mt-1">
{messages.map((msg) => (
<ChatMessage
key={msg.messageId}
message={msg}
allMessages={messages}
// Read-only: no approve/reject/ask-user callbacks
/>
))}
</div>
);
const list = <SubagentMessageList messages={messages} />;
// Scope the MCP apps registry to the subagent's own tool servers; without
// the subagent (fetch pending/failed) fall through to the parent's provider.
return subagent ? <ChatMcpAppsProvider currentAgent={subagent}>{list}</ChatMcpAppsProvider> : list;
}

const AgentCallDisplay = ({ call, result, status = "requested", isError = false, tokenStats, subagentSessionId }: AgentCallDisplayProps) => {
Expand Down Expand Up @@ -245,7 +308,7 @@ const AgentCallDisplay = ({ call, result, status = "requested", isError = false,
{activityExpanded && (
<ActivityDepthContext.Provider value={activityDepth + 1}>
<div className="mt-2 border rounded bg-muted/20 p-2 max-h-96 overflow-y-auto">
<SubagentActivityPanel sessionId={subagentSessionId} isComplete={status === "completed"} />
<SubagentActivityPanel sessionId={subagentSessionId} isComplete={status === "completed"} agentToolName={call.name} />
</div>
</ActivityDepthContext.Provider>
)}
Expand Down
94 changes: 60 additions & 34 deletions ui/src/components/chat/ChatInterface.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import type React from "react";
import { useState, useRef, useEffect, useMemo } from "react";
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
import { ArrowBigUp, X, Loader2, Mic, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Expand All @@ -14,6 +14,8 @@ import { useSpeechRecognition } from "@/hooks/useSpeechRecognition";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import ChatMessage from "@/components/chat/ChatMessage";
import ToolCallGroup, { groupToolCallMessages, buildToolCallResultsIndex, collectPendingApprovalIds } from "@/components/chat/ToolCallGroup";
import { isAgentToolName } from "@/lib/utils";
import ChatMinimap from "@/components/chat/ChatMinimap";
import StreamingMessage from "./StreamingMessage";
import SessionTokenStatsDisplay from "@/components/chat/TokenStats";
Expand Down Expand Up @@ -151,6 +153,31 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se

const allMessages = useMemo(() => [...storedMessages, ...streamingMessages], [storedMessages, streamingMessages]);

// Fold consecutive runs of tool-call messages into collapsible groups.
// MCP app calls render interactive UI and subagent calls render the
// AgentCallDisplay activity panel, so both stay outside the groups.
// Approval requests stay outside only while undecided (pendingDecisions
// makes decided approvals fold in immediately, before the server responds).
const isStandaloneToolName = useCallback(
(toolName: string) => isAgentToolName(toolName) || !!getMcpAppForTool(toolName),
[getMcpAppForTool],
);
const pendingApprovalIds = useMemo(
() => collectPendingApprovalIds(allMessages, pendingDecisions),
[allMessages, pendingDecisions],
);
const groupingOptions = useMemo(
() => ({ isStandaloneToolName, pendingDecisions, pendingApprovalIds }),
[isStandaloneToolName, pendingDecisions, pendingApprovalIds],
);
// Group over the COMBINED transcript (stored + streaming) so a run that
// spans the boundary — e.g. an approval request persisted at
// input_required and its tool result arriving on the post-approval stream —
// folds into a single group instead of two.
const renderItems = useMemo(() => groupToolCallMessages(allMessages, groupingOptions), [allMessages, groupingOptions]);
// Shared call_id -> is_error lookup so each group summary is O(group size).
const toolResultsByCallId = useMemo(() => buildToolCallResultsIndex(allMessages), [allMessages]);

const { handleMessageEvent } = useMemo(() => createMessageHandlers({
setMessages: setStreamingMessages,
setIsStreaming,
Expand Down Expand Up @@ -1026,6 +1053,36 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se
}
};

const renderChatMessage = (message: Message, key: string) => (
<ChatMessage
key={key}
message={message}
allMessages={allMessages}
agentContext={agentContext}
onApprove={shareReadOnly ? undefined : handleApprove}
onReject={shareReadOnly ? undefined : handleReject}
onAskUserSubmit={shareReadOnly ? undefined : handleAskUserSubmit}
pendingDecisions={pendingDecisions}
getMcpAppForTool={getMcpAppForTool}
onMcpAppSendMessage={handleMcpAppSendMessage}
/>
);

const renderMessageItems = (items: ReturnType<typeof groupToolCallMessages>, keyPrefix: string) =>
items.map(item =>
item.kind === "group" ? (
<div key={`${keyPrefix}-group-${item.startIndex}`} data-mm-item data-mm-role="assistant">
<ToolCallGroup messages={item.messages} resultsByCallId={toolResultsByCallId}>
{item.messages.map((message, j) => renderChatMessage(message, `${keyPrefix}-${item.startIndex + j}`))}
</ToolCallGroup>
</div>
) : (
<div key={`${keyPrefix}-${item.startIndex}`} data-mm-item data-mm-role={item.message.role === "user" ? "user" : "assistant"}>
{renderChatMessage(item.message, `${keyPrefix}-msg-${item.startIndex}`)}
</div>
)
);

if (sessionNotFound) {
return (
<div className="flex h-full w-full flex-col items-center justify-center p-4 text-center">
Expand Down Expand Up @@ -1069,39 +1126,8 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se
</div>
) : (
<>
{/* Display stored messages from session */}
{storedMessages.map((message, index) => {
return <div key={`stored-${index}`} data-mm-item data-mm-role={message.role === "user" ? "user" : "assistant"}>
<ChatMessage
message={message}
allMessages={allMessages}
agentContext={agentContext}
onApprove={shareReadOnly ? undefined : handleApprove}
onReject={shareReadOnly ? undefined : handleReject}
onAskUserSubmit={shareReadOnly ? undefined : handleAskUserSubmit}
pendingDecisions={pendingDecisions}
getMcpAppForTool={getMcpAppForTool}
onMcpAppSendMessage={handleMcpAppSendMessage}
/>
</div>
})}

{/* Display streaming messages */}
{streamingMessages.map((message, index) => {
return <div key={`stream-${index}`} data-mm-item data-mm-role={message.role === "user" ? "user" : "assistant"}>
<ChatMessage
message={message}
allMessages={allMessages}
agentContext={agentContext}
onApprove={shareReadOnly ? undefined : handleApprove}
onReject={shareReadOnly ? undefined : handleReject}
onAskUserSubmit={shareReadOnly ? undefined : handleAskUserSubmit}
pendingDecisions={pendingDecisions}
getMcpAppForTool={getMcpAppForTool}
onMcpAppSendMessage={handleMcpAppSendMessage}
/>
</div>
})}
{/* Display all messages (stored + streaming) as one grouped list */}
{renderMessageItems(renderItems, "msg")}

{isStreaming && (
<div data-mm-item data-mm-role="assistant">
Expand Down
Loading
Loading