Show tool calls as distinct steps + render markdown in responses

- Backend emits tool_call events with tool_name, label, args, and status
- Frontend shows each tool call as a step with ✓/spinner/✗ indicator
- Tool call steps show icon, label, date range, and filters
- AI responses rendered with ReactMarkdown (bold, lists, tables, code)
- Cursor-like UX: Thinking → tool calls → Analyzing → streamed answer

Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-02-24 22:14:50 +00:00
parent 838be8fcb4
commit 9eae9183be
5 changed files with 190 additions and 36 deletions

View file

@ -425,7 +425,6 @@ async def stream_usage_ai_chat(
handler = TOOL_HANDLERS.get(fn_name)
if not handler:
yield _sse({"type": "status", "message": f"Unknown tool: {fn_name}"})
chat_messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
@ -434,8 +433,11 @@ async def stream_usage_ai_chat(
continue
yield _sse({
"type": "status",
"message": f"Fetching {handler['label']} ({fn_args.get('start_date', '')} to {fn_args.get('end_date', '')})..."
"type": "tool_call",
"tool_name": fn_name,
"tool_label": handler["label"],
"arguments": fn_args,
"status": "running",
})
try:
@ -458,8 +460,24 @@ async def stream_usage_ai_chat(
raw_data = await handler["fetch"](**fetch_kwargs)
tool_result = handler["summarise"](raw_data)
yield _sse({
"type": "tool_call",
"tool_name": fn_name,
"tool_label": handler["label"],
"arguments": fn_args,
"status": "complete",
})
except Exception as e:
tool_result = f"Error fetching {handler['label']}: {str(e)}"
yield _sse({
"type": "tool_call",
"tool_name": fn_name,
"tool_label": handler["label"],
"arguments": fn_args,
"status": "error",
"error": str(e),
})
chat_messages.append({
"role": "tool",

View file

@ -200,12 +200,15 @@ class TestStreamUsageAiChat:
events.append(json.loads(event.replace("data: ", "").strip()))
status_events = [e for e in events if e["type"] == "status"]
tool_call_events = [e for e in events if e["type"] == "tool_call"]
chunk_events = [e for e in events if e["type"] == "chunk"]
done_events = [e for e in events if e["type"] == "done"]
assert len(status_events) >= 2
assert len(status_events) >= 1
assert "Thinking" in status_events[0]["message"]
assert "Fetching" in status_events[1]["message"]
assert len(tool_call_events) >= 1
assert tool_call_events[0]["tool_name"] == "get_usage_data"
assert tool_call_events[0]["status"] in ("running", "complete")
assert len(chunk_events) >= 1
assert len(done_events) == 1

View file

@ -42,7 +42,7 @@ describe("UsageAIChatPanel", () => {
it("should render model selector", () => {
renderWithProviders(<UsageAIChatPanel {...defaultProps} />);
expect(screen.getByText("Select a model")).toBeInTheDocument();
expect(screen.getByText("Select a model (optional, defaults to gpt-4o-mini)")).toBeInTheDocument();
});
it("should render empty state message when no conversation", () => {

View file

@ -1,13 +1,23 @@
import React, { useEffect, useRef, useState } from "react";
import { Select, Input, Spin } from "antd";
import { Button } from "@tremor/react";
import { modelHubCall, usageAiChatStream } from "../../networking";
import ReactMarkdown from "react-markdown";
import { modelHubCall, usageAiChatStream, UsageAiToolCallEvent } from "../../networking";
const { TextArea } = Input;
interface ToolCallStep {
tool_name: string;
tool_label: string;
arguments: Record<string, string>;
status: "running" | "complete" | "error";
error?: string;
}
interface ChatMessage {
role: "user" | "assistant";
content: string;
toolCalls?: ToolCallStep[];
}
interface UsageAIChatPanelProps {
@ -16,6 +26,83 @@ interface UsageAIChatPanelProps {
accessToken: string | null;
}
const TOOL_ICONS: Record<string, string> = {
get_usage_data: "📊",
get_team_usage_data: "👥",
get_tag_usage_data: "🏷️",
};
const ToolCallDisplay: React.FC<{ step: ToolCallStep }> = ({ step }) => {
const icon = TOOL_ICONS[step.tool_name] || "🔧";
const args = step.arguments;
const dateRange = args.start_date && args.end_date
? `${args.start_date}${args.end_date}`
: "";
const filter = args.team_ids || args.tags || args.user_id || "";
return (
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-gray-100 border border-gray-200 text-xs">
<span className="flex-shrink-0 mt-0.5">
{step.status === "running" ? (
<Spin size="small" />
) : step.status === "error" ? (
<span className="text-red-500"></span>
) : (
<span className="text-green-600"></span>
)}
</span>
<div className="min-w-0">
<div className="font-medium text-gray-700">
{icon} {step.tool_label}
</div>
{dateRange && (
<div className="text-gray-500 mt-0.5">{dateRange}</div>
)}
{filter && (
<div className="text-gray-500 mt-0.5">Filter: {filter}</div>
)}
{step.status === "error" && step.error && (
<div className="text-red-600 mt-0.5">{step.error}</div>
)}
</div>
</div>
);
};
const MarkdownContent: React.FC<{ content: string }> = ({ content }) => (
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
ul: ({ children }) => <ul className="list-disc pl-4 mb-2 space-y-0.5">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-4 mb-2 space-y-0.5">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
h1: ({ children }) => <h4 className="font-semibold text-sm mt-2 mb-1">{children}</h4>,
h2: ({ children }) => <h4 className="font-semibold text-sm mt-2 mb-1">{children}</h4>,
h3: ({ children }) => <h4 className="font-semibold text-sm mt-2 mb-1">{children}</h4>,
code: ({ children, className }) => {
const isBlock = className?.includes("language-");
return isBlock ? (
<pre className="bg-gray-100 rounded p-2 my-1 overflow-x-auto text-xs">
<code>{children}</code>
</pre>
) : (
<code className="px-1 py-0.5 rounded bg-gray-100 text-xs font-mono">{children}</code>
);
},
table: ({ children }) => (
<div className="overflow-x-auto my-2">
<table className="text-xs border-collapse w-full">{children}</table>
</div>
),
th: ({ children }) => <th className="border border-gray-200 px-2 py-1 bg-gray-50 font-medium text-left">{children}</th>,
td: ({ children }) => <td className="border border-gray-200 px-2 py-1">{children}</td>,
}}
>
{content}
</ReactMarkdown>
);
const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
open,
onClose,
@ -29,6 +116,7 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [streamingContent, setStreamingContent] = useState("");
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [activeToolCalls, setActiveToolCalls] = useState<ToolCallStep[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
@ -42,7 +130,7 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
if (typeof messagesEndRef.current?.scrollIntoView === "function") {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages, streamingContent]);
}, [messages, streamingContent, activeToolCalls, statusMessage]);
const loadModels = async () => {
if (!accessToken) return;
@ -72,11 +160,13 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
setIsLoading(true);
setStreamingContent("");
setStatusMessage(null);
setActiveToolCalls([]);
const abortController = new AbortController();
abortControllerRef.current = abortController;
let accumulated = "";
const toolCalls: ToolCallStep[] = [];
try {
await usageAiChatStream(
@ -90,11 +180,16 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
},
() => {
setStatusMessage(null);
setMessages((prev) => [...prev, { role: "assistant", content: accumulated }]);
setActiveToolCalls([]);
setMessages((prev) => [
...prev,
{ role: "assistant", content: accumulated, toolCalls: toolCalls.length > 0 ? [...toolCalls] : undefined },
]);
setStreamingContent("");
},
(errorMsg: string) => {
setStatusMessage(null);
setActiveToolCalls([]);
setMessages((prev) => [
...prev,
{ role: "assistant", content: `Error: ${errorMsg}` },
@ -104,6 +199,15 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
(status: string) => {
setStatusMessage(status);
},
(event: UsageAiToolCallEvent) => {
const idx = toolCalls.findIndex((tc) => tc.tool_name === event.tool_name);
if (idx >= 0) {
toolCalls[idx] = { ...event };
} else {
toolCalls.push({ ...event });
}
setActiveToolCalls([...toolCalls]);
},
abortController.signal,
);
} catch (error: any) {
@ -139,6 +243,8 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
const handleClear = () => {
setMessages([]);
setStreamingContent("");
setActiveToolCalls([]);
setStatusMessage(null);
};
return (
@ -175,11 +281,12 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
{/* Model selector */}
<div className="px-5 py-3 border-b border-gray-100 flex-shrink-0">
<Select
placeholder="Select a model"
placeholder="Select a model (optional, defaults to gpt-4o-mini)"
value={selectedModel}
onChange={(value) => setSelectedModel(value)}
loading={isLoadingModels}
showSearch
allowClear
size="small"
className="w-full"
options={availableModels.map((m) => ({ label: m, value: m }))}
@ -191,7 +298,7 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
{/* Chat messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50">
{messages.length === 0 && !streamingContent && (
{messages.length === 0 && !streamingContent && !isLoading && (
<div className="flex flex-col items-center justify-center h-full text-gray-400">
<svg className="w-8 h-8 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
@ -202,38 +309,53 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
)}
{messages.map((msg, idx) => (
<div
key={idx}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[88%] rounded-xl px-3.5 py-2 text-sm leading-relaxed ${
msg.role === "user"
? "bg-blue-600 text-white"
: "bg-white border border-gray-200 text-gray-800"
}`}
>
<div className="whitespace-pre-wrap break-words">{msg.content}</div>
</div>
<div key={idx}>
{msg.role === "user" ? (
<div className="flex justify-end">
<div className="max-w-[88%] rounded-xl px-3.5 py-2 text-sm leading-relaxed bg-blue-600 text-white">
{msg.content}
</div>
</div>
) : (
<div className="space-y-2">
{/* Tool calls for this message */}
{msg.toolCalls && msg.toolCalls.length > 0 && (
<div className="space-y-1.5">
{msg.toolCalls.map((tc, tcIdx) => (
<ToolCallDisplay key={tcIdx} step={tc} />
))}
</div>
)}
{/* Response */}
<div className="max-w-[95%] rounded-xl px-3.5 py-2.5 text-sm leading-relaxed bg-white border border-gray-200 text-gray-800">
<MarkdownContent content={msg.content} />
</div>
</div>
)}
</div>
))}
{streamingContent && (
<div className="flex justify-start">
<div className="max-w-[88%] rounded-xl px-3.5 py-2 text-sm leading-relaxed bg-white border border-gray-200 text-gray-800">
<div className="whitespace-pre-wrap break-words">{streamingContent}</div>
</div>
{/* Active tool calls (in-progress) */}
{isLoading && activeToolCalls.length > 0 && (
<div className="space-y-1.5">
{activeToolCalls.map((tc, idx) => (
<ToolCallDisplay key={idx} step={tc} />
))}
</div>
)}
{/* Status / spinner */}
{isLoading && !streamingContent && (
<div className="flex justify-start">
<div className="rounded-xl px-3.5 py-2 bg-white border border-gray-200 flex items-center gap-2">
<Spin size="small" />
{statusMessage && (
<span className="text-xs text-gray-500 italic">{statusMessage}</span>
)}
</div>
<div className="flex items-center gap-2 px-3 py-2 text-xs text-gray-500">
<Spin size="small" />
<span className="italic">{statusMessage || "Thinking..."}</span>
</div>
)}
{/* Streaming response */}
{streamingContent && (
<div className="max-w-[95%] rounded-xl px-3.5 py-2.5 text-sm leading-relaxed bg-white border border-gray-200 text-gray-800">
<MarkdownContent content={streamingContent} />
</div>
)}

View file

@ -5907,6 +5907,14 @@ export const enrichPolicyTemplateStream = async (
}
};
export interface UsageAiToolCallEvent {
tool_name: string;
tool_label: string;
arguments: Record<string, string>;
status: "running" | "complete" | "error";
error?: string;
}
export const usageAiChatStream = async (
accessToken: string,
messages: { role: string; content: string }[],
@ -5915,6 +5923,7 @@ export const usageAiChatStream = async (
onDone: () => void,
onError?: (error: string) => void,
onStatus?: (message: string) => void,
onToolCall?: (event: UsageAiToolCallEvent) => void,
signal?: AbortSignal,
) => {
const url = proxyBaseUrl
@ -5960,6 +5969,8 @@ export const usageAiChatStream = async (
onChunk(event.content);
} else if (event.type === "status") {
onStatus?.(event.message);
} else if (event.type === "tool_call") {
onToolCall?.(event as UsageAiToolCallEvent);
} else if (event.type === "done") {
onDone();
} else if (event.type === "error") {