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 47c0dc41e1
commit 57736beb06
8 changed files with 134 additions and 28 deletions

View File

@@ -5,7 +5,7 @@
import fs from "node:fs";
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 { userEvent } from "@testing-library/user-event";
import { ChatView } from "../ChatView";
@@ -742,6 +742,41 @@ describe("ChatView", () => {
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", () => {
setupMockChat({
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" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
@@ -437,11 +437,38 @@ describe("QuickChatFAB", () => {
fireEvent.change(input, { target: { value: "Hello" } });
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(() => {
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 () => {
@@ -483,17 +510,22 @@ describe("QuickChatFAB", () => {
fireEvent.change(input, { target: { value: "Hello" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
// Wait for streaming to complete
await waitFor(() => {
expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled();
});
// Wait for streaming to complete and assistant response to appear
await waitFor(
() => {
expect(screen.getByText(/Here's my response/)).toBeDefined();
},
{ timeout: 5000 },
);
// Check that user's "Hello" message is preserved
expect(screen.getByText("Hello")).toBeDefined();
// Check that assistant response is shown (mock concatenates thinking + text)
// The mock sends "Thinking..." then "Here's my response." which concatenates
expect(screen.getByText(/Here's my response/)).toBeDefined();
// Input should be enabled after streaming completes
expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled();
// 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 () => {