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:
gsxdsm
2026-07-22 19:20:55 -07:00
parent 24a60dbba3
commit 8814925209
4 changed files with 411 additions and 53 deletions

View 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.

View File

@@ -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(<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([
["desktop", 1280],
["mobile", 390],

View File

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

View File

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