feat(platform): Chat UI refinements with simplified tool status indicators (#11337)

This commit is contained in:
Swifty
2025-11-07 22:49:03 +01:00
committed by GitHub
parent e68896a25a
commit 8058b9487b
16 changed files with 102 additions and 289 deletions

View File

@@ -39,6 +39,7 @@ class StreamToolCallStart(StreamBaseResponse):
"""Tool call started notification."""
type: ResponseType = ResponseType.TOOL_CALL_START
tool_name: str = Field(..., description="Name of the tool that was executed")
tool_id: str = Field(..., description="Unique tool call ID")

View File

@@ -440,9 +440,11 @@ async def _stream_chat_chunks(
if (
idx not in emitted_start_for_idx
and tool_calls[idx]["id"]
and tool_calls[idx]["function"]["name"]
):
yield StreamToolCallStart(
tool_id=tool_calls[idx]["id"],
tool_name=tool_calls[idx]["function"]["name"],
timestamp=datetime.now(UTC).isoformat(),
)
emitted_start_for_idx.add(idx)

View File

@@ -43,7 +43,7 @@ async def execute_tool(
"find_agent": find_agent_tool,
"get_agent_details": get_agent_details_tool,
"get_required_setup_info": get_required_setup_info_tool,
"setup_agent": setup_agent_tool,
"schedule_agent": setup_agent_tool,
"run_agent": run_agent_tool,
}
if tool_name not in tool_map:

View File

@@ -120,7 +120,7 @@ class FindAgentTool(BaseTool):
f"Found {len(agents)} agent{'s' if len(agents) != 1 else ''} for '{query}'"
)
return AgentCarouselResponse(
message="Now you have found some options for the user to choose from. Please ask the user if they would like to use any of these agents. If they do, please call the get_agent_details tool for this agent.",
message="Now you have found some options for the user to choose from. You can add a link to a recommended agent at: /marketplace/agent/agent_id Please ask the user if they would like to use any of these agents. If they do, please call the get_agent_details tool for this agent.",
title=title,
agents=agents,
count=len(agents),

View File

@@ -204,7 +204,7 @@ class GetAgentDetailsTool(BaseTool):
)
return AgentDetailsResponse(
message=f"Found agent '{agent_details.name}'. You do not need to run this tool again for this agent.",
message=f"Found agent '{agent_details.name}'. When presenting the agent you do not need to mention the required credentials. You do not need to run this tool again for this agent.",
session_id=session_id,
agent=agent_details,
user_authenticated=user_id is not None,

View File

@@ -66,6 +66,7 @@ class AgentCarouselResponse(ToolResponseBase):
title: str = "Available Agents"
agents: list[AgentInfo]
count: int
name: str = "agent_carousel"
class NoResultsResponse(ToolResponseBase):
@@ -73,6 +74,7 @@ class NoResultsResponse(ToolResponseBase):
type: ResponseType = ResponseType.NO_RESULTS
suggestions: list[str] = []
name: str = "no_results"
# Agent details models

View File

@@ -248,7 +248,7 @@ class RunAgentTool(BaseTool):
)
return ExecutionStartedResponse(
message="Agent execution successfully started. Do not run this tool again unless specifically asked to run the agent again.",
message=f"Agent execution successfully started. You can add a link to the agent at: /library/agents/{library_agent.id}. Do not run this tool again unless specifically asked to run the agent again.",
session_id=session_id,
execution_id=execution.id,
graph_id=library_agent.graph_id,

View File

@@ -273,7 +273,7 @@ class SetupAgentTool(BaseTool):
)
return ExecutionStartedResponse(
message="Agent execution successfully scheduled. Do not run this tool again unless specifically asked to run the agent again.",
message=f"Agent execution successfully scheduled. You can add a link to the agent at: /library/agents/{library_agent.id}. Do not run this tool again unless specifically asked to run the agent again.",
session_id=session_id,
execution_id=result.id,
graph_id=library_agent.graph_id,

View File

@@ -145,6 +145,7 @@ export function parseToolResponse(
if (isAgentArray(agentsData)) {
return {
type: "agent_carousel",
toolName: "agent_carousel",
agents: agentsData,
totalCount: parsedResult.total_count as number | undefined,
timestamp: timestamp || new Date(),
@@ -156,6 +157,7 @@ export function parseToolResponse(
if (responseType === "execution_started") {
return {
type: "execution_started",
toolName: "execution_started",
executionId: (parsedResult.execution_id as string) || "",
agentName: parsedResult.agent_name as string | undefined,
message: parsedResult.message as string | undefined,
@@ -165,6 +167,7 @@ export function parseToolResponse(
if (responseType === "need_login") {
return {
type: "login_needed",
toolName: "login_needed",
message:
(parsedResult.message as string) ||
"Please sign in to use chat and agent features",
@@ -260,6 +263,7 @@ export function extractCredentialsNeeded(
}));
return {
type: "credentials_needed",
toolName: "get_required_setup_info",
credentials,
message: `To run ${agentName}, you need to add ${credentials.length === 1 ? "credentials" : `${credentials.length} credentials`}.`,
agentName,

View File

@@ -141,6 +141,7 @@ export function handleLoginNeeded(
) {
const loginNeededMessage: ChatMessageData = {
type: "login_needed",
toolName: "login_needed",
message: chunk.message || "Please sign in to use chat and agent features",
sessionId: chunk.session_id || deps.sessionId,
agentInfo: chunk.agent_info,

View File

@@ -9,12 +9,9 @@ import { ToolCallMessage } from "@/app/(platform)/chat/components/ToolCallMessag
import { ToolResponseMessage } from "@/app/(platform)/chat/components/ToolResponseMessage/ToolResponseMessage";
import { AuthPromptWidget } from "@/app/(platform)/chat/components/AuthPromptWidget/AuthPromptWidget";
import { ChatCredentialsSetup } from "@/app/(platform)/chat/components/ChatCredentialsSetup/ChatCredentialsSetup";
import { NoResultsMessage } from "@/app/(platform)/chat/components/NoResultsMessage/NoResultsMessage";
import { AgentCarouselMessage } from "@/app/(platform)/chat/components/AgentCarouselMessage/AgentCarouselMessage";
import { ExecutionStartedMessage } from "@/app/(platform)/chat/components/ExecutionStartedMessage/ExecutionStartedMessage";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useChatMessage, type ChatMessageData } from "./useChatMessage";
import { getToolActionPhrase } from "@/app/(platform)/chat/helpers";
export interface ChatMessageProps {
message: ChatMessageData;
className?: string;
@@ -38,9 +35,6 @@ export function ChatMessage({
isToolResponse,
isLoginNeeded,
isCredentialsNeeded,
isNoResults,
isAgentCarousel,
isExecutionStarted,
} = useChatMessage(message);
const handleAllCredentialsComplete = useCallback(
@@ -129,63 +123,25 @@ export function ChatMessage({
if (isToolCall && message.type === "tool_call") {
return (
<div className={cn("px-4 py-2", className)}>
<ToolCallMessage
toolId={message.toolId}
toolName={message.toolName}
arguments={message.arguments}
/>
<ToolCallMessage toolName={message.toolName} />
</div>
);
}
// Render tool response messages
if (isToolResponse && message.type === "tool_response") {
if (
(isToolResponse && message.type === "tool_response") ||
message.type === "no_results" ||
message.type === "agent_carousel" ||
message.type === "execution_started"
) {
return (
<div className={cn("px-4 py-2", className)}>
<ToolResponseMessage
toolId={message.toolId}
toolName={message.toolName}
result={message.result}
success={message.success}
/>
<ToolResponseMessage toolName={getToolActionPhrase(message.toolName)} />
</div>
);
}
// Render no results messages
if (isNoResults && message.type === "no_results") {
return (
<NoResultsMessage
message={message.message}
suggestions={message.suggestions}
className={className}
/>
);
}
// Render agent carousel messages
if (isAgentCarousel && message.type === "agent_carousel") {
return (
<AgentCarouselMessage
agents={message.agents}
totalCount={message.totalCount}
className={className}
/>
);
}
// Render execution started messages
if (isExecutionStarted && message.type === "execution_started") {
return (
<ExecutionStartedMessage
executionId={message.executionId}
agentName={message.agentName}
message={message.message}
className={className}
/>
);
}
// Render regular chat messages
if (message.type === "message") {
return (

View File

@@ -25,6 +25,7 @@ export type ChatMessageData =
}
| {
type: "login_needed";
toolName: string;
message: string;
sessionId: string;
agentInfo?: {
@@ -36,6 +37,7 @@ export type ChatMessageData =
}
| {
type: "credentials_needed";
toolName: string;
credentials: Array<{
provider: string;
providerName: string;
@@ -49,6 +51,7 @@ export type ChatMessageData =
}
| {
type: "no_results";
toolName: string;
message: string;
suggestions?: string[];
sessionId?: string;
@@ -56,6 +59,7 @@ export type ChatMessageData =
}
| {
type: "agent_carousel";
toolName: string;
agents: Array<{
id: string;
name: string;
@@ -67,6 +71,7 @@ export type ChatMessageData =
}
| {
type: "execution_started";
toolName: string;
executionId: string;
agentName?: string;
message?: string;

View File

@@ -1,29 +1,18 @@
import React, { useState } from "react";
import { Text } from "@/components/atoms/Text/Text";
import { Wrench, Spinner, CaretDown, CaretUp } from "@phosphor-icons/react";
import React from "react";
import { WrenchIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
import { getToolDisplayName } from "@/app/(platform)/chat/helpers";
import type { ToolArguments } from "@/types/chat";
import { getToolActionPhrase } from "@/app/(platform)/chat/helpers";
export interface ToolCallMessageProps {
toolId: string;
toolName: string;
arguments?: ToolArguments;
className?: string;
}
export function ToolCallMessage({
toolId,
toolName,
arguments: args,
className,
}: ToolCallMessageProps) {
const [isExpanded, setIsExpanded] = useState(false);
export function ToolCallMessage({ toolName, className }: ToolCallMessageProps) {
return (
<div
className={cn(
"overflow-hidden rounded-lg border transition-all duration-200",
"mx-10 max-w-[70%] overflow-hidden rounded-lg border transition-all duration-200",
"border-neutral-200 dark:border-neutral-700",
"bg-white dark:bg-neutral-900",
"animate-in fade-in-50 slide-in-from-top-1",
@@ -37,72 +26,24 @@ export function ToolCallMessage({
"bg-gradient-to-r from-neutral-50 to-neutral-100 dark:from-neutral-800/20 dark:to-neutral-700/20",
)}
>
<div className="flex items-center gap-2">
<Wrench
<div className="flex items-center gap-2 overflow-hidden">
<WrenchIcon
size={16}
weight="bold"
className="text-neutral-500 dark:text-neutral-400"
className="flex-shrink-0 text-neutral-500 dark:text-neutral-400"
/>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{getToolDisplayName(toolName)}
<span className="relative inline-block overflow-hidden text-sm font-medium text-neutral-700 dark:text-neutral-300">
{getToolActionPhrase(toolName)}...
<span
className={cn(
"absolute inset-0 bg-gradient-to-r from-transparent via-white/50 to-transparent",
"dark:via-white/20",
"animate-shimmer",
)}
/>
</span>
<div className="ml-2 flex items-center gap-1.5">
<Spinner
size={16}
weight="bold"
className="animate-spin text-blue-500"
/>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
Executing...
</span>
</div>
</div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="rounded p-1 hover:bg-neutral-200/50 dark:hover:bg-neutral-700/50"
aria-label={isExpanded ? "Collapse details" : "Expand details"}
>
{isExpanded ? (
<CaretUp
size={16}
weight="bold"
className="text-neutral-600 dark:text-neutral-400"
/>
) : (
<CaretDown
size={16}
weight="bold"
className="text-neutral-600 dark:text-neutral-400"
/>
)}
</button>
</div>
{/* Expandable Content */}
{isExpanded && (
<div className="px-4 py-3">
{args && Object.keys(args).length > 0 && (
<div className="mb-3">
<div className="mb-2 text-xs font-medium text-neutral-600 dark:text-neutral-400">
Parameters:
</div>
<div className="rounded-md bg-neutral-50 p-3 dark:bg-neutral-800">
<pre className="overflow-x-auto text-xs text-neutral-700 dark:text-neutral-300">
{JSON.stringify(args, null, 2)}
</pre>
</div>
</div>
)}
<Text
variant="small"
className="text-neutral-500 dark:text-neutral-400"
>
Tool ID: {toolId.slice(0, 8)}...
</Text>
</div>
)}
</div>
);
}

View File

@@ -1,90 +1,23 @@
import React, { useState } from "react";
import { Text } from "@/components/atoms/Text/Text";
import {
CheckCircle,
XCircle,
CaretDown,
CaretUp,
Wrench,
} from "@phosphor-icons/react";
import React from "react";
import { WrenchIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
import { getToolDisplayName } from "@/app/(platform)/chat/helpers";
import type { ToolResult } from "@/types/chat";
import { getToolActionPhrase } from "@/app/(platform)/chat/helpers";
export interface ToolResponseMessageProps {
toolId: string;
toolName: string;
result: ToolResult;
success?: boolean;
className?: string;
}
// Check if result should be hidden (special response types)
function shouldHideResult(result: ToolResult): boolean {
try {
const resultString =
typeof result === "string" ? result : JSON.stringify(result);
const parsed = JSON.parse(resultString);
// Hide raw JSON for these special types
if (parsed.type === "agent_carousel") return true;
if (parsed.type === "execution_started") return true;
if (parsed.type === "setup_requirements") return true;
if (parsed.type === "no_results") return true;
return false;
} catch {
return false;
}
}
// Get a friendly summary for special response types
function getResultSummary(result: ToolResult): string | null {
try {
const resultString =
typeof result === "string" ? result : JSON.stringify(result);
const parsed = JSON.parse(resultString);
if (parsed.type === "agent_carousel") {
return `Found ${parsed.agents?.length || parsed.count || 0} agents${parsed.query ? ` matching "${parsed.query}"` : ""}`;
}
if (parsed.type === "execution_started") {
return `Started execution${parsed.execution_id ? ` (ID: ${parsed.execution_id.slice(0, 8)}...)` : ""}`;
}
if (parsed.type === "setup_requirements") {
return "Retrieved setup requirements";
}
if (parsed.type === "no_results") {
return parsed.message || "No results found";
}
return null;
} catch {
return null;
}
}
export function ToolResponseMessage({
toolId,
toolName,
result,
success = true,
className,
}: ToolResponseMessageProps) {
const [isExpanded, setIsExpanded] = useState(false);
const hideResult = shouldHideResult(result);
const resultSummary = getResultSummary(result);
const resultString =
typeof result === "object"
? JSON.stringify(result, null, 2)
: String(result);
const shouldTruncate = resultString.length > 200;
return (
<div
className={cn(
"overflow-hidden rounded-lg border transition-all duration-200",
"mx-10 max-w-[70%] overflow-hidden rounded-lg border transition-all duration-200",
success
? "border-neutral-200 dark:border-neutral-700"
: "border-red-200 dark:border-red-800",
@@ -104,97 +37,16 @@ export function ToolResponseMessage({
)}
>
<div className="flex items-center gap-2">
<Wrench
<WrenchIcon
size={16}
weight="bold"
className="text-neutral-500 dark:text-neutral-400"
/>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{getToolDisplayName(toolName)}
{getToolActionPhrase(toolName)}...
</span>
<div className="ml-2 flex items-center gap-1.5">
{success ? (
<CheckCircle size={16} weight="fill" className="text-green-500" />
) : (
<XCircle size={16} weight="fill" className="text-red-500" />
)}
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{success ? "Completed" : "Error"}
</span>
</div>
</div>
{!hideResult && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="rounded p-1 hover:bg-neutral-200/50 dark:hover:bg-neutral-700/50"
aria-label={isExpanded ? "Collapse details" : "Expand details"}
>
{isExpanded ? (
<CaretUp
size={16}
weight="bold"
className="text-neutral-600 dark:text-neutral-400"
/>
) : (
<CaretDown
size={16}
weight="bold"
className="text-neutral-600 dark:text-neutral-400"
/>
)}
</button>
)}
</div>
{/* Expandable Content */}
{isExpanded && !hideResult && (
<div className="px-4 py-3">
<div className="mb-2 text-xs font-medium text-neutral-600 dark:text-neutral-400">
Result:
</div>
<div
className={cn(
"rounded-md p-3",
success
? "bg-green-50 dark:bg-green-900/20"
: "bg-red-50 dark:bg-red-900/20",
)}
>
<pre
className={cn(
"whitespace-pre-wrap text-xs",
success
? "text-green-800 dark:text-green-200"
: "text-red-800 dark:text-red-200",
)}
>
{shouldTruncate && !isExpanded
? `${resultString.slice(0, 200)}...`
: resultString}
</pre>
</div>
<Text
variant="small"
className="mt-2 text-neutral-500 dark:text-neutral-400"
>
Tool ID: {toolId.slice(0, 8)}...
</Text>
</div>
)}
{/* Summary for special response types */}
{hideResult && resultSummary && (
<div className="px-4 py-2">
<Text
variant="small"
className="text-neutral-600 dark:text-neutral-400"
>
{resultSummary}
</Text>
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,6 @@
/**
* Maps internal tool names to user-friendly display names with emojis.
* @deprecated Use getToolActionPhrase or getToolCompletionPhrase for status messages
*
* @param toolName - The internal tool name from the backend
* @returns A user-friendly display name with an emoji prefix
@@ -16,6 +17,54 @@ export function getToolDisplayName(toolName: string): string {
return toolDisplayNames[toolName] || toolName;
}
/**
* Maps internal tool names to human-friendly action phrases (present continuous).
* Used for tool call messages to indicate what action is currently happening.
*
* @param toolName - The internal tool name from the backend
* @returns A human-friendly action phrase in present continuous tense
*/
export function getToolActionPhrase(toolName: string): string {
const toolActionPhrases: Record<string, string> = {
find_agent: "Looking for agents in the marketplace",
agent_carousel: "Looking for agents in the marketplace",
get_agent_details: "Learning about the agent",
check_credentials: "Checking your credentials",
setup_agent: "Setting up the agent",
execution_started: "Running the agent",
run_agent: "Running the agent",
get_required_setup_info: "Getting setup requirements",
schedule_agent: "Scheduling the agent to run",
};
// Return mapped phrase or generate human-friendly fallback
return toolActionPhrases[toolName] || toolName;
}
/**
* Maps internal tool names to human-friendly completion phrases (past tense).
* Used for tool response messages to indicate what action was completed.
*
* @param toolName - The internal tool name from the backend
* @returns A human-friendly completion phrase in past tense
*/
export function getToolCompletionPhrase(toolName: string): string {
const toolCompletionPhrases: Record<string, string> = {
find_agent: "Finished searching the marketplace",
get_agent_details: "Got agent details",
check_credentials: "Checked credentials",
setup_agent: "Agent setup complete",
run_agent: "Agent execution started",
get_required_setup_info: "Got setup requirements",
};
// Return mapped phrase or generate human-friendly fallback
return (
toolCompletionPhrases[toolName] ||
`Finished ${toolName.replace(/_/g, " ").replace("...", "")}`
);
}
/** Validate UUID v4 format */
export function isValidUUID(value: string): boolean {
const uuidRegex =

View File

@@ -13,7 +13,7 @@ export function TallyPopupSimple() {
}
return (
<div className="fixed bottom-1 right-24 z-20 hidden select-none items-center gap-4 p-3 transition-all duration-300 ease-in-out md:flex">
<div className="fixed bottom-1 right-0 z-20 hidden select-none items-center gap-4 p-3 transition-all duration-300 ease-in-out md:flex">
{state.showTutorial && (
<Button
variant="primary"