diff --git a/.changeset/fn-7982-planner-chat-stale-message.md b/.changeset/fn-7982-planner-chat-stale-message.md new file mode 100644 index 0000000000..80d53d3006 --- /dev/null +++ b/.changeset/fn-7982-planner-chat-stale-message.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix task chat showing a stale agent message while generating a new reply. +category: fix +dev: TaskPlannerChatTab now clears streamingThinking and the streaming-assistant row on fresh generations while preserving attach-to-in-flight snapshots. diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.tsx b/packages/dashboard/app/components/TaskPlannerChatTab.tsx index 812ee5537c..9a717e851e 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.tsx +++ b/packages/dashboard/app/components/TaskPlannerChatTab.tsx @@ -398,9 +398,22 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, }) => { const { resolvedSessionId, content = "", inFlightGeneration, requestId, attach } = options; const isCurrentStreamRequest = () => streamRequestRef.current === requestId; - let accumulated = inFlightGeneration?.streamingText ?? ""; - let accumulatedThinking = inFlightGeneration?.streamingThinking ?? ""; - const streamingToolCalls = cloneToolCalls(inFlightGeneration?.toolCalls); + const inFlightSnapshot = attach ? inFlightGeneration : null; + let accumulated = inFlightSnapshot?.streamingText ?? ""; + let accumulatedThinking = inFlightSnapshot?.streamingThinking ?? ""; + const streamingToolCalls = cloneToolCalls(inFlightSnapshot?.toolCalls); + + /* + * FNXC:TaskDetailPlannerChat 2026-07-15-00:00: + * A fresh reply, and an attach without a persisted in-flight snapshot, must start with empty + * streaming carriers so no previous turn appears before its first event. Only an attach with a + * valid snapshot restores text, thinking, and tools because that data belongs to the still-live + * generation the user is returning to. + */ + if (!inFlightSnapshot) { + setStreamingThinking(""); + setMessages((current) => current.filter((message) => message.id !== "streaming-assistant")); + } streamRef.current?.close(); if (!isCurrentStreamRequest()) return; diff --git a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx index f3f6a46ae8..01ddfc3df8 100644 --- a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx @@ -630,6 +630,50 @@ describe("TaskPlannerChatTab", () => { await waitFor(() => expect(screen.getByText("Hello")).toBeInTheDocument()); }); + it("clears a completed turn's streaming carriers before a delayed consecutive reply", async () => { + const user = userEvent.setup(); + const streamHandlers: any[] = []; + mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { + streamHandlers.push(handlers); + return { close: vi.fn(), isConnected: () => true }; + }); + renderPlannerChat(); + await screen.findByTestId("task-planner-chat-empty"); + + const input = screen.getByLabelText("Message planner chat"); + await user.type(input, "First reply"); + await user.click(screen.getByRole("button", { name: "Send" })); + act(() => { + streamHandlers[0].onText("Previous transient reply"); + streamHandlers[0].onThinking("Previous transient thinking"); + streamHandlers[0].onDone({ + messageId: "assistant-first", + message: { + id: "assistant-first", + sessionId: "chat-planner", + role: "assistant", + content: "Previous completed reply", + thinkingOutput: "Previous completed thinking", + metadata: null, + createdAt: "2026-06-30T00:03:00.000Z", + }, + }); + }); + expect(await screen.findByText("Previous completed reply")).toBeInTheDocument(); + + await user.type(input, "Second delayed reply"); + await user.click(screen.getByRole("button", { name: "Send" })); + + // The prior persisted turn remains in the transcript, but the new live bubble must be empty + // until this generation supplies its own first event. + const streamingBubble = screen.getByTestId("chat-message-__streaming__"); + expect(streamingBubble).toHaveTextContent("Working…"); + expect(streamingBubble).not.toHaveTextContent("Previous transient reply"); + expect(streamingBubble).not.toHaveTextContent("Previous transient thinking"); + expect(streamingBubble).not.toHaveTextContent("Previous completed reply"); + expect(streamingBubble).not.toHaveTextContent("Previous completed thinking"); + }); + it("sends planner Chat exactly once on the first mobile tap while the textarea is focused", async () => { mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null }); renderPlannerChat({ projectId: "project-1" });