diff --git a/README.md b/README.md index 01f6f06..ad85aec 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ React frontend for interacting with the [composable-agents](https://github.com/s ## Features -- **Agent management** -- Create, view, configure, and delete agents via YAML file upload -- **Real-time chat** -- Stream AI responses via SSE (Server-Sent Events) -- **Human-in-the-loop** -- Review and approve/reject tool calls before execution +- **Agent management** — Create, view, configure, and delete agents via YAML file upload +- **Real-time chat** — Stream AI responses via SSE with typed events (thinking, content, structured response) +- **Human-in-the-loop** — Review and approve/reject tool calls before execution - **Thread history** -- Conversation threads grouped by agent in a sidebar - **RAG file browser** -- Browse MinIO folders and files with breadcrumb navigation and file metadata display - **Material Design 3** -- Inspired design system with shadcn/ui components diff --git a/package-lock.json b/package-lock.json index 96154a5..ed92b0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@hookform/resolvers": "^5.2.2", "@microsoft/fetch-event-source": "^2.0.1", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", @@ -20,6 +21,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", @@ -31,10 +33,11 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "framer-motion": "^12.38.0", + "js-yaml": "^4.1.1", "lucide-react": "^1.7.0", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-hook-form": "^7.72.1", + "react-hook-form": "^7.73.1", "react-markdown": "^10.1.0", "react-router-dom": "^7.14.0", "remark-gfm": "^4.0.1", @@ -50,6 +53,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/js-yaml": "^4.0.9", "@types/node": "^25.5.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -720,6 +724,37 @@ "version": "1.1.3", "license": "MIT" }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-alert-dialog": { "version": "1.1.15", "license": "MIT", @@ -842,6 +877,36 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "license": "MIT", @@ -1506,6 +1571,35 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tabs": { "version": "1.1.13", "license": "MIT", @@ -2798,6 +2892,13 @@ "@types/unist": "*" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "dev": true, @@ -3335,6 +3436,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/aria-hidden": { "version": "1.2.6", "license": "MIT", @@ -4626,6 +4733,18 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "25.0.1", "dev": true, diff --git a/src/application/components/chat/ChatMessage.tsx b/src/application/components/chat/ChatMessage.tsx index 008511d..21615e8 100644 --- a/src/application/components/chat/ChatMessage.tsx +++ b/src/application/components/chat/ChatMessage.tsx @@ -8,6 +8,8 @@ import { } from "@/domain/entities/chat/message"; import StatusBadge from "@/application/components/shared/StatusBadge"; import HITLReviewPanel from "@/application/components/chat/HITLReviewPanel"; +import ThinkingBlock from "@/application/components/chat/ThinkingBlock"; +import StructuredResponseCard from "@/application/components/chat/StructuredResponseCard"; interface ChatMessageProps { message: Message; @@ -52,7 +54,6 @@ export default function ChatMessage({ if (isAi) { return (
- {/* Avatar */}
hub @@ -60,7 +61,6 @@ export default function ChatMessage({
- {/* Agent name + status */}
{agentName} @@ -68,7 +68,6 @@ export default function ChatMessage({ {message.status && }
- {/* Content bubble */}
+ + +
- {/* HITL Review Panel */} - {isAwaitingHitl && - message.tool_calls && - message.tool_calls.length > 0 && - threadId && ( - - )} + {isAwaitingHitl && message.tool_calls?.length && threadId && ( + + )} - {/* Timestamp */}

{formatTimestamp(message.timestamp)}

@@ -106,7 +103,6 @@ export default function ChatMessage({ ); } - // System / Tool messages: compact, centered return (
diff --git a/src/application/components/chat/MessageList.tsx b/src/application/components/chat/MessageList.tsx index bfcfcba..d8b1c33 100644 --- a/src/application/components/chat/MessageList.tsx +++ b/src/application/components/chat/MessageList.tsx @@ -5,6 +5,8 @@ import { useMessages } from "@/application/hooks/chat/useMessages"; import { useChatStore } from "@/application/stores/useChatStore"; import { MessageRole } from "@/domain/entities/chat/message"; import ChatMessage from "@/application/components/chat/ChatMessage"; +import ThinkingBlock from "@/application/components/chat/ThinkingBlock"; +import StructuredResponseCard from "@/application/components/chat/StructuredResponseCard"; interface MessageListProps { threadId: string; @@ -16,15 +18,27 @@ export default function MessageList({ agentName, }: Readonly) { const { data: messages, isLoading } = useMessages(threadId); - const { streamingContent, isStreaming, pendingUserMessage } = useChatStore(); + const { + streamingContent, + streamingThinking, + structuredResponse, + isStreaming, + pendingUserMessage, + error, + } = useChatStore(); const scrollRef = useRef(null); - // Auto-scroll to bottom when messages change or streaming content updates useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + const el = scrollRef.current; + if (!el) return; + const isNearBottom = + el.scrollHeight - el.scrollTop - el.clientHeight < 150; + if (isNearBottom) { + requestAnimationFrame(() => { + el.scrollTop = el.scrollHeight; + }); } - }, [messages, streamingContent, pendingUserMessage, isStreaming]); + }, [messages, streamingContent, streamingThinking, pendingUserMessage, error]); if (isLoading) { return ( @@ -34,9 +48,9 @@ export default function MessageList({ ); } - const hasMessages = messages && messages.length > 0; + const hasMessages = (messages?.length ?? 0) > 0; - if (!hasMessages && !isStreaming) { + if (!hasMessages && !isStreaming && !error) { return (
@@ -66,7 +80,6 @@ export default function MessageList({ /> ))} - {/* Pending user message (optimistic) */} {pendingUserMessage && ( )} - {/* Streaming: single agent bubble with content + spinner */} {isStreaming && (
@@ -104,7 +117,9 @@ export default function MessageList({
)} -
+ + +
progress_activity @@ -116,6 +131,29 @@ export default function MessageList({
)} + + {error && !isStreaming && ( +
+
+ + error + +
+
+
+ + {agentName} + +
+
+

+ Something went wrong +

+

{error}

+
+
+
+ )}
); diff --git a/src/application/components/chat/StructuredResponseCard.tsx b/src/application/components/chat/StructuredResponseCard.tsx new file mode 100644 index 0000000..0cd61ea --- /dev/null +++ b/src/application/components/chat/StructuredResponseCard.tsx @@ -0,0 +1,88 @@ +import { useRef, useState, useEffect } from "react"; +import { cn } from "@/application/lib/utils"; + +interface StructuredResponseCardProps { + data: unknown; + className?: string; +} + +export default function StructuredResponseCard({ + data, + className, +}: StructuredResponseCardProps) { + const [copied, setCopied] = useState(false); + const timerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + if (data == null) return null; + + let displayText = ""; + try { + displayText = JSON.stringify(data, null, 2); + } catch { + try { + displayText = String(data); + } catch { + displayText = "[Unable to display data]"; + } + } + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(displayText); + setCopied(true); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopied(false), 1500); + } catch { + /* ignore */ + } + }; + + return ( +
+
+
+ + data_object + + + Structured Response + +
+ +
+
+
+          {displayText}
+        
+
+
+ ); +} diff --git a/src/application/components/chat/ThinkingBlock.tsx b/src/application/components/chat/ThinkingBlock.tsx new file mode 100644 index 0000000..880b505 --- /dev/null +++ b/src/application/components/chat/ThinkingBlock.tsx @@ -0,0 +1,44 @@ +import { useState, useId } from "react"; +import { cn } from "@/application/lib/utils"; + +interface ThinkingBlockProps { + text: string | null; +} + +export default function ThinkingBlock({ text }: ThinkingBlockProps) { + const [isExpanded, setIsExpanded] = useState(false); + const panelId = useId(); + + if (!text?.trim()) return null; + + return ( +
+ + {isExpanded && ( +
+
+            {text}
+          
+
+ )} +
+ ); +} diff --git a/src/application/hooks/chat/useStreamChat.ts b/src/application/hooks/chat/useStreamChat.ts index b8ea279..f607980 100644 --- a/src/application/hooks/chat/useStreamChat.ts +++ b/src/application/hooks/chat/useStreamChat.ts @@ -1,28 +1,39 @@ import { useCallback, useEffect, useRef } from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { chatApi } from "@/infrastructure/api/chat/chatApi"; import { useChatStore } from "@/application/stores/useChatStore"; +import { StreamEventType } from "@/domain/entities/chat/streamEvent"; import type { ChatRequest } from "@/domain/entities/chat/chatRequest"; export function useStreamChat(threadId: string | null) { const queryClient = useQueryClient(); - const { setStreaming, appendStreamChunk, clearStream } = useChatStore(); const abortRef = useRef(null); const stream = useCallback( (request: ChatRequest) => { if (!threadId) return; + const { + clearStream, + setStreaming, + setPendingUserMessage, + // setError read via getState() in callbacks + } = useChatStore.getState(); + clearStream(); setStreaming(true); - useChatStore.getState().setPendingUserMessage(request.message ?? null); + setPendingUserMessage(request.message ?? null); abortRef.current = chatApi.streamMessage( threadId, request, - (chunk) => { - appendStreamChunk(chunk); + (event) => { + if (event.type === StreamEventType.ERROR) { + useChatStore.getState().setStreaming(false); + useChatStore.getState().setError(event.data); + return; + } + useChatStore.getState().appendStreamEvent(event); }, async () => { try { @@ -30,29 +41,31 @@ export function useStreamChat(threadId: string | null) { queryKey: ["messages", threadId], }); } finally { - useChatStore.getState().setPendingUserMessage(null); + const { setPendingUserMessage, setStreaming } = + useChatStore.getState(); + setPendingUserMessage(null); setStreaming(false); } }, (error) => { console.error("Stream error:", error); - useChatStore.getState().setPendingUserMessage(null); - setStreaming(false); - toast.error("Stream error", { - description: error.message || "An error occurred while streaming.", - }); + useChatStore.getState().setStreaming(false); + useChatStore.getState().setError( + error.message || "An error occurred while streaming.", + ); }, ); }, - [threadId, clearStream, setStreaming, appendStreamChunk, queryClient], + [threadId, queryClient], ); const cancel = useCallback(() => { if (abortRef.current) { abortRef.current.abort(); - setStreaming(false); + abortRef.current = null; + useChatStore.getState().clearStream(); } - }, [setStreaming]); + }, []); useEffect(() => { return () => { diff --git a/src/application/stores/useChatStore.ts b/src/application/stores/useChatStore.ts index 2755762..c15ab69 100644 --- a/src/application/stores/useChatStore.ts +++ b/src/application/stores/useChatStore.ts @@ -1,32 +1,80 @@ import { create } from "zustand"; +import type { StreamEvent } from "@/domain/entities/chat/streamEvent"; interface ChatState { activeThreadId: string | null; streamingContent: string; + streamingThinking: string; + structuredResponse: unknown | null; isStreaming: boolean; pendingUserMessage: string | null; useStreaming: boolean; + error: string | null; setActiveThread: (id: string | null) => void; - appendStreamChunk: (chunk: string) => void; + appendStreamEvent: (event: StreamEvent) => void; clearStream: () => void; setStreaming: (streaming: boolean) => void; setPendingUserMessage: (msg: string | null) => void; + setError: (msg: string | null) => void; toggleStreaming: () => void; } export const useChatStore = create((set) => ({ activeThreadId: null, streamingContent: "", + streamingThinking: "", + structuredResponse: null, isStreaming: false, pendingUserMessage: null, useStreaming: true, + error: null, setActiveThread: (id) => set({ activeThreadId: id }), - appendStreamChunk: (chunk) => - set((state) => ({ streamingContent: state.streamingContent + chunk })), + appendStreamEvent: (ev: StreamEvent) => + set((state) => { + if (!ev.type || typeof ev.data !== "string") { + console.warn("[ChatStore] Invalid stream event:", ev); + return state; + } + switch (ev.type) { + case "thinking": + return { streamingThinking: state.streamingThinking + ev.data }; + case "content": + return { streamingContent: state.streamingContent + ev.data }; + case "structured": + try { + return { structuredResponse: JSON.parse(ev.data) }; + } catch (e) { + console.warn("[ChatStore] Failed to parse structured event data:", ev.data, e); + return state; + } + case "message": + try { + const msg = JSON.parse(ev.data); + return { structuredResponse: msg.structured_response ?? null }; + } catch (e) { + console.warn( + "[ChatStore] Failed to parse message event data:", + ev.data, + e, + ); + return state; + } + default: + return state; + } + }), clearStream: () => - set({ streamingContent: "", isStreaming: false, pendingUserMessage: null }), + set({ + streamingContent: "", + streamingThinking: "", + structuredResponse: null, + isStreaming: false, + pendingUserMessage: null, + error: null, + }), setStreaming: (streaming) => set({ isStreaming: streaming }), setPendingUserMessage: (msg) => set({ pendingUserMessage: msg }), + setError: (msg) => set({ error: msg }), toggleStreaming: () => set((state) => ({ useStreaming: !state.useStreaming })), })); diff --git a/src/domain/entities/chat/message.ts b/src/domain/entities/chat/message.ts index 8542bcd..2e4a125 100644 --- a/src/domain/entities/chat/message.ts +++ b/src/domain/entities/chat/message.ts @@ -24,4 +24,5 @@ export interface Message { tool_calls: ToolCall[] | null; status: MessageStatus | null; structured_response: unknown; + thinking: string | null; } diff --git a/src/domain/entities/chat/streamEvent.ts b/src/domain/entities/chat/streamEvent.ts new file mode 100644 index 0000000..abd4f70 --- /dev/null +++ b/src/domain/entities/chat/streamEvent.ts @@ -0,0 +1,12 @@ +export enum StreamEventType { + THINKING = "thinking", + CONTENT = "content", + STRUCTURED = "structured", + MESSAGE = "message", + ERROR = "error", +} + +export interface StreamEvent { + type: StreamEventType; + data: string; +} diff --git a/src/domain/ports/chat/chatPort.ts b/src/domain/ports/chat/chatPort.ts index 6f23449..226567b 100644 --- a/src/domain/ports/chat/chatPort.ts +++ b/src/domain/ports/chat/chatPort.ts @@ -1,5 +1,6 @@ import type { ChatRequest } from "@/domain/entities/chat/chatRequest"; import type { Message } from "@/domain/entities/chat/message"; +import type { StreamEvent } from "@/domain/entities/chat/streamEvent"; import type { Thread } from "@/domain/entities/chat/thread"; export interface IChatPort { @@ -12,7 +13,7 @@ export interface IChatPort { streamMessage( threadId: string, request: ChatRequest, - onChunk: (data: string) => void, + onChunk: (event: StreamEvent) => void, onComplete: () => void, onError: (err: Error) => void, ): AbortController; diff --git a/src/infrastructure/api/chat/chatApi.ts b/src/infrastructure/api/chat/chatApi.ts index 872d067..c8698b2 100644 --- a/src/infrastructure/api/chat/chatApi.ts +++ b/src/infrastructure/api/chat/chatApi.ts @@ -1,11 +1,23 @@ import type { ChatRequest } from "@/domain/entities/chat/chatRequest"; import type { Message } from "@/domain/entities/chat/message"; +import { StreamEventType } from "@/domain/entities/chat/streamEvent"; +import type { StreamEvent } from "@/domain/entities/chat/streamEvent"; import type { Thread } from "@/domain/entities/chat/thread"; import type { IChatPort } from "@/domain/ports/chat/chatPort"; import { apiClient } from "@/infrastructure/api/axiosInstance"; import { configRepository } from "@/infrastructure/config/configRepositoryInstance"; import { fetchEventSource } from "@microsoft/fetch-event-source"; +const VALID_EVENT_TYPES: string[] = Object.values(StreamEventType); + +function isValidStreamEvent(parsed: unknown): parsed is StreamEvent { + if (typeof parsed !== "object" || parsed === null) return false; + const p = parsed as Record; + if (!VALID_EVENT_TYPES.includes(p.type as string)) return false; + if (typeof p.data !== "string") return false; + return true; +} + export const chatApi: IChatPort = { async createThread(agentName: string): Promise { const response = await apiClient.post("/api/v1/threads", { @@ -47,7 +59,7 @@ export const chatApi: IChatPort = { streamMessage( threadId: string, request: ChatRequest, - onChunk: (data: string) => void, + onChunk: (event: StreamEvent) => void, onComplete: () => void, onError: (err: Error) => void, ): AbortController { @@ -65,8 +77,22 @@ export const chatApi: IChatPort = { body: JSON.stringify(request), signal: ctrl.signal, onmessage(ev) { - if (ev.data) { - onChunk(ev.data); + if (ev.data === "[DONE]") return; + if (!ev.data) return; + try { + const parsed = JSON.parse(ev.data); + if (isValidStreamEvent(parsed)) { + onChunk(parsed); + } else { + console.warn("[SSE] Unknown event format:", ev.data); + onChunk({ + type: StreamEventType.CONTENT, + data: ev.data, + }); + } + } catch { + // Fallback: treat raw data as content for backward compatibility + onChunk({ type: StreamEventType.CONTENT, data: ev.data }); } }, onclose() { diff --git a/tests/unit/stores/useChatStore.test.ts b/tests/unit/stores/useChatStore.test.ts index 05afccc..b590d0a 100644 --- a/tests/unit/stores/useChatStore.test.ts +++ b/tests/unit/stores/useChatStore.test.ts @@ -6,6 +6,8 @@ describe("useChatStore", () => { useChatStore.setState({ activeThreadId: null, streamingContent: "", + streamingThinking: "", + structuredResponse: null, isStreaming: false, pendingUserMessage: null, useStreaming: true, @@ -66,13 +68,34 @@ describe("useChatStore", () => { expect(state.pendingUserMessage).toBeNull(); }); - it("appendStreamChunk appends to streamingContent", () => { - useChatStore.getState().appendStreamChunk("Hello "); - useChatStore.getState().appendStreamChunk("world"); + it("appendStreamEvent appends content to streamingContent", () => { + useChatStore.getState().appendStreamEvent({ type: "content", data: "Hello " }); + useChatStore.getState().appendStreamEvent({ type: "content", data: "world" }); expect(useChatStore.getState().streamingContent).toBe("Hello world"); }); + it("appendStreamEvent appends thinking to streamingThinking", () => { + useChatStore.getState().appendStreamEvent({ type: "thinking", data: "I think " }); + useChatStore.getState().appendStreamEvent({ type: "thinking", data: "this" }); + + expect(useChatStore.getState().streamingThinking).toBe("I think this"); + }); + + it("appendStreamEvent sets structuredResponse from message event", () => { + const msg = { structured_response: { answer: 42 } }; + useChatStore.getState().appendStreamEvent({ type: "message", data: JSON.stringify(msg) }); + + expect(useChatStore.getState().structuredResponse).toEqual({ answer: 42 }); + }); + + it("appendStreamEvent ignores unknown event types", () => { + const initial = useChatStore.getState().streamingContent; + useChatStore.getState().appendStreamEvent({ type: "unknown" as "content", data: "x" }); + + expect(useChatStore.getState().streamingContent).toBe(initial); + }); + it("setActiveThread updates activeThreadId", () => { useChatStore.getState().setActiveThread("thread-42");