fix(ai-chat): disarm pending adoption on New chat/select; drop dead helper

Closes the 3rd PR #138 review.

Warning fix: the render-phase reconciler only disarms the error-path adoption
fallback when activeChatId actually changes. Pressing 'New chat' while ALREADY
in a new chat keeps activeChatId === null (a no-op atom write), so the reconciler
never fired and a stale armed fallback could adopt the just-failed chat from a
late refetch, yanking the user out of their fresh chat. useChatSession now
returns cancelPendingAdoption(); the window calls it from startNewChat AND
selectChat. (The hook call moved above those callbacks so they can reference it.)
Added a hook test that fails without the explicit disarm, plus a test for the
existing-chat onTurnFinished branch (no adoption + per-chat invalidation).

Cleanups: removed the dead pickNewlyCreatedChatId (the fallback effect uses
newlyAddedChatIds directly with the 0/1/>1 decision inline) and its tests/doc
mention; inlined the two invalidation closures (onTurnFinished is read live by
useChat's onFinish, never in an effect dep array, so memoizing them was needless
ceremony).

Verified: tsc clean, 127 ai-chat tests green; live (z.ai glm-5.2) new chat + 2nd
turn recalled the number in the SAME row (1 chat / 4 messages), no page errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-23 03:04:40 +03:00
parent f59ca3cb0d
commit 8f9a218c68
5 changed files with 82 additions and 70 deletions

View File

@@ -173,18 +173,42 @@ export default function AiChatWindow() {
? { id: openPageData.id, title: openPageData.title }
: null;
// startNewChat/selectChat only set the public atom; useChatSession's render-
// phase reconciler does the remount AND disarms any pending new-chat fallback.
// The AI-chat thread-identity lifecycle (mount key, both new-chat id adoption
// paths, the history-loaded latch, the render-phase reconciler) lives in this
// hook. See adopt-chat-id.ts for the canonical #137 two-tab race explanation.
// The invalidate closures are passed inline: `onTurnFinished` is read live by
// useChat's onFinish (never in an effect dep array), so their identity does not
// matter — no memoization ceremony needed.
const { threadKey, waitingForHistory, onTurnFinished, cancelPendingAdoption } =
useChatSession({
activeChatId,
setActiveChatId,
chats,
messagesLoading,
onInvalidateChatList: () =>
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY }),
onInvalidateChatMessages: (id) =>
queryClient.invalidateQueries({ queryKey: AI_CHAT_MESSAGES_RQ_KEY(id) }),
});
// startNewChat/selectChat set the public atom; the hook's render-phase
// reconciler handles the remount when activeChatId actually CHANGES. But
// pressing "New chat" while already in a new chat leaves activeChatId === null
// (a no-op for the atom), so the reconciler never fires — explicitly disarm any
// armed error-path fallback here so a late refetch can't yank the user into a
// just-failed chat after they chose a fresh one.
const startNewChat = useCallback((): void => {
cancelPendingAdoption();
setActiveChatId(null);
setHistoryOpen(false);
setDraft("");
// Default the picker back to "Universal assistant" for the fresh chat.
setSelectedRoleId(null);
}, [setActiveChatId, setDraft, setSelectedRoleId]);
}, [cancelPendingAdoption, setActiveChatId, setDraft, setSelectedRoleId]);
const selectChat = useCallback(
(chatId: string): void => {
cancelPendingAdoption();
setActiveChatId(chatId);
setHistoryOpen(false);
setDraft("");
@@ -192,33 +216,9 @@ export default function AiChatWindow() {
// chat's header/assistant-name (which prefers the chat's persisted role).
setSelectedRoleId(null);
},
[setActiveChatId, setDraft, setSelectedRoleId],
[cancelPendingAdoption, setActiveChatId, setDraft, setSelectedRoleId],
);
// The AI-chat thread-identity lifecycle (mount key, both new-chat id adoption
// paths, the history-loaded latch, the render-phase reconciler) lives in this
// hook. See adopt-chat-id.ts for the canonical #137 two-tab race explanation.
// Memoized so the hook's `onTurnFinished` useCallback keeps a stable identity
// across renders (it is read live by useChat's onFinish, so this is cosmetic,
// not load-bearing — but avoids needless re-creation).
const invalidateChatList = useCallback(
() => queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY }),
[queryClient],
);
const invalidateChatMessages = useCallback(
(id: string) =>
queryClient.invalidateQueries({ queryKey: AI_CHAT_MESSAGES_RQ_KEY(id) }),
[queryClient],
);
const { threadKey, waitingForHistory, onTurnFinished } = useChatSession({
activeChatId,
setActiveChatId,
chats,
messagesLoading,
onInvalidateChatList: invalidateChatList,
onInvalidateChatMessages: invalidateChatMessages,
});
// The active chat object (for its title) and an export gate: only enable the
// export button when an existing chat with loaded persisted rows is active.
const activeChat = useMemo(

View File

@@ -114,6 +114,37 @@ describe("useChatSession", () => {
expect(setActiveChatId).not.toHaveBeenCalledWith("new");
});
it("startNewChat while already in a new chat: cancelPendingAdoption stops a late refetch adopting the failed chat", () => {
// The Warning path the render-phase reconciler can't catch: pressing "New
// chat" while already in a new chat keeps activeChatId === null (a no-op for
// the atom), so only the explicit cancelPendingAdoption() disarms.
const { result, rerender, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [{ id: "x" }] },
});
result.current.onTurnFinished(undefined); // first turn failed → arm (before=["x"])
result.current.cancelPendingAdoption(); // window calls this from startNewChat
// The just-failed row lands in a late refetch; it must NOT be adopted.
rerender({
activeChatId: null,
chats: { items: [{ id: "x" }, { id: "failed" }] },
});
expect(setActiveChatId).not.toHaveBeenCalledWith("failed");
});
it("onTurnFinished for an existing chat: no adoption, invalidates that chat's messages", () => {
const {
result,
setActiveChatId,
onInvalidateChatList,
onInvalidateChatMessages,
} = setup({ activeChatId: "chat-1", chats: { items: [{ id: "chat-1" }] } });
result.current.onTurnFinished("chat-1");
expect(setActiveChatId).not.toHaveBeenCalled(); // existing chat is never re-adopted
expect(onInvalidateChatList).toHaveBeenCalled();
expect(onInvalidateChatMessages).toHaveBeenCalledWith("chat-1");
});
it("in-place adopt keeps threadKey stable; an external switch remounts", () => {
const chats = { items: [{ id: "B" }] };
const { result, rerender } = setup({ activeChatId: null, chats });

View File

@@ -20,6 +20,10 @@ export interface ChatSession {
/** Call when a turn finishes; `serverChatId` is the authoritative streamed id
* (undefined on a failed turn). Handles new-chat id adoption + invalidations. */
onTurnFinished: (serverChatId?: string) => void;
/** Disarm any pending error-path new-chat fallback. The window calls this from
* startNewChat/selectChat so a late refetch can't yank the user back into a
* just-failed chat after they explicitly moved on. */
cancelPendingAdoption: () => void;
}
/**
@@ -192,5 +196,20 @@ export function useChatSession(params: {
thread.key === activeChatId &&
historyLoadedKeyRef.current !== activeChatId;
return { threadKey: thread.key, waitingForHistory, onTurnFinished };
// Explicit disarm for startNewChat/selectChat. The render-phase reconciler only
// disarms when activeChatId actually changes, but "New chat" pressed while the
// user is ALREADY in a new chat is a no-op for the atom (activeChatId stays
// null), so the reconciler never fires — without this an armed fallback could
// adopt the just-failed chat from a late refetch and yank the user out of their
// fresh chat. Stable identity (writes a ref).
const cancelPendingAdoption = useCallback(() => {
pendingNewChatRef.current = null;
}, []);
return {
threadKey: thread.key,
waitingForHistory,
onTurnFinished,
cancelPendingAdoption,
};
}

View File

@@ -1,7 +1,6 @@
import { describe, it, expect } from "vitest";
import {
resolveAdoptedChatId,
pickNewlyCreatedChatId,
newlyAddedChatIds,
extractServerChatId,
} from "./adopt-chat-id";
@@ -21,32 +20,6 @@ describe("resolveAdoptedChatId", () => {
});
});
describe("pickNewlyCreatedChatId", () => {
it("returns the single newly-appeared id", () => {
expect(pickNewlyCreatedChatId(["a", "b"], ["c", "a", "b"])).toBe("c");
});
it("returns null when no new id appeared", () => {
expect(pickNewlyCreatedChatId(["a", "b"], ["a", "b"])).toBeNull();
});
it("returns null when more than one new id appeared (ambiguous)", () => {
expect(pickNewlyCreatedChatId(["a"], ["a", "b", "c"])).toBeNull();
});
it("returns the single after id when before is empty", () => {
expect(pickNewlyCreatedChatId([], ["only"])).toBe("only");
});
it("treats a duplicated new id as one (deduped, not ambiguous)", () => {
expect(pickNewlyCreatedChatId(["a"], ["a", "new", "new"])).toBe("new");
});
it("returns null when membership is unchanged but reordered", () => {
expect(pickNewlyCreatedChatId(["a", "b"], ["b", "a"])).toBeNull();
});
});
describe("newlyAddedChatIds", () => {
it("returns the single new id", () => {
expect([...newlyAddedChatIds(["a", "b"], ["a", "b", "c"])]).toEqual(["c"]);

View File

@@ -25,8 +25,9 @@
* FALLBACK path (only when a new chat's first turn errors BEFORE the `start`
* chunk, so no metadata id ever reached the client): adopt the single chat that
* NEWLY appeared in the per-user list relative to a pre-refetch snapshot —
* `newlyAddedChatIds` / `pickNewlyCreatedChatId`. This is unambiguous and does
* not race a second tab the way the old "newest chat in the list" guess did.
* `newlyAddedChatIds` (the fallback effect adopts only when exactly one id is
* new). This is unambiguous and does not race a second tab the way the old
* "newest chat in the list" guess did.
* ============================================================================
*/
@@ -67,15 +68,3 @@ export function newlyAddedChatIds(
const before = new Set(beforeIds);
return new Set(afterIds.filter((id) => !before.has(id)));
}
/**
* Return the single id present in `afterIds` but not in `beforeIds`. Returns
* null when zero or more-than-one such id exists (ambiguous — do not adopt).
*/
export function pickNewlyCreatedChatId(
beforeIds: readonly string[],
afterIds: readonly string[],
): string | null {
const added = newlyAddedChatIds(beforeIds, afterIds);
return added.size === 1 ? [...added][0] : null;
}