feat(chat): enhance streaming status and tool call events

This commit is contained in:
Rodrigo Verdiani 2026-06-11 21:46:53 -03:00
parent bf080eed88
commit 4498a3f666
3 changed files with 171 additions and 55 deletions

View File

@ -29,6 +29,8 @@ export function AIChatPanel() {
isLoadingMessages,
isStreaming,
streamingContent,
toolStatus,
isToolRunning,
streamError,
isCreatingSession,
createSession,
@ -215,8 +217,6 @@ export function AIChatPanel() {
<>
{messages.map((message, idx) => {
const isUser = message.role === "USER";
const isLastMessage = idx === messages.length - 1;
const isStreamingMessage = isLastMessage && isStreaming && !message.id;
return (
<div
@ -245,20 +245,6 @@ export function AIChatPanel() {
>
{isUser ? (
<div className="break-words whitespace-pre-wrap">{message.content}</div>
) : isStreamingMessage ? (
<div className="break-words whitespace-pre-wrap">
{streamingContent ? (
<>
{streamingContent}
<span className="ml-0.5 inline-block h-4 w-1 animate-pulse bg-current align-middle" />
</>
) : (
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Analyzing...</span>
</div>
)}
</div>
) : (
<div className="[&_th]:border-border [&_td]:border-border max-w-none break-words [&_ol]:my-2 [&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_pre]:my-2 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_ul]:my-2">
<ReactMarkdown
@ -323,6 +309,40 @@ export function AIChatPanel() {
})}
</>
)}
{isStreaming && (
<>
<div className="mb-4 flex gap-3">
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
<Bot className="h-4 w-4" />
</div>
<div className="flex max-w-[85%] min-w-0 flex-col items-start gap-1">
<div className="bg-muted overflow-hidden rounded-lg px-3 py-2 text-sm">
<div className="break-words whitespace-pre-wrap">
{streamingContent ? (
<>
{streamingContent}
<span className="ml-0.5 inline-block h-4 w-1 animate-pulse bg-current align-middle" />
</>
) : (
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="text-primary h-4 w-4 animate-spin" />
<span>Analyzing...</span>
</div>
)}
</div>
</div>
</div>
</div>
{toolStatus && (
<div className="text-muted-foreground bg-muted border-l-primary my-1 flex items-center gap-2 rounded-lg border-l-[3px] px-3 py-2 text-sm">
{isToolRunning && (
<Loader2 className="text-primary h-3.5 w-3.5 shrink-0 animate-spin" />
)}
<span>{toolStatus}</span>
</div>
)}
</>
)}
<div ref={messagesEndRef} />
</div>
)}

View File

@ -10,6 +10,46 @@ import {
} from "@/api/generated/chat/chat.ts";
import type { SessionResponse, MessageResponse } from "@/api/generated/api.schemas.ts";
interface ChunkEvent {
type: "chunk";
content: string;
}
interface ToolCallStartEvent {
type: "tool_call_start";
tool: string;
}
interface ToolCallEndEvent {
type: "tool_call_end";
tool: string;
durationMs: number;
resultSummary: string;
}
interface ThinkingEvent {
type: "thinking";
message: string;
}
interface DoneEvent {
type: "done";
sessionId: string;
}
interface ErrorEvent {
type: "error";
message: string;
}
type SSEEvent =
| ChunkEvent
| ToolCallStartEvent
| ToolCallEndEvent
| ThinkingEvent
| DoneEvent
| ErrorEvent;
export function useAIChat() {
const params = useParams();
const binaryId = params.binaryId!;
@ -20,6 +60,8 @@ export function useAIChat() {
const [streamingContent, setStreamingContent] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [streamError, setStreamError] = useState<string | null>(null);
const [toolStatus, setToolStatus] = useState<string | null>(null);
const [isToolRunning, setIsToolRunning] = useState(false);
const [pendingUserMessage, setPendingUserMessage] = useState<MessageResponse | null>(null);
const { data: binary } = useGetBinary(binaryId, {
@ -89,6 +131,8 @@ export function useAIChat() {
setIsStreaming(true);
setStreamingContent("");
setToolStatus(null);
setIsToolRunning(false);
setStreamError(null);
setPendingUserMessage({
role: "USER",
@ -137,12 +181,33 @@ export function useAIChat() {
if (!line.startsWith("data:")) continue;
const event = JSON.parse(line.slice(5).trim());
if (event.type === "chunk") {
fullContent += event.content;
const sseEvent = event as SSEEvent;
switch (sseEvent.type) {
case "chunk":
fullContent += sseEvent.content;
setStreamingContent(fullContent);
} else if (event.type === "done") {
setToolStatus(null);
setIsToolRunning(false);
break;
case "tool_call_start":
setToolStatus(`Calling ${sseEvent.tool}...`);
setIsToolRunning(true);
break;
case "tool_call_end":
setToolStatus(
`${sseEvent.tool} completed (${sseEvent.durationMs}ms): ${sseEvent.resultSummary}`
);
setIsToolRunning(false);
break;
case "thinking":
setToolStatus(sseEvent.message);
setIsToolRunning(true);
break;
case "done":
setIsStreaming(false);
setStreamingContent("");
setToolStatus(null);
setIsToolRunning(false);
setPendingUserMessage(null);
queryClient.invalidateQueries({
queryKey: [`/chat/sessions/${activeSessionId}/messages`],
@ -150,10 +215,14 @@ export function useAIChat() {
queryClient.invalidateQueries({
queryKey: [`/binaries/${binaryId}/chat/sessions`],
});
} else if (event.type === "error") {
break;
case "error":
setIsStreaming(false);
setToolStatus(null);
setIsToolRunning(false);
setPendingUserMessage(null);
setStreamError(event.message);
setStreamError(sseEvent.message);
break;
}
}
}
@ -164,12 +233,33 @@ export function useAIChat() {
if (!line.startsWith("data:")) continue;
const event = JSON.parse(line.slice(5).trim());
if (event.type === "chunk") {
fullContent += event.content;
const sseEvent = event as SSEEvent;
switch (sseEvent.type) {
case "chunk":
fullContent += sseEvent.content;
setStreamingContent(fullContent);
} else if (event.type === "done") {
setToolStatus(null);
setIsToolRunning(false);
break;
case "tool_call_start":
setToolStatus(`Calling ${sseEvent.tool}...`);
setIsToolRunning(true);
break;
case "tool_call_end":
setToolStatus(
`${sseEvent.tool} completed (${sseEvent.durationMs}ms): ${sseEvent.resultSummary}`
);
setIsToolRunning(false);
break;
case "thinking":
setToolStatus(sseEvent.message);
setIsToolRunning(true);
break;
case "done":
setIsStreaming(false);
setStreamingContent("");
setToolStatus(null);
setIsToolRunning(false);
setPendingUserMessage(null);
queryClient.invalidateQueries({
queryKey: [`/chat/sessions/${activeSessionId}/messages`],
@ -177,10 +267,14 @@ export function useAIChat() {
queryClient.invalidateQueries({
queryKey: [`/binaries/${binaryId}/chat/sessions`],
});
} else if (event.type === "error") {
break;
case "error":
setIsStreaming(false);
setToolStatus(null);
setIsToolRunning(false);
setPendingUserMessage(null);
setStreamError(event.message);
setStreamError(sseEvent.message);
break;
}
}
}
@ -190,6 +284,8 @@ export function useAIChat() {
} catch (err: unknown) {
if (err instanceof Error && err.name !== "AbortError") {
setIsStreaming(false);
setToolStatus(null);
setIsToolRunning(false);
setPendingUserMessage(null);
setStreamError(err.message || "An unexpected error occurred");
}
@ -201,17 +297,14 @@ export function useAIChat() {
const cancelStream = useCallback(() => {
abortRef.current?.abort();
setIsStreaming(false);
setToolStatus(null);
setIsToolRunning(false);
setPendingUserMessage(null);
}, []);
const messages: MessageResponse[] = [...historicalMessages];
if (isStreaming && pendingUserMessage) {
messages.push(pendingUserMessage);
messages.push({
role: "ASSISTANT",
content: streamingContent,
createdAt: new Date().toISOString(),
});
}
const userFriendlyError = streamError
@ -231,6 +324,8 @@ export function useAIChat() {
isLoadingMessages: messagesQuery.isLoading,
isStreaming,
streamingContent,
toolStatus,
isToolRunning,
streamError: userFriendlyError,
isCreatingSession: createSessionMutation.isPending,
createSession,

View File

@ -28,6 +28,7 @@
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-success: var(--success);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);