fix(FN-2011): keep chat input responsive during streaming

- Make useChat and useQuickChat sendMessage synchronous and update consumers to call it without awaiting
- Allow ChatView and QuickChatFAB inputs to remain editable while responses stream
- Keep quick chat send disabled during active streaming to prevent concurrent sends
- Add hook and component tests for synchronous sendMessage and non-blocking input behavior
This commit is contained in:
Fusion
2026-04-17 15:38:32 -07:00
committed by gsxdsm
parent d8f3cb065a
commit da2d9fbcf1
8 changed files with 134 additions and 28 deletions

View File

@@ -461,21 +461,17 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
); );
// Handle send message // Handle send message
const handleSend = useCallback(async () => { const handleSend = useCallback(() => {
const trimmed = messageInput.trim(); const trimmed = messageInput.trim();
if (!trimmed || isStreaming || !activeSession) return; if (!trimmed || !activeSession) return;
setMessageInput(""); setMessageInput("");
setShowSkillMenu(false); setShowSkillMenu(false);
setSkillFilter(""); setSkillFilter("");
setMentionPopupVisible(false); setMentionPopupVisible(false);
setMentionFilter(""); setMentionFilter("");
setMentionStartPos(-1); setMentionStartPos(-1);
try { sendMessage(trimmed);
await sendMessage(trimmed); }, [messageInput, activeSession, sendMessage]);
} catch {
addToast("Failed to send message", "error");
}
}, [messageInput, isStreaming, activeSession, sendMessage, addToast]);
const handleSkillSelect = useCallback( const handleSkillSelect = useCallback(
(skill: DiscoveredSkill) => { (skill: DiscoveredSkill) => {
@@ -1069,7 +1065,6 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
onClick={handleInputSelectionChange} onClick={handleInputSelectionChange}
onBlur={handleInputBlur} onBlur={handleInputBlur}
onFocus={handleInputFocus} onFocus={handleInputFocus}
disabled={isStreaming}
rows={1} rows={1}
data-testid="chat-input" data-testid="chat-input"
/> />

View File

@@ -536,9 +536,9 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
return "Select a model to start chatting"; return "Select a model to start chatting";
}, [chatMode, selectedAgent, selectedModelTag]); }, [chatMode, selectedAgent, selectedModelTag]);
const inputDisabled = !hasChatTarget || !activeSession || sessionsLoading || isStreaming; const inputDisabled = !hasChatTarget || !activeSession || sessionsLoading;
const handleSendMessage = useCallback(async () => { const handleSendMessage = useCallback(() => {
const trimmed = messageInput.trim(); const trimmed = messageInput.trim();
if (!trimmed || inputDisabled) return; if (!trimmed || inputDisabled) return;
@@ -546,7 +546,7 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
setMentionPopupVisible(false); setMentionPopupVisible(false);
setMentionFilter(""); setMentionFilter("");
setMentionStartPos(-1); setMentionStartPos(-1);
await sendMessage(trimmed); sendMessage(trimmed);
}, [sendMessage, inputDisabled, messageInput]); }, [sendMessage, inputDisabled, messageInput]);
const updateMentionState = useCallback((value: string, cursorPos: number) => { const updateMentionState = useCallback((value: string, cursorPos: number) => {
@@ -929,7 +929,7 @@ export function QuickChatFAB({ projectId, addToast, showFAB = true, open, onOpen
<button <button
type="button" type="button"
onClick={() => void handleSendMessage()} onClick={() => void handleSendMessage()}
disabled={inputDisabled || messageInput.trim().length === 0} disabled={inputDisabled || isStreaming || messageInput.trim().length === 0}
data-testid="quick-chat-send" data-testid="quick-chat-send"
> >
<Send size={16} /> <Send size={16} />

View File

@@ -5,7 +5,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { render, screen, waitFor, within } from "@testing-library/react"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { ChatView } from "../ChatView"; import { ChatView } from "../ChatView";
@@ -742,6 +742,41 @@ describe("ChatView", () => {
expect(sendButton).toBeDisabled(); expect(sendButton).toBeDisabled();
}); });
it("textarea is enabled during streaming", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
],
isStreaming: true,
streamingText: "Thinking...",
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
expect(textarea).not.toBeDisabled();
});
it("user can type while streaming", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
],
isStreaming: true,
streamingText: "Thinking...",
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const textarea = screen.getByTestId("chat-input");
// User should be able to type in the textarea while streaming
fireEvent.change(textarea, { target: { value: "Second message" } });
expect((textarea as HTMLTextAreaElement).value).toBe("Second message");
});
it("shows streaming indicator when isStreaming is true", () => { it("shows streaming indicator when isStreaming is true", () => {
setupMockChat({ setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }, activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },

View File

@@ -423,7 +423,7 @@ describe("QuickChatFAB", () => {
}); });
}); });
it("streaming state shows streaming message and disables input", async () => { it("streaming state shows streaming message and keeps input enabled", async () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />); render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab")); fireEvent.click(screen.getByTestId("quick-chat-fab"));
@@ -437,11 +437,38 @@ describe("QuickChatFAB", () => {
fireEvent.change(input, { target: { value: "Hello" } }); fireEvent.change(input, { target: { value: "Hello" } });
fireEvent.click(screen.getByTestId("quick-chat-send")); fireEvent.click(screen.getByTestId("quick-chat-send"));
// Input should be cleared and disabled during streaming // Input should be cleared but NOT disabled during streaming
await waitFor(() => { await waitFor(() => {
expect((screen.getByTestId("quick-chat-input") as HTMLInputElement).value).toBe(""); expect((screen.getByTestId("quick-chat-input") as HTMLInputElement).value).toBe("");
}); });
expect(screen.getByTestId("quick-chat-input")).toBeDisabled(); expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled();
// Send button should be disabled during streaming
expect(screen.getByTestId("quick-chat-send")).toBeDisabled();
});
it("user can type while streaming", async () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
// Wait for session initialization
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Hello" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
// Input should be cleared after send
await waitFor(() => {
expect((screen.getByTestId("quick-chat-input") as HTMLInputElement).value).toBe("");
});
// User should still be able to type in the input while streaming
fireEvent.change(input, { target: { value: "Second message" } });
expect((screen.getByTestId("quick-chat-input") as HTMLInputElement).value).toBe("Second message");
}); });
it("after streaming completes, assistant message is shown", async () => { it("after streaming completes, assistant message is shown", async () => {
@@ -483,17 +510,22 @@ describe("QuickChatFAB", () => {
fireEvent.change(input, { target: { value: "Hello" } }); fireEvent.change(input, { target: { value: "Hello" } });
fireEvent.click(screen.getByTestId("quick-chat-send")); fireEvent.click(screen.getByTestId("quick-chat-send"));
// Wait for streaming to complete // Wait for streaming to complete and assistant response to appear
await waitFor(() => { await waitFor(
expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled(); () => {
}); expect(screen.getByText(/Here's my response/)).toBeDefined();
},
{ timeout: 5000 },
);
// Check that user's "Hello" message is preserved // Check that user's "Hello" message is preserved
expect(screen.getByText("Hello")).toBeDefined(); expect(screen.getByText("Hello")).toBeDefined();
// Check that assistant response is shown (mock concatenates thinking + text) // Input should be enabled after streaming completes
// The mock sends "Thinking..." then "Here's my response." which concatenates expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled();
expect(screen.getByText(/Here's my response/)).toBeDefined();
// Send button should be enabled after streaming completes (input is empty so still disabled)
expect(screen.getByTestId("quick-chat-send")).toBeDisabled();
}); });
it("switching agents creates a new session for the selected agent", async () => { it("switching agents creates a new session for the selected agent", async () => {

View File

@@ -109,6 +109,30 @@ describe("useChat", () => {
expect(result.current.sessions[1]?.id).toBe("session-002"); expect(result.current.sessions[1]?.id).toBe("session-002");
}); });
it("sendMessage is synchronous and returns void", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.activeSession?.id).toBe("session-001");
});
// sendMessage should return void (undefined), not a Promise
const sendResult = result.current.sendMessage("Hello");
expect(sendResult).toBeUndefined();
});
it("populates agentsMap on mount", async () => { it("populates agentsMap on mount", async () => {
const { result } = renderHook(() => useChat("proj-123")); const { result } = renderHook(() => useChat("proj-123"));

View File

@@ -41,6 +41,26 @@ describe("useQuickChat", () => {
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true }); mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
}); });
it("sendMessage is synchronous and returns void", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValue({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const { result } = renderHook(() => useQuickChat("proj-123"));
await act(async () => {
await result.current.switchSession("agent-001");
});
await waitFor(() => {
expect(result.current.activeSession).not.toBeNull();
});
// sendMessage should return void (undefined), not a Promise
const sendResult = result.current.sendMessage("Hello");
expect(sendResult).toBeUndefined();
});
it("startModelChat creates a KB session with provider/model override", async () => { it("startModelChat creates a KB session with provider/model override", async () => {
const { result } = renderHook(() => useQuickChat("proj-123")); const { result } = renderHook(() => useQuickChat("proj-123"));

View File

@@ -58,7 +58,7 @@ export interface UseChatReturn {
deleteSession: (id: string) => Promise<void>; deleteSession: (id: string) => Promise<void>;
// Message operations // Message operations
sendMessage: (content: string) => Promise<void>; sendMessage: (content: string) => void;
loadMoreMessages: () => Promise<void>; loadMoreMessages: () => Promise<void>;
hasMoreMessages: boolean; hasMoreMessages: boolean;
@@ -291,7 +291,7 @@ export function useChat(projectId?: string): UseChatReturn {
// Send a message // Send a message
const sendMessage = useCallback( const sendMessage = useCallback(
async (content: string) => { (content: string) => {
if (!activeSession) return; if (!activeSession) return;
// Close any existing stream // Close any existing stream

View File

@@ -42,7 +42,7 @@ export interface UseQuickChatReturn {
streamingThinking: string; streamingThinking: string;
// Operations // Operations
sendMessage: (content: string) => Promise<void>; sendMessage: (content: string) => void;
switchSession: (agentId: string, modelProvider?: string, modelId?: string) => Promise<void>; switchSession: (agentId: string, modelProvider?: string, modelId?: string) => Promise<void>;
startModelChat: (modelProvider: string, modelId: string) => Promise<void>; startModelChat: (modelProvider: string, modelId: string) => Promise<void>;
loadMessages: () => Promise<void>; loadMessages: () => Promise<void>;
@@ -248,7 +248,7 @@ export function useQuickChat(
// Send a message using SSE streaming // Send a message using SSE streaming
const sendMessage = useCallback( const sendMessage = useCallback(
async (content: string) => { (content: string) => {
if (!activeSession || !content.trim()) return; if (!activeSession || !content.trim()) return;
// Close any existing stream // Close any existing stream