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:
Fusion
2026-04-28 19:43:26 -07:00
committed by gsxdsm
parent 946984a5d0
commit b248ffc6e6
4 changed files with 109 additions and 12 deletions

View File

@@ -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)}

View File

@@ -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) => {

View File

@@ -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",

View File

@@ -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;