diff --git a/.changeset/fn-8504-chat-reentry-state.md b/.changeset/fn-8504-chat-reentry-state.md
new file mode 100644
index 0000000000..caed9554bd
--- /dev/null
+++ b/.changeset/fn-8504-chat-reentry-state.md
@@ -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.
diff --git a/packages/dashboard/app/components/__tests__/ChatView.streaming-thread.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.streaming-thread.test.tsx
index 8102e5ad9d..52b33240c2 100644
--- a/packages/dashboard/app/components/__tests__/ChatView.streaming-thread.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ChatView.streaming-thread.test.tsx
@@ -194,6 +194,53 @@ describe("FN-6599 ChatView streaming prior thread", () => {
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();
+ await screen.findByText("authoritative partial response");
+ firstView.unmount();
+
+ render();
+ 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([
["desktop", 1280],
["mobile", 390],
diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts
index c5ff4f6251..b2b30708d1 100644
--- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts
+++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts
@@ -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 = {
...makeSession({ id: "session-001", agentId: "agent-001" }),
isGenerating: true,
@@ -1954,17 +1954,17 @@ describe("useChat", () => {
});
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(() => {
result.current.stopStreaming();
});
- await waitFor(() => {
- expect(mockAttachChatStream).toHaveBeenCalledTimes(1);
- expect(result.current.isStreaming).toBe(false);
- });
+ expect(mockAttachChatStream).not.toHaveBeenCalled();
+ 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 () => {
@@ -2080,6 +2080,145 @@ describe("useChat", () => {
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 () => {
const session = {
...makeSession({ id: "session-001", agentId: "agent-001" }),
@@ -2708,9 +2847,9 @@ describe("useChat", () => {
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockAttachChatStream.mockReturnValue(null as never);
- mockFetchChatSession.mockResolvedValue({
- session: { ...session, isGenerating: false },
- });
+ mockFetchChatSession
+ .mockResolvedValueOnce({ session })
+ .mockResolvedValue({ session: { ...session, isGenerating: false } });
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 () => {
const generatingSession = {
...makeSession({
@@ -3801,6 +4003,7 @@ describe("useChat", () => {
mockGetScopedItem.mockImplementation((key) => key === "kb-chat-active-session" ? generatingSession.id : undefined);
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [generatingSession] });
+ mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession });
mockFetchChatMessages.mockResolvedValueOnce({ messages: priorThreadNewestFirst });
const { result } = renderHook(() => useChat("proj-123"));
@@ -3873,6 +4076,7 @@ describe("useChat", () => {
cacheMessages("proj-123", generatingSession.id, [priorUser, laterAssistant]);
mockGetScopedItem.mockImplementation((key) => key === "kb-chat-active-session" ? generatingSession.id : undefined);
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [generatingSession] });
+ mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession });
mockFetchChatMessages.mockResolvedValueOnce({ messages: [laterAssistant, persistedUser, priorUser] });
const { result } = renderHook(() => useChat("proj-123"));
@@ -4187,6 +4391,7 @@ describe("useChat", () => {
};
let attachedHandlers: StreamAppendHandlers | undefined;
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
+ mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockAttachChatStream.mockImplementation((_sessionId, handlers) => {
attachedHandlers = handlers;
@@ -5171,6 +5376,7 @@ describe("useChat", () => {
describe("FN-3336: streaming state recovery on reload", () => {
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 };
+ mockFetchChatSession.mockResolvedValueOnce({ session });
mockGetScopedItem.mockReturnValue("session-001");
mockFetchChatSessions
.mockResolvedValueOnce({ sessions: [session] })
@@ -5183,7 +5389,9 @@ describe("useChat", () => {
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 result.current.refreshSessions();
@@ -5194,7 +5402,7 @@ describe("useChat", () => {
});
// 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 () => {
@@ -5265,6 +5473,7 @@ describe("useChat", () => {
},
};
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
+ mockFetchChatSession.mockResolvedValueOnce({ session });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const { result } = renderHook(() => useChat("proj-123"));
@@ -5308,7 +5517,20 @@ describe("useChat", () => {
const otherSession = makeSession({ id: "session-002", agentId: "agent-002", title: "Other" });
const handlers: StreamAppendHandlers[] = [];
const closeFirstStream = vi.fn();
+ const resumedGeneration = {
+ ...generatingSession,
+ inFlightGeneration: {
+ ...generatingSession.inFlightGeneration,
+ streamingText: "Hello world",
+ streamingThinking: "plan next ",
+ replayFromEventId: 6,
+ },
+ };
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [generatingSession, otherSession] });
+ mockFetchChatSession
+ .mockResolvedValueOnce({ session: generatingSession })
+ .mockResolvedValueOnce({ session: otherSession })
+ .mockResolvedValueOnce({ session: resumedGeneration });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockAttachChatStream.mockImplementation((_sessionId, nextHandlers) => {
handlers.push(nextHandlers);
@@ -5390,6 +5612,7 @@ describe("useChat", () => {
it("sets isStreaming=true when selecting a session with isGenerating=true", async () => {
const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: true };
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
+ mockFetchChatSession.mockResolvedValueOnce({ session });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const { result } = renderHook(() => useChat("proj-123"));
diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts
index 1ff504393f..4bfd6fb038 100644
--- a/packages/dashboard/app/hooks/useChat.ts
+++ b/packages/dashboard/app/hooks/useChat.ts
@@ -445,6 +445,10 @@ export function useChat(
const activeSessionRef = useRef(activeSession);
const messagesRef = useRef(messages);
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;
activeSessionRef.current = activeSession;
messagesRef.current = messages;
@@ -731,7 +735,30 @@ export function useChat(
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;
+ /*
+ 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 needsPriorThreadLoad = currentMessages.length === 0 || currentMessages[0]?.sessionId !== sessionId;
lastAttachedGenerationRef.current = {
@@ -758,6 +785,13 @@ export function useChat(
setStreamingThinking(inFlightGeneration.streamingThinking);
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);
const { handlers } = createChatStreamHandlers({
@@ -779,6 +813,7 @@ export function useChat(
setActiveSession((prev) => prev && prev.id === fallbackSessionId ? { ...prev, ...nextModel } : prev);
},
onDone: () => {
+ if (!ownsAttachedSession()) return;
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
@@ -790,6 +825,7 @@ export function useChat(
flushPendingMessage();
},
onError: (data) => {
+ if (!ownsAttachedSession()) return;
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
@@ -831,14 +867,16 @@ export function useChat(
if (id && currentActiveSessionId === id && !sessionOverride) {
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) {
streamRef.current.close();
streamRef.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);
setActiveSession(session || null);
activeSessionRef.current = session || null;
@@ -846,49 +884,81 @@ export function useChat(
if (id) {
void fetchChatSession(id, projectId)
.then(({ session: refreshedSession }) => {
- if (!refreshedSession.isGenerating) {
- return;
- }
- // Only act if the user hasn't navigated away from this session
- // while the authoritative refresh was in flight.
- if (activeSessionRef.current?.id !== id) {
- return;
- }
- setActiveSession((prev) => {
- if (!prev || prev.id !== id) {
- return prev;
+ if (
+ refreshedSession.id !== id
+ || activeSessionSelectionRef.current !== selectionVersion
+ || activeSessionRef.current?.id !== id
+ ) {
+ if (
+ refreshedSession.id !== id
+ && authoritativeSelectionRefreshRef.current?.version === selectionVersion
+ ) {
+ authoritativeSelectionRefreshRef.current = null;
+ if (session?.isGenerating && !streamRef.current) {
+ attachIfGenerating(id, session.inFlightGeneration, { silent: true });
+ }
}
- return {
- ...prev,
- ...refreshedSession,
- };
- });
+ return;
+ }
+ 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:
- FN-7656: returning to a session with an in-flight generation must restore the
- working/"Thinking…" indicator immediately, even before the first response delta.
- The local `sessions` cache's `isGenerating` flag is often stale (chat:session:updated
- SSE payloads lack the route-level isGenerating/inFlightGeneration enrichment), and
- early in a generation the server reports isGenerating:true with inFlightGeneration
- 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.
+ FNXC:ChatStreaming 2026-07-20-19:15:
+ Re-entry must wait for the authoritative session snapshot before opening a stream.
+ A cached list row can carry an older cursor/text/tool snapshot; attaching from it
+ prevents the newer refresh from reseeding or replaying correctly. The selection
+ incarnation guards A → B → A, while the resolved snapshot atomically supplies the
+ working state (including null pre-first-delta snapshots) and replay cursor.
*/
- 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 });
}
})
.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();
setHasMoreMessages(false);
- // Load messages for this session
+ // Load messages for this session while the authoritative request is pending.
if (id) {
hydrateMessagesFromCache(id);
loadMessages(id);
@@ -896,14 +966,6 @@ export function useChat(
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
if (id) {
setScopedItem(ACTIVE_SESSION_STORAGE_KEY, id, projectId);
@@ -1642,6 +1704,13 @@ export function useChat(
if (!activeSession?.id || activeSession.isGenerating !== true || streamRef.current) {
return;
}
+ const pendingRefresh = authoritativeSelectionRefreshRef.current;
+ if (
+ pendingRefresh?.sessionId === activeSession.id
+ && pendingRefresh.version === activeSessionSelectionRef.current
+ ) {
+ return;
+ }
const replayFromEventId = typeof activeSession.inFlightGeneration?.replayFromEventId === "number"
? activeSession.inFlightGeneration.replayFromEventId
@@ -1658,6 +1727,13 @@ export function useChat(
// until generation finishes and messages can be reloaded.
useEffect(() => {
if (!activeSessionRef.current?.isGenerating) return;
+ const pendingRefresh = authoritativeSelectionRefreshRef.current;
+ if (
+ pendingRefresh?.sessionId === activeSessionRef.current.id
+ && pendingRefresh.version === activeSessionSelectionRef.current
+ ) {
+ return;
+ }
if (!streamRef.current) {
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));
return sortChatSessions(updated);
});
- // If this is the active session, update it too
- if (activeSessionRef.current?.id === updatedSession.id) {
+ // If this is the active session, update it too unless selection is still awaiting
+ // 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);
if (updatedSession.isGenerating && !streamRef.current) {
attachIfGenerating(updatedSession.id, updatedSession.inFlightGeneration);