fix(FN-2904): preserve quick chat streaming state on resume
- Keep active streaming responses alive when resuming an existing quick chat session instead of resetting stream state - Update useQuickChat session-resume logic to only restart waiting indicators when appropriate - Adjust QuickChatFAB waiting indicator behavior so it reflects real in-flight assistant activity - Add focused hook and component tests covering same-session resume and waiting-indicator regressions Fusion-Task-Id: FN-2904
This commit is contained in:
@@ -1517,7 +1517,7 @@ export function QuickChatFAB({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="quick-chat-panel-waiting" data-testid="quick-chat-waiting">
|
<p className="quick-chat-panel-waiting" data-testid="quick-chat-waiting">
|
||||||
{streamingThinking ? "Thinking…" : "Connecting…"}
|
Thinking…
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{renderToolCalls(streamingToolCalls, true)}
|
{renderToolCalls(streamingToolCalls, true)}
|
||||||
|
|||||||
@@ -820,6 +820,30 @@ describe("QuickChatFAB", () => {
|
|||||||
expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled();
|
expect(screen.getByTestId("quick-chat-input")).not.toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows Thinking waiting indicator while streaming before first text chunk", async () => {
|
||||||
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, _handlers) => ({
|
||||||
|
close: vi.fn(),
|
||||||
|
isConnected: vi.fn(() => true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchResumeChatSession).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = await screen.findByTestId("quick-chat-input");
|
||||||
|
fireEvent.change(input, { target: { value: "Hello" } });
|
||||||
|
fireEvent.click(screen.getByTestId("quick-chat-send"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("quick-chat-waiting")).toHaveTextContent("Thinking…");
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("quick-chat-waiting")).not.toHaveTextContent("Connecting…");
|
||||||
|
});
|
||||||
|
|
||||||
it("clicking stop button cancels streaming", async () => {
|
it("clicking stop button cancels streaming", async () => {
|
||||||
const closeFn = vi.fn();
|
const closeFn = vi.fn();
|
||||||
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||||
|
|||||||
@@ -206,6 +206,75 @@ describe("useQuickChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("switchSession preserves isStreaming when same session is resumed", async () => {
|
||||||
|
const existingSession = makeSession({ id: "session-existing", agentId: "agent-001" });
|
||||||
|
|
||||||
|
mockFetchResumeChatSession.mockResolvedValueOnce({ session: existingSession });
|
||||||
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
|
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.switchSession("agent-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.sendMessage("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isStreaming).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.switchSession("agent-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isStreaming).toBe(true);
|
||||||
|
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-existing", { limit: 50 }, "proj-123");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switchSession resets streaming state when switching to a different session", async () => {
|
||||||
|
const sessionA = makeSession({ id: "session-a", agentId: "agent-001" });
|
||||||
|
const sessionB = makeSession({ id: "session-b", agentId: "agent-002" });
|
||||||
|
const closeFn = vi.fn();
|
||||||
|
|
||||||
|
mockFetchResumeChatSession
|
||||||
|
.mockResolvedValueOnce({ session: sessionA })
|
||||||
|
.mockResolvedValueOnce({ session: sessionB });
|
||||||
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
|
mockStreamChatResponse.mockReturnValue({ close: closeFn, isConnected: () => true });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.switchSession("agent-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.sendMessage("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isStreaming).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.switchSession("agent-002");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(closeFn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.current.isStreaming).toBe(false);
|
||||||
|
expect(result.current.streamingText).toBe("");
|
||||||
|
expect(result.current.streamingThinking).toBe("");
|
||||||
|
expect(result.current.streamingToolCalls).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("resumes via targeted lookup without loading the full active-session list", async () => {
|
it("resumes via targeted lookup without loading the full active-session list", async () => {
|
||||||
const existingSession = makeSession({
|
const existingSession = makeSession({
|
||||||
id: "session-targeted",
|
id: "session-targeted",
|
||||||
|
|||||||
@@ -277,19 +277,23 @@ export function useQuickChat(
|
|||||||
const targetSessionKey = buildSessionKey(target.agentId, target.modelProvider, target.modelId);
|
const targetSessionKey = buildSessionKey(target.agentId, target.modelProvider, target.modelId);
|
||||||
currentSessionTargetRef.current = target;
|
currentSessionTargetRef.current = target;
|
||||||
|
|
||||||
// Close any existing stream
|
const isSameSession = targetSessionKey === currentSessionKeyRef.current && activeSession;
|
||||||
if (streamRef.current) {
|
|
||||||
streamRef.current.close();
|
if (!isSameSession) {
|
||||||
streamRef.current = null;
|
// Close any existing stream
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.close();
|
||||||
|
streamRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset streaming state
|
||||||
|
setStreamingText("");
|
||||||
|
setStreamingThinking("");
|
||||||
|
setStreamingToolCalls([]);
|
||||||
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset streaming state
|
if (isSameSession) {
|
||||||
setStreamingText("");
|
|
||||||
setStreamingThinking("");
|
|
||||||
setStreamingToolCalls([]);
|
|
||||||
setIsStreaming(false);
|
|
||||||
|
|
||||||
if (targetSessionKey === currentSessionKeyRef.current && activeSession) {
|
|
||||||
// Same chat target — just reload messages from server
|
// Same chat target — just reload messages from server
|
||||||
await reloadMessages();
|
await reloadMessages();
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user