From e16b19ed667e8dcbdc67464d9d9894c79831cdb0 Mon Sep 17 00:00:00 2001 From: Yunxiao Xu Date: Fri, 13 Feb 2026 03:13:58 -0800 Subject: [PATCH] feat(ux): Implement advanced reasoning view with progress tracking and refined Markdown rendering. --- .../src/components/chat/ChatInterface.tsx | 20 ++++-- .../src/components/chat/ExecutionStatus.tsx | 64 +++++++++++++++++++ .../src/components/chat/MessageBubble.tsx | 20 ------ frontend/src/services/chat.test.ts | 4 +- frontend/src/services/chat.ts | 42 ++++++++---- frontend/src/services/chat_history.test.ts | 13 ++-- 6 files changed, 114 insertions(+), 49 deletions(-) create mode 100644 frontend/src/components/chat/ExecutionStatus.tsx diff --git a/frontend/src/components/chat/ChatInterface.tsx b/frontend/src/components/chat/ChatInterface.tsx index f606130..a35e638 100644 --- a/frontend/src/components/chat/ChatInterface.tsx +++ b/frontend/src/components/chat/ChatInterface.tsx @@ -1,6 +1,7 @@ import * as React from "react" import { MessageList } from "./MessageList" import { ChatInput } from "./ChatInput" +import { ExecutionStatus } from "./ExecutionStatus" import { ChatService, type MessageResponse } from "@/services/chat" import { AlertCircle } from "lucide-react" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" @@ -22,18 +23,17 @@ export function ChatInterface({ const [isStreaming, setIsStreaming] = React.useState(false) const [error, setError] = React.useState(null) + // Get steps from the currently active assistant message if streaming + const activeSteps = isStreaming && messages.length > 0 + ? messages[messages.length - 1].steps || [] + : [] + // Sync messages if threadId or initialMessages changes React.useEffect(() => { - console.log("ChatInterface: Syncing messages", initialMessages) setMessages(initialMessages) setError(null) }, [threadId, initialMessages]) - // Log messages changes for debugging - React.useEffect(() => { - console.log("ChatInterface: Messages state updated", messages) - }, [messages]) - const handleSendMessage = async (text: string) => { setError(null) setIsStreaming(true) @@ -69,7 +69,7 @@ export function ChatInterface({ return (
{error && ( - + Error {error} @@ -77,6 +77,12 @@ export function ChatInterface({ )} + + {isStreaming && activeSteps.length > 0 && ( +
+ +
+ )} steps.includes(p.match)).length + const totalPhases = PHASE_CONFIG.length + const progress = Math.min((completedCount / totalPhases) * 100, 100) + + return ( +
+
+
+
+
+ + {isComplete ? "100%" : `${Math.round(progress)}%`} + +
+ +
+ {steps.map((step, index) => { + const isLast = index === steps.length - 1 + const isCompletionStep = PHASE_CONFIG.some(p => p.match === step) + + return ( +
+ {isLast && !isComplete && !isCompletionStep ? ( + + ) : ( + + )} + {step} +
+ ) + })} +
+
+ ) +} diff --git a/frontend/src/components/chat/MessageBubble.tsx b/frontend/src/components/chat/MessageBubble.tsx index 0a310e6..d63ff69 100644 --- a/frontend/src/components/chat/MessageBubble.tsx +++ b/frontend/src/components/chat/MessageBubble.tsx @@ -24,26 +24,6 @@ export function MessageBubble({ message }: MessageBubbleProps) { : "bg-primary text-primary-foreground" )} > - {isAssistant && message.steps && message.steps.length > 0 && ( -
- {message.steps.map((step, index) => { - const isLast = index === message.steps!.length - 1 - return ( -
-
- {step} -
- ) - })} -
- )} -
{isAssistant ? ( message.content ? ( diff --git a/frontend/src/services/chat.test.ts b/frontend/src/services/chat.test.ts index b05200a..b1bc5e4 100644 --- a/frontend/src/services/chat.test.ts +++ b/frontend/src/services/chat.test.ts @@ -22,14 +22,14 @@ describe("ChatService SSE Parsing", () => { const events = ChatService.parseSSEChunk(rawChunk) expect(events).toHaveLength(2) - expect(events[1].data.chunk).toBe(" World") + expect(events[1].data!.chunk).toBe(" World") }) it("should parse encoded plots from executor node", () => { const rawChunk = `data: {"type": "on_chain_end", "name": "executor", "data": {"encoded_plots": ["base64data"]}}\n\n` const events = ChatService.parseSSEChunk(rawChunk) - expect(events[0].data.encoded_plots).toEqual(["base64data"]) + expect(events[0].data!.encoded_plots).toEqual(["base64data"]) }) it("should identify the done event", () => { diff --git a/frontend/src/services/chat.ts b/frontend/src/services/chat.ts index 22935b3..f8ad0e9 100644 --- a/frontend/src/services/chat.ts +++ b/frontend/src/services/chat.ts @@ -10,13 +10,26 @@ export interface MessageResponse { steps?: string[] // reasoning steps } +export interface ConversationResponse { + id: string + name: string + summary?: string + created_at: string + data_state: string +} + export interface ChatEvent { type: string name?: string node?: string data?: { chunk?: { content?: string } - output?: { messages?: { content: string }[] } + output?: { + messages?: { content: string }[] + plots?: string[] + dfs?: Record + summary?: string + } encoded_plots?: string[] message?: string } @@ -47,7 +60,7 @@ export const ChatService = { if (!dataStr) continue try { - const event = JSON.parse(dataStr) + const event = JSON.parse(dataStr) as ChatEvent events.push(event) } catch (err) { console.error("Failed to parse SSE event JSON:", err, dataStr) @@ -245,36 +258,37 @@ export const ChatService = { } activeMessages = this.updateMessagesWithEvent(activeMessages, event) - onMessageUpdate(activeMessages) + onMessageUpdate([...activeMessages]) } } - } catch (err: any) { + } catch (err: unknown) { console.error("Streaming error:", err) - if (onError) onError(err.message || "Connection failed") + const message = err instanceof Error ? err.message : "Connection failed" + if (onError) onError(message) } }, - async listConversations() { - const response = await api.get("/conversations") + async listConversations(): Promise { + const response = await api.get("/conversations") return response.data }, - async createConversation(name: string = "New Conversation") { - const response = await api.post("/conversations", { name }) + async createConversation(name: string = "New Conversation"): Promise { + const response = await api.post("/conversations", { name }) return response.data }, - async renameConversation(conversationId: string, name: string) { - const response = await api.patch(`/conversations/${conversationId}`, { name }) + async renameConversation(conversationId: string, name: string): Promise { + const response = await api.patch(`/conversations/${conversationId}`, { name }) return response.data }, - async deleteConversation(conversationId: string) { + async deleteConversation(conversationId: string): Promise { await api.delete(`/conversations/${conversationId}`) }, - async getMessages(conversationId: string) { - const response = await api.get(`/conversations/${conversationId}/messages`) + async getMessages(conversationId: string): Promise { + const response = await api.get(`/conversations/${conversationId}/messages`) return response.data } } diff --git a/frontend/src/services/chat_history.test.ts b/frontend/src/services/chat_history.test.ts index 76497f5..f49c32c 100644 --- a/frontend/src/services/chat_history.test.ts +++ b/frontend/src/services/chat_history.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import api from "./api" import { ChatService } from "./chat" +import { AxiosResponse } from "axios" vi.mock("./api") -const mockedApi = api as any +const mockedApi = vi.mocked(api) describe("ChatService History Management", () => { beforeEach(() => { @@ -12,7 +13,7 @@ describe("ChatService History Management", () => { it("should list conversations", async () => { const mockData = [{ id: "c1", name: "Conv 1" }] - mockedApi.get.mockResolvedValueOnce({ data: mockData }) + mockedApi.get.mockResolvedValueOnce({ data: mockData } as AxiosResponse) const result = await ChatService.listConversations() expect(mockedApi.get).toHaveBeenCalledWith("/conversations") @@ -21,7 +22,7 @@ describe("ChatService History Management", () => { it("should create a conversation", async () => { const mockData = { id: "c2", name: "New Conv" } - mockedApi.post.mockResolvedValueOnce({ data: mockData }) + mockedApi.post.mockResolvedValueOnce({ data: mockData } as AxiosResponse) const result = await ChatService.createConversation("New Conv") expect(mockedApi.post).toHaveBeenCalledWith("/conversations", { name: "New Conv" }) @@ -30,7 +31,7 @@ describe("ChatService History Management", () => { it("should rename a conversation", async () => { const mockData = { id: "c1", name: "Renamed" } - mockedApi.patch.mockResolvedValueOnce({ data: mockData }) + mockedApi.patch.mockResolvedValueOnce({ data: mockData } as AxiosResponse) const result = await ChatService.renameConversation("c1", "Renamed") expect(mockedApi.patch).toHaveBeenCalledWith("/conversations/c1", { name: "Renamed" }) @@ -38,7 +39,7 @@ describe("ChatService History Management", () => { }) it("should delete a conversation", async () => { - mockedApi.delete.mockResolvedValueOnce({}) + mockedApi.delete.mockResolvedValueOnce({} as AxiosResponse) await ChatService.deleteConversation("c1") expect(mockedApi.delete).toHaveBeenCalledWith("/conversations/c1") @@ -46,7 +47,7 @@ describe("ChatService History Management", () => { it("should fetch messages", async () => { const mockData = [{ id: "m1", content: "Hi" }] - mockedApi.get.mockResolvedValueOnce({ data: mockData }) + mockedApi.get.mockResolvedValueOnce({ data: mockData } as AxiosResponse) const result = await ChatService.getMessages("c1") expect(mockedApi.get).toHaveBeenCalledWith("/conversations/c1/messages")