FN-8504: restore in-flight chat state after re-entry
Restore authoritative streaming state when users return to an active chat session. - Delay stream attachment until the authoritative session snapshot is loaded - Guard stream callbacks and SSE updates by selection incarnation - Cover re-entry, stale refresh, and transcript restoration scenarios - Add a patch changeset for the chat-state fix Files changed: .changeset/fn-8504-chat-reentry-state.md | 7 + .../__tests__/ChatView.streaming-thread.test.tsx | 47 ++++ .../dashboard/app/hooks/__tests__/useChat.test.ts | 245 ++++++++++++++++++++- packages/dashboard/app/hooks/useChat.ts | 159 +++++++++---- 4 files changed, 408 insertions(+), 50 deletions(-) Fusion-Task-Id: FN-8504 Fusion-Task-Lineage: 0b339eb9-78d1-4db5-ae3a-e8314380662c Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8504-chat-reentry-state.md
Normal file
7
.changeset/fn-8504-chat-reentry-state.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Restore active chat thinking and partial response state when returning to a conversation.
|
||||||
|
category: fix
|
||||||
|
dev: Guards direct-chat re-entry refreshes and late stream terminal callbacks by session selection ownership.
|
||||||
@@ -194,6 +194,53 @@ describe("FN-6599 ChatView streaming prior thread", () => {
|
|||||||
expect(screen.getByText("Second answer")).toBeInTheDocument();
|
expect(screen.getByText("Second answer")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["desktop", 1280],
|
||||||
|
["mobile", 390],
|
||||||
|
])("FN-8504 restores the authoritative in-flight bubble after leaving and re-entering on %s", async (_label, width) => {
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
const generatingSession = makeSession({
|
||||||
|
id: "session-reentry",
|
||||||
|
agentId: "agent-001",
|
||||||
|
title: "Re-entry",
|
||||||
|
isGenerating: true,
|
||||||
|
inFlightGeneration: {
|
||||||
|
status: "generating" as const,
|
||||||
|
streamingText: "authoritative partial response",
|
||||||
|
streamingThinking: "authoritative reasoning",
|
||||||
|
toolCalls: [{ toolName: "read", status: "running" as const, isError: false, args: { path: "README.md" } }],
|
||||||
|
replayFromEventId: 23,
|
||||||
|
updatedAt: "2026-07-20T19:15:00.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const priorMessage = makeMessage({ id: "msg-prior", sessionId: generatingSession.id, role: "user", content: "Prior question" });
|
||||||
|
mockGetScopedItem.mockImplementation((key) => key === "kb-chat-active-session" ? generatingSession.id : undefined);
|
||||||
|
mockFetchChatSessions.mockResolvedValue({ sessions: [generatingSession] });
|
||||||
|
mockFetchChatSession.mockResolvedValue({ session: generatingSession });
|
||||||
|
mockFetchChatMessages.mockResolvedValue({ messages: [priorMessage] });
|
||||||
|
|
||||||
|
const firstView = render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
await screen.findByText("authoritative partial response");
|
||||||
|
firstView.unmount();
|
||||||
|
|
||||||
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("authoritative partial response")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Thinking")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("read")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("running")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Prior question")).toBeInTheDocument();
|
||||||
|
expect(mockAttachChatStream).toHaveBeenLastCalledWith(
|
||||||
|
generatingSession.id,
|
||||||
|
expect.any(Object),
|
||||||
|
"proj-123",
|
||||||
|
{ lastEventId: 23 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(mockAttachChatStream).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["desktop", 1280],
|
["desktop", 1280],
|
||||||
["mobile", 390],
|
["mobile", 390],
|
||||||
|
|||||||
@@ -1927,7 +1927,7 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("FN-5104 does not reattach after stopStreaming cancels active generation", async () => {
|
it("FN-5104 does not reattach after an authoritative idle response", async () => {
|
||||||
const session = {
|
const session = {
|
||||||
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
||||||
isGenerating: true,
|
isGenerating: true,
|
||||||
@@ -1954,17 +1954,17 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockAttachChatStream).toHaveBeenCalledTimes(1);
|
// A stale list row must not reopen a generation that the authoritative endpoint says ended.
|
||||||
|
expect(result.current.isStreaming).toBe(false);
|
||||||
|
expect(mockAttachChatStream).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.stopStreaming();
|
result.current.stopStreaming();
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
expect(mockAttachChatStream).not.toHaveBeenCalled();
|
||||||
expect(mockAttachChatStream).toHaveBeenCalledTimes(1);
|
expect(result.current.isStreaming).toBe(false);
|
||||||
expect(result.current.isStreaming).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("FN-7656 reattaches and shows working state on refresh reporting isGenerating with no inFlightGeneration snapshot yet (pre-first-delta)", async () => {
|
it("FN-7656 reattaches and shows working state on refresh reporting isGenerating with no inFlightGeneration snapshot yet (pre-first-delta)", async () => {
|
||||||
@@ -2080,6 +2080,145 @@ describe("useChat", () => {
|
|||||||
expect(result.current.activeSession?.id).toBe("session-002");
|
expect(result.current.activeSession?.id).toBe("session-002");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("FN-8504 waits for the authoritative re-entry snapshot before attaching from its cursor", async () => {
|
||||||
|
const staleListSession = {
|
||||||
|
...makeSession({ id: "session-stale-cursor", agentId: "agent-001" }),
|
||||||
|
isGenerating: true,
|
||||||
|
inFlightGeneration: {
|
||||||
|
status: "generating" as const,
|
||||||
|
streamingText: "stale partial",
|
||||||
|
streamingThinking: "stale reasoning",
|
||||||
|
toolCalls: [],
|
||||||
|
replayFromEventId: 17,
|
||||||
|
updatedAt: "2026-07-20T19:00:00.000Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const authoritativeRefresh = createDeferredPromise<{ session: ChatSession }>();
|
||||||
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [staleListSession] });
|
||||||
|
mockFetchChatSession.mockReturnValueOnce(authoritativeRefresh.promise);
|
||||||
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChat());
|
||||||
|
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.selectSession(staleListSession.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchChatSession).toHaveBeenCalledWith(staleListSession.id, undefined);
|
||||||
|
});
|
||||||
|
expect(mockAttachChatStream).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
authoritativeRefresh.resolve({
|
||||||
|
session: {
|
||||||
|
...staleListSession,
|
||||||
|
inFlightGeneration: {
|
||||||
|
...staleListSession.inFlightGeneration,
|
||||||
|
streamingText: "authoritative partial",
|
||||||
|
streamingThinking: "authoritative reasoning",
|
||||||
|
replayFromEventId: 23,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAttachChatStream).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockAttachChatStream).toHaveBeenCalledWith(
|
||||||
|
staleListSession.id,
|
||||||
|
expect.any(Object),
|
||||||
|
undefined,
|
||||||
|
{ lastEventId: 23 },
|
||||||
|
);
|
||||||
|
expect(result.current.streamingText).toBe("authoritative partial");
|
||||||
|
expect(result.current.streamingThinking).toBe("authoritative reasoning");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FN-8504 ignores an earlier A refresh after rapid A → B → A re-entry", async () => {
|
||||||
|
const sessionA = {
|
||||||
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
||||||
|
isGenerating: false,
|
||||||
|
inFlightGeneration: null,
|
||||||
|
};
|
||||||
|
const sessionB = {
|
||||||
|
...makeSession({ id: "session-002", agentId: "agent-002" }),
|
||||||
|
isGenerating: false,
|
||||||
|
inFlightGeneration: null,
|
||||||
|
};
|
||||||
|
const oldARefresh = createDeferredPromise<{ session: ChatSession }>();
|
||||||
|
const currentARefresh = createDeferredPromise<{ session: ChatSession }>();
|
||||||
|
let aFetches = 0;
|
||||||
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [sessionA, sessionB] });
|
||||||
|
mockFetchChatSession.mockImplementation((id) => {
|
||||||
|
if (id === sessionA.id) {
|
||||||
|
aFetches += 1;
|
||||||
|
return aFetches === 1 ? oldARefresh.promise : currentARefresh.promise;
|
||||||
|
}
|
||||||
|
return Promise.resolve({ session: sessionB });
|
||||||
|
});
|
||||||
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChat());
|
||||||
|
await waitFor(() => expect(result.current.sessions).toHaveLength(2));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.selectSession(sessionA.id);
|
||||||
|
result.current.selectSession(sessionB.id);
|
||||||
|
result.current.selectSession(sessionA.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(aFetches).toBe(2));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
oldARefresh.resolve({
|
||||||
|
session: {
|
||||||
|
...sessionA,
|
||||||
|
isGenerating: true,
|
||||||
|
inFlightGeneration: {
|
||||||
|
status: "generating",
|
||||||
|
streamingText: "obsolete partial",
|
||||||
|
streamingThinking: "obsolete reasoning",
|
||||||
|
toolCalls: [],
|
||||||
|
replayFromEventId: 31,
|
||||||
|
updatedAt: "2026-07-20T18:00:00.000Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockAttachChatStream).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
currentARefresh.resolve({
|
||||||
|
session: {
|
||||||
|
...sessionA,
|
||||||
|
isGenerating: true,
|
||||||
|
inFlightGeneration: {
|
||||||
|
status: "generating",
|
||||||
|
streamingText: "current partial",
|
||||||
|
streamingThinking: "current reasoning",
|
||||||
|
toolCalls: [],
|
||||||
|
replayFromEventId: 32,
|
||||||
|
updatedAt: "2026-07-20T18:01:00.000Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAttachChatStream).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockAttachChatStream).toHaveBeenCalledWith(sessionA.id, expect.any(Object), undefined, { lastEventId: 32 });
|
||||||
|
expect(result.current.streamingText).toBe("current partial");
|
||||||
|
expect(result.current.streamingThinking).toBe("current reasoning");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("fetches session on visible return only when no live stream and swallows reconnect failures", async () => {
|
it("fetches session on visible return only when no live stream and swallows reconnect failures", async () => {
|
||||||
const session = {
|
const session = {
|
||||||
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
...makeSession({ id: "session-001", agentId: "agent-001" }),
|
||||||
@@ -2708,9 +2847,9 @@ describe("useChat", () => {
|
|||||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
mockAttachChatStream.mockReturnValue(null as never);
|
mockAttachChatStream.mockReturnValue(null as never);
|
||||||
mockFetchChatSession.mockResolvedValue({
|
mockFetchChatSession
|
||||||
session: { ...session, isGenerating: false },
|
.mockResolvedValueOnce({ session })
|
||||||
});
|
.mockResolvedValue({ session: { ...session, isGenerating: false } });
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat("proj-123"));
|
const { result } = renderHook(() => useChat("proj-123"));
|
||||||
|
|
||||||
@@ -3775,6 +3914,69 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("FN-8504 waits for the authoritative cursor when an SSE update races session re-entry", async () => {
|
||||||
|
const staleSession = {
|
||||||
|
...makeSession({ id: "session-reentry-race", agentId: "agent-001" }),
|
||||||
|
isGenerating: false,
|
||||||
|
inFlightGeneration: null,
|
||||||
|
};
|
||||||
|
const staleSseSession = {
|
||||||
|
...staleSession,
|
||||||
|
isGenerating: true,
|
||||||
|
inFlightGeneration: {
|
||||||
|
status: "generating" as const,
|
||||||
|
streamingText: "stale partial",
|
||||||
|
streamingThinking: "",
|
||||||
|
toolCalls: [],
|
||||||
|
replayFromEventId: 4,
|
||||||
|
updatedAt: "2026-07-22T19:05:00.000Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const authoritativeSession = {
|
||||||
|
...staleSseSession,
|
||||||
|
inFlightGeneration: {
|
||||||
|
...staleSseSession.inFlightGeneration,
|
||||||
|
streamingText: "authoritative partial",
|
||||||
|
streamingThinking: "authoritative reasoning",
|
||||||
|
replayFromEventId: 23,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const refresh = createDeferredPromise<{ session: ChatSession }>();
|
||||||
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [staleSession] });
|
||||||
|
mockFetchChatSession.mockReturnValueOnce(refresh.promise);
|
||||||
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChat("proj-123"));
|
||||||
|
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
|
||||||
|
|
||||||
|
act(() => result.current.selectSession(staleSession.id));
|
||||||
|
act(() => {
|
||||||
|
subscribeHandler["chat:session:updated"]?.({
|
||||||
|
data: JSON.stringify(staleSseSession),
|
||||||
|
} as MessageEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockAttachChatStream).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.isStreaming).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
refresh.resolve({ session: authoritativeSession });
|
||||||
|
await refresh.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAttachChatStream).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockAttachChatStream).toHaveBeenCalledWith(
|
||||||
|
staleSession.id,
|
||||||
|
expect.any(Object),
|
||||||
|
"proj-123",
|
||||||
|
{ lastEventId: 23 },
|
||||||
|
);
|
||||||
|
expect(result.current.streamingText).toBe("authoritative partial");
|
||||||
|
expect(result.current.streamingThinking).toBe("authoritative reasoning");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("FN-6599 keeps restored main-chat prior thread visible during selectSession recovery attach", async () => {
|
it("FN-6599 keeps restored main-chat prior thread visible during selectSession recovery attach", async () => {
|
||||||
const generatingSession = {
|
const generatingSession = {
|
||||||
...makeSession({
|
...makeSession({
|
||||||
@@ -3801,6 +4003,7 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
mockGetScopedItem.mockImplementation((key) => key === "kb-chat-active-session" ? generatingSession.id : undefined);
|
mockGetScopedItem.mockImplementation((key) => key === "kb-chat-active-session" ? generatingSession.id : undefined);
|
||||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [generatingSession] });
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [generatingSession] });
|
||||||
|
mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession });
|
||||||
mockFetchChatMessages.mockResolvedValueOnce({ messages: priorThreadNewestFirst });
|
mockFetchChatMessages.mockResolvedValueOnce({ messages: priorThreadNewestFirst });
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat("proj-123"));
|
const { result } = renderHook(() => useChat("proj-123"));
|
||||||
@@ -3873,6 +4076,7 @@ describe("useChat", () => {
|
|||||||
cacheMessages("proj-123", generatingSession.id, [priorUser, laterAssistant]);
|
cacheMessages("proj-123", generatingSession.id, [priorUser, laterAssistant]);
|
||||||
mockGetScopedItem.mockImplementation((key) => key === "kb-chat-active-session" ? generatingSession.id : undefined);
|
mockGetScopedItem.mockImplementation((key) => key === "kb-chat-active-session" ? generatingSession.id : undefined);
|
||||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [generatingSession] });
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [generatingSession] });
|
||||||
|
mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession });
|
||||||
mockFetchChatMessages.mockResolvedValueOnce({ messages: [laterAssistant, persistedUser, priorUser] });
|
mockFetchChatMessages.mockResolvedValueOnce({ messages: [laterAssistant, persistedUser, priorUser] });
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat("proj-123"));
|
const { result } = renderHook(() => useChat("proj-123"));
|
||||||
@@ -4187,6 +4391,7 @@ describe("useChat", () => {
|
|||||||
};
|
};
|
||||||
let attachedHandlers: StreamAppendHandlers | undefined;
|
let attachedHandlers: StreamAppendHandlers | undefined;
|
||||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||||
|
mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession });
|
||||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
mockAttachChatStream.mockImplementation((_sessionId, handlers) => {
|
mockAttachChatStream.mockImplementation((_sessionId, handlers) => {
|
||||||
attachedHandlers = handlers;
|
attachedHandlers = handlers;
|
||||||
@@ -5171,6 +5376,7 @@ describe("useChat", () => {
|
|||||||
describe("FN-3336: streaming state recovery on reload", () => {
|
describe("FN-3336: streaming state recovery on reload", () => {
|
||||||
it("does not re-select and reset active session on subsequent session refreshes", async () => {
|
it("does not re-select and reset active session on subsequent session refreshes", async () => {
|
||||||
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
|
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
|
||||||
|
mockFetchChatSession.mockResolvedValueOnce({ session });
|
||||||
mockGetScopedItem.mockReturnValue("session-001");
|
mockGetScopedItem.mockReturnValue("session-001");
|
||||||
mockFetchChatSessions
|
mockFetchChatSessions
|
||||||
.mockResolvedValueOnce({ sessions: [session] })
|
.mockResolvedValueOnce({ sessions: [session] })
|
||||||
@@ -5183,7 +5389,9 @@ describe("useChat", () => {
|
|||||||
expect(result.current.activeSession?.id).toBe("session-001");
|
expect(result.current.activeSession?.id).toBe("session-001");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockFetchChatMessages).toHaveBeenCalledTimes(1);
|
// Authoritative re-entry reuses the attach transcript load, then later session-list
|
||||||
|
// refreshes must not select/reset the active thread again.
|
||||||
|
expect(mockFetchChatMessages).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.refreshSessions();
|
await result.current.refreshSessions();
|
||||||
@@ -5194,7 +5402,7 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// A sessions refresh should not auto-reselect/reset the active thread.
|
// A sessions refresh should not auto-reselect/reset the active thread.
|
||||||
expect(mockFetchChatMessages).toHaveBeenCalledTimes(1);
|
expect(mockFetchChatMessages).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves streaming text/thinking/tool state across sessions refresh", async () => {
|
it("preserves streaming text/thinking/tool state across sessions refresh", async () => {
|
||||||
@@ -5265,6 +5473,7 @@ describe("useChat", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||||
|
mockFetchChatSession.mockResolvedValueOnce({ session });
|
||||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat("proj-123"));
|
const { result } = renderHook(() => useChat("proj-123"));
|
||||||
@@ -5308,7 +5517,20 @@ describe("useChat", () => {
|
|||||||
const otherSession = makeSession({ id: "session-002", agentId: "agent-002", title: "Other" });
|
const otherSession = makeSession({ id: "session-002", agentId: "agent-002", title: "Other" });
|
||||||
const handlers: StreamAppendHandlers[] = [];
|
const handlers: StreamAppendHandlers[] = [];
|
||||||
const closeFirstStream = vi.fn();
|
const closeFirstStream = vi.fn();
|
||||||
|
const resumedGeneration = {
|
||||||
|
...generatingSession,
|
||||||
|
inFlightGeneration: {
|
||||||
|
...generatingSession.inFlightGeneration,
|
||||||
|
streamingText: "Hello world",
|
||||||
|
streamingThinking: "plan next ",
|
||||||
|
replayFromEventId: 6,
|
||||||
|
},
|
||||||
|
};
|
||||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [generatingSession, otherSession] });
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [generatingSession, otherSession] });
|
||||||
|
mockFetchChatSession
|
||||||
|
.mockResolvedValueOnce({ session: generatingSession })
|
||||||
|
.mockResolvedValueOnce({ session: otherSession })
|
||||||
|
.mockResolvedValueOnce({ session: resumedGeneration });
|
||||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
mockAttachChatStream.mockImplementation((_sessionId, nextHandlers) => {
|
mockAttachChatStream.mockImplementation((_sessionId, nextHandlers) => {
|
||||||
handlers.push(nextHandlers);
|
handlers.push(nextHandlers);
|
||||||
@@ -5390,6 +5612,7 @@ describe("useChat", () => {
|
|||||||
it("sets isStreaming=true when selecting a session with isGenerating=true", async () => {
|
it("sets isStreaming=true when selecting a session with isGenerating=true", async () => {
|
||||||
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
|
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
|
||||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||||
|
mockFetchChatSession.mockResolvedValueOnce({ session });
|
||||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat("proj-123"));
|
const { result } = renderHook(() => useChat("proj-123"));
|
||||||
|
|||||||
@@ -445,6 +445,10 @@ export function useChat(
|
|||||||
const activeSessionRef = useRef(activeSession);
|
const activeSessionRef = useRef(activeSession);
|
||||||
const messagesRef = useRef(messages);
|
const messagesRef = useRef(messages);
|
||||||
const isStreamingRef = useRef(isStreaming);
|
const isStreamingRef = useRef(isStreaming);
|
||||||
|
// Incremented for every selection, including A → B → A. Session ids alone cannot
|
||||||
|
// distinguish an old A refresh from the newly re-entered A thread.
|
||||||
|
const activeSessionSelectionRef = useRef(0);
|
||||||
|
const authoritativeSelectionRefreshRef = useRef<{ sessionId: string; version: number } | null>(null);
|
||||||
sessionsRef.current = sessions;
|
sessionsRef.current = sessions;
|
||||||
activeSessionRef.current = activeSession;
|
activeSessionRef.current = activeSession;
|
||||||
messagesRef.current = messages;
|
messagesRef.current = messages;
|
||||||
@@ -731,7 +735,30 @@ export function useChat(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pendingRefresh = authoritativeSelectionRefreshRef.current;
|
||||||
|
if (
|
||||||
|
pendingRefresh?.sessionId === sessionId
|
||||||
|
&& pendingRefresh.version === activeSessionSelectionRef.current
|
||||||
|
) {
|
||||||
|
/*
|
||||||
|
FNXC:ChatStreaming 2026-07-22-19:05:
|
||||||
|
An SSE list update can arrive between selection and its authoritative session read.
|
||||||
|
Do not let that potentially stale row claim stream ownership: the authoritative snapshot
|
||||||
|
owns the cursor and must seed the restored bubble before any attach path can continue.
|
||||||
|
*/
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
cancelledByUserRef.current = false;
|
cancelledByUserRef.current = false;
|
||||||
|
/*
|
||||||
|
FNXC:ChatStreaming 2026-07-20-18:45:
|
||||||
|
A closed stream can still deliver terminal callbacks. Bind each attachment to the selected
|
||||||
|
session incarnation so completion or errors from a departed thread cannot clear the restored
|
||||||
|
bubble, Stop control, or transcript of a thread re-entered afterward.
|
||||||
|
*/
|
||||||
|
const attachmentSelectionVersion = activeSessionSelectionRef.current;
|
||||||
|
const ownsAttachedSession = () =>
|
||||||
|
activeSessionSelectionRef.current === attachmentSelectionVersion && activeSessionRef.current?.id === sessionId;
|
||||||
const currentMessages = messagesRef.current;
|
const currentMessages = messagesRef.current;
|
||||||
const needsPriorThreadLoad = currentMessages.length === 0 || currentMessages[0]?.sessionId !== sessionId;
|
const needsPriorThreadLoad = currentMessages.length === 0 || currentMessages[0]?.sessionId !== sessionId;
|
||||||
lastAttachedGenerationRef.current = {
|
lastAttachedGenerationRef.current = {
|
||||||
@@ -758,6 +785,13 @@ export function useChat(
|
|||||||
setStreamingThinking(inFlightGeneration.streamingThinking);
|
setStreamingThinking(inFlightGeneration.streamingThinking);
|
||||||
setStreamingToolCalls(inFlightGeneration.toolCalls);
|
setStreamingToolCalls(inFlightGeneration.toolCalls);
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
FNXC:ChatStreaming 2026-07-22-19:20:
|
||||||
|
Re-entry must expose Working atomically to same-tick SSE and transcript callbacks. React has
|
||||||
|
not committed setIsStreaming when attachChatStream replays, so synchronize the ownership ref
|
||||||
|
before attaching; otherwise an empty stale transcript can erase the restored prior thread.
|
||||||
|
*/
|
||||||
|
isStreamingRef.current = true;
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
|
|
||||||
const { handlers } = createChatStreamHandlers({
|
const { handlers } = createChatStreamHandlers({
|
||||||
@@ -779,6 +813,7 @@ export function useChat(
|
|||||||
setActiveSession((prev) => prev && prev.id === fallbackSessionId ? { ...prev, ...nextModel } : prev);
|
setActiveSession((prev) => prev && prev.id === fallbackSessionId ? { ...prev, ...nextModel } : prev);
|
||||||
},
|
},
|
||||||
onDone: () => {
|
onDone: () => {
|
||||||
|
if (!ownsAttachedSession()) return;
|
||||||
setStreamingText("");
|
setStreamingText("");
|
||||||
setStreamingThinking("");
|
setStreamingThinking("");
|
||||||
setStreamingToolCalls([]);
|
setStreamingToolCalls([]);
|
||||||
@@ -790,6 +825,7 @@ export function useChat(
|
|||||||
flushPendingMessage();
|
flushPendingMessage();
|
||||||
},
|
},
|
||||||
onError: (data) => {
|
onError: (data) => {
|
||||||
|
if (!ownsAttachedSession()) return;
|
||||||
setStreamingText("");
|
setStreamingText("");
|
||||||
setStreamingThinking("");
|
setStreamingThinking("");
|
||||||
setStreamingToolCalls([]);
|
setStreamingToolCalls([]);
|
||||||
@@ -831,14 +867,16 @@ export function useChat(
|
|||||||
if (id && currentActiveSessionId === id && !sessionOverride) {
|
if (id && currentActiveSessionId === id && !sessionOverride) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Close any existing stream
|
const selectionVersion = ++activeSessionSelectionRef.current;
|
||||||
|
authoritativeSelectionRefreshRef.current = id ? { sessionId: id, version: selectionVersion } : null;
|
||||||
|
// Close any existing stream before its transient state is reset.
|
||||||
if (streamRef.current) {
|
if (streamRef.current) {
|
||||||
streamRef.current.close();
|
streamRef.current.close();
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
}
|
}
|
||||||
lastAttachedGenerationRef.current = null;
|
lastAttachedGenerationRef.current = null;
|
||||||
|
|
||||||
// Find and set active session
|
// Find and set active session while its authoritative state hydrates.
|
||||||
const session = sessionOverride ?? sessions.find((s) => s.id === id);
|
const session = sessionOverride ?? sessions.find((s) => s.id === id);
|
||||||
setActiveSession(session || null);
|
setActiveSession(session || null);
|
||||||
activeSessionRef.current = session || null;
|
activeSessionRef.current = session || null;
|
||||||
@@ -846,49 +884,81 @@ export function useChat(
|
|||||||
if (id) {
|
if (id) {
|
||||||
void fetchChatSession(id, projectId)
|
void fetchChatSession(id, projectId)
|
||||||
.then(({ session: refreshedSession }) => {
|
.then(({ session: refreshedSession }) => {
|
||||||
if (!refreshedSession.isGenerating) {
|
if (
|
||||||
return;
|
refreshedSession.id !== id
|
||||||
}
|
|| activeSessionSelectionRef.current !== selectionVersion
|
||||||
// Only act if the user hasn't navigated away from this session
|
|| activeSessionRef.current?.id !== id
|
||||||
// while the authoritative refresh was in flight.
|
) {
|
||||||
if (activeSessionRef.current?.id !== id) {
|
if (
|
||||||
return;
|
refreshedSession.id !== id
|
||||||
}
|
&& authoritativeSelectionRefreshRef.current?.version === selectionVersion
|
||||||
setActiveSession((prev) => {
|
) {
|
||||||
if (!prev || prev.id !== id) {
|
authoritativeSelectionRefreshRef.current = null;
|
||||||
return prev;
|
if (session?.isGenerating && !streamRef.current) {
|
||||||
|
attachIfGenerating(id, session.inFlightGeneration, { silent: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return;
|
||||||
...prev,
|
}
|
||||||
...refreshedSession,
|
if (typeof refreshedSession.isGenerating !== "boolean") {
|
||||||
};
|
/*
|
||||||
});
|
FNXC:ChatStreaming 2026-07-20-19:20:
|
||||||
|
An omitted generation enrichment is not an authoritative idle verdict. Preserve
|
||||||
|
legacy cached recovery only for that malformed/older response; current responses
|
||||||
|
must include the boolean and therefore cannot bypass snapshot reconciliation.
|
||||||
|
*/
|
||||||
|
authoritativeSelectionRefreshRef.current = null;
|
||||||
|
if (session?.isGenerating && !streamRef.current) {
|
||||||
|
attachIfGenerating(id, session.inFlightGeneration, { silent: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const authoritativeSession = { ...activeSessionRef.current, ...refreshedSession };
|
||||||
|
authoritativeSelectionRefreshRef.current = null;
|
||||||
|
setActiveSession(authoritativeSession);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:ChatStreaming 2026-07-07-00:00:
|
FNXC:ChatStreaming 2026-07-20-19:15:
|
||||||
FN-7656: returning to a session with an in-flight generation must restore the
|
Re-entry must wait for the authoritative session snapshot before opening a stream.
|
||||||
working/"Thinking…" indicator immediately, even before the first response delta.
|
A cached list row can carry an older cursor/text/tool snapshot; attaching from it
|
||||||
The local `sessions` cache's `isGenerating` flag is often stale (chat:session:updated
|
prevents the newer refresh from reseeding or replaying correctly. The selection
|
||||||
SSE payloads lack the route-level isGenerating/inFlightGeneration enrichment), and
|
incarnation guards A → B → A, while the resolved snapshot atomically supplies the
|
||||||
early in a generation the server reports isGenerating:true with inFlightGeneration
|
working state (including null pre-first-delta snapshots) and replay cursor.
|
||||||
still null (no delta emitted yet). Reattach on isGenerating alone via this
|
|
||||||
authoritative fetchChatSession refresh rather than requiring inFlightGeneration too;
|
|
||||||
attachIfGenerating already handles a null inFlightGeneration snapshot gracefully and
|
|
||||||
guards against double-attach via streamRef.current.
|
|
||||||
*/
|
*/
|
||||||
if (!streamRef.current) {
|
if (refreshedSession.isGenerating && !streamRef.current) {
|
||||||
|
/*
|
||||||
|
FNXC:ChatStreaming 2026-07-22-19:25:
|
||||||
|
The selection transcript request can resolve empty or stale before its authoritative
|
||||||
|
generation snapshot arrives. Reattach reloads the persisted thread so re-entry keeps
|
||||||
|
prior messages visible rather than leaving a restored streaming bubble by itself.
|
||||||
|
*/
|
||||||
attachIfGenerating(id, refreshedSession.inFlightGeneration, { silent: true });
|
attachIfGenerating(id, refreshedSession.inFlightGeneration, { silent: true });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Ignore stale-cache recovery fetch failures.
|
const pendingRefresh = authoritativeSelectionRefreshRef.current;
|
||||||
|
if (
|
||||||
|
pendingRefresh?.sessionId !== id
|
||||||
|
|| pendingRefresh.version !== selectionVersion
|
||||||
|
|| activeSessionSelectionRef.current !== selectionVersion
|
||||||
|
|| activeSessionRef.current?.id !== id
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
authoritativeSelectionRefreshRef.current = null;
|
||||||
|
// A transport failure is not an idle verdict. Retain the prior recovery behavior,
|
||||||
|
// but only for this still-current selection incarnation.
|
||||||
|
if (session?.isGenerating && !streamRef.current) {
|
||||||
|
attachIfGenerating(id, session.inFlightGeneration, { silent: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset transient state
|
|
||||||
resetTransientComposerState();
|
resetTransientComposerState();
|
||||||
setHasMoreMessages(false);
|
setHasMoreMessages(false);
|
||||||
|
|
||||||
// Load messages for this session
|
// Load messages for this session while the authoritative request is pending.
|
||||||
if (id) {
|
if (id) {
|
||||||
hydrateMessagesFromCache(id);
|
hydrateMessagesFromCache(id);
|
||||||
loadMessages(id);
|
loadMessages(id);
|
||||||
@@ -896,14 +966,6 @@ export function useChat(
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recover streaming state if the server reports an active generation.
|
|
||||||
// After a reload/HMR, the server keeps generating but the UI loses
|
|
||||||
// all streaming state. Showing "Working…" immediately tells the
|
|
||||||
// user the AI is still processing the request.
|
|
||||||
if (session?.isGenerating) {
|
|
||||||
attachIfGenerating(session.id, session.inFlightGeneration, { priorThreadLoadAlreadyStarted: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist active session to localStorage
|
// Persist active session to localStorage
|
||||||
if (id) {
|
if (id) {
|
||||||
setScopedItem(ACTIVE_SESSION_STORAGE_KEY, id, projectId);
|
setScopedItem(ACTIVE_SESSION_STORAGE_KEY, id, projectId);
|
||||||
@@ -1642,6 +1704,13 @@ export function useChat(
|
|||||||
if (!activeSession?.id || activeSession.isGenerating !== true || streamRef.current) {
|
if (!activeSession?.id || activeSession.isGenerating !== true || streamRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const pendingRefresh = authoritativeSelectionRefreshRef.current;
|
||||||
|
if (
|
||||||
|
pendingRefresh?.sessionId === activeSession.id
|
||||||
|
&& pendingRefresh.version === activeSessionSelectionRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const replayFromEventId = typeof activeSession.inFlightGeneration?.replayFromEventId === "number"
|
const replayFromEventId = typeof activeSession.inFlightGeneration?.replayFromEventId === "number"
|
||||||
? activeSession.inFlightGeneration.replayFromEventId
|
? activeSession.inFlightGeneration.replayFromEventId
|
||||||
@@ -1658,6 +1727,13 @@ export function useChat(
|
|||||||
// until generation finishes and messages can be reloaded.
|
// until generation finishes and messages can be reloaded.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeSessionRef.current?.isGenerating) return;
|
if (!activeSessionRef.current?.isGenerating) return;
|
||||||
|
const pendingRefresh = authoritativeSelectionRefreshRef.current;
|
||||||
|
if (
|
||||||
|
pendingRefresh?.sessionId === activeSessionRef.current.id
|
||||||
|
&& pendingRefresh.version === activeSessionSelectionRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!streamRef.current) {
|
if (!streamRef.current) {
|
||||||
attachIfGenerating(activeSessionRef.current.id, activeSessionRef.current.inFlightGeneration);
|
attachIfGenerating(activeSessionRef.current.id, activeSessionRef.current.inFlightGeneration);
|
||||||
@@ -1766,8 +1842,13 @@ export function useChat(
|
|||||||
const updated = prev.map((s) => (s.id === updatedSession.id ? updatedSession : s));
|
const updated = prev.map((s) => (s.id === updatedSession.id ? updatedSession : s));
|
||||||
return sortChatSessions(updated);
|
return sortChatSessions(updated);
|
||||||
});
|
});
|
||||||
// If this is the active session, update it too
|
// If this is the active session, update it too unless selection is still awaiting
|
||||||
if (activeSessionRef.current?.id === updatedSession.id) {
|
// its authoritative session snapshot. The list/SSE payload may have an older cursor.
|
||||||
|
const pendingRefresh = authoritativeSelectionRefreshRef.current;
|
||||||
|
const awaitingAuthoritativeSnapshot =
|
||||||
|
pendingRefresh?.sessionId === updatedSession.id
|
||||||
|
&& pendingRefresh.version === activeSessionSelectionRef.current;
|
||||||
|
if (activeSessionRef.current?.id === updatedSession.id && !awaitingAuthoritativeSnapshot) {
|
||||||
setActiveSession(updatedSession);
|
setActiveSession(updatedSession);
|
||||||
if (updatedSession.isGenerating && !streamRef.current) {
|
if (updatedSession.isGenerating && !streamRef.current) {
|
||||||
attachIfGenerating(updatedSession.id, updatedSession.inFlightGeneration);
|
attachIfGenerating(updatedSession.id, updatedSession.inFlightGeneration);
|
||||||
|
|||||||
Reference in New Issue
Block a user