323 lines
16 KiB
TypeScript
323 lines
16 KiB
TypeScript
import { useCallback, useEffect, useReducer, useRef } from "react";
|
|
import { generateId } from "ai";
|
|
import {
|
|
resolveAdoptedChatId,
|
|
newlyAddedChatIds,
|
|
} from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
|
import {
|
|
newThread,
|
|
switchThread,
|
|
threadSessionReducer,
|
|
} from "@/features/ai-chat/utils/thread-identity.ts";
|
|
|
|
/** Inputs to {@link useChatSession}. `activeChatId`/`setActiveChatId` are the
|
|
* public selection atom (also written from outside the window, e.g. page
|
|
* history); the rest is read-only context the hook needs. */
|
|
export interface UseChatSessionOptions {
|
|
activeChatId: string | null;
|
|
setActiveChatId: (id: string | null) => void;
|
|
chats: { items?: { id: string }[] } | undefined;
|
|
messagesLoading: boolean;
|
|
/** Wraps queryClient.invalidateQueries(AI_CHATS_RQ_KEY). */
|
|
onInvalidateChatList: () => void;
|
|
/** Wraps the per-chat messages invalidation. */
|
|
onInvalidateChatMessages: (chatId: string) => void;
|
|
}
|
|
|
|
/** What the window needs from a chat session: the ChatThread mount key, the
|
|
* history-loader gate, and the turn-finished callback. */
|
|
export interface UseChatSessionResult {
|
|
/** ChatThread mount key (was `thread.key`). */
|
|
threadKey: string;
|
|
/** Show the history loader instead of the live thread. */
|
|
waitingForHistory: boolean;
|
|
/** Force a brand-new, empty thread (new mount key, no chat id) UNCONDITIONALLY,
|
|
* even when `activeChatId` is unchanged. The window calls this from
|
|
* startNewChat so "New chat" pressed WHILE a brand-new chat's first turn is
|
|
* still streaming (activeChatId still null, nothing to diverge) actually
|
|
* resets the chat instead of only dropping the role badge (#161). */
|
|
startFreshThread: () => void;
|
|
/** Call when a turn finishes; `serverChatId` is the authoritative streamed id
|
|
* (undefined on a failed turn). `finishingThreadKey` is the mount key of the
|
|
* thread that produced the turn (omit => "current thread", back-compatible):
|
|
* a turn ABANDONED by New chat mid-stream still fires this after its thread
|
|
* unmounted, so adoption is gated to the still-mounted thread (#161). Handles
|
|
* new-chat id adoption + invalidations. */
|
|
onTurnFinished: (serverChatId?: string, finishingThreadKey?: string) => void;
|
|
/** Call EARLY (at the stream's `start` chunk) with the authoritative streamed
|
|
* chat id so a brand-new chat adopts its real id WHILE its first turn is still
|
|
* streaming — making `activeChatId`-gated affordances (e.g. the Copy/export
|
|
* button, #174) available immediately. In-place adoption only (same mount key,
|
|
* no list/messages invalidation — that is left to onTurnFinished at the end).
|
|
* Idempotent and a no-op once the chat already has an id. */
|
|
onServerChatId: (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;
|
|
}
|
|
|
|
/** Project a chat list to its id array (the before/after snapshot for the
|
|
* error-path fallback). */
|
|
function chatIdSnapshot(
|
|
chats: { items?: { id: string }[] } | undefined,
|
|
): string[] {
|
|
return chats?.items?.map((c) => c.id) ?? [];
|
|
}
|
|
|
|
/**
|
|
* Owns the AI-chat thread-identity lifecycle: the single atomic thread identity,
|
|
* both new-chat id adoption paths (primary streamed-metadata + bounded error-path
|
|
* fallback), the history-loaded latch, and the render-phase reconciler that keeps
|
|
* the thread's mount key in sync with the public `activeChatId` atom.
|
|
*
|
|
* This is the twice-bugged area for the #137 two-tab adoption race; the canonical
|
|
* explanation of the adoption design lives in adopt-chat-id.ts.
|
|
*/
|
|
export function useChatSession(
|
|
params: UseChatSessionOptions,
|
|
): UseChatSessionResult {
|
|
const {
|
|
activeChatId,
|
|
setActiveChatId,
|
|
chats,
|
|
messagesLoading,
|
|
onInvalidateChatList,
|
|
onInvalidateChatMessages,
|
|
} = params;
|
|
|
|
// Live mirror of `activeChatId`, read by onTurnFinished. ai@6 fires both
|
|
// onFinish AND onError on a failed turn, so onTurnFinished can run twice in one
|
|
// turn (once with the streamed id, once without) BEFORE a re-render. Reading
|
|
// the ref — which the primary-adoption branch updates imperatively — makes that
|
|
// second call see the just-adopted id, so it cannot re-arm the fallback. (A
|
|
// plain closure over `activeChatId` would still read null on the second call.)
|
|
const activeChatIdRef = useRef(activeChatId);
|
|
activeChatIdRef.current = activeChatId;
|
|
|
|
// The mounted thread's identity: ONE atomic value tying ChatThread's mount key
|
|
// (`thread.key`) to the chat id that mounted thread holds (`thread.chatId`).
|
|
// Consolidating these makes the "key vs chat id diverged" state unrepresentable
|
|
// — every change goes through an explicit transition (see thread-identity.ts):
|
|
// `newThread`/`switchThread` to (re)mount, `adoptThread` for in-place adoption.
|
|
// Initial: a non-null activeChatId switches to it; a null one gets a fresh
|
|
// session key with no chat id yet.
|
|
const [thread, dispatch] = useReducer(threadSessionReducer, undefined, () =>
|
|
activeChatId === null
|
|
? newThread(`new-${generateId()}`)
|
|
: switchThread(activeChatId),
|
|
);
|
|
|
|
// Live mirror of the mounted thread's mount key, read by onTurnFinished to tell
|
|
// the CURRENT thread from one ABANDONED by New chat mid-stream. @ai-sdk/react
|
|
// does not abort a stream on unmount and proxies callbacks through a ref, so an
|
|
// abandoned turn's onFinish/onError still fires AFTER its ChatThread unmounted;
|
|
// matching its key against this ref keeps that late finish from adopting the
|
|
// abandoned chat and yanking the user out of the fresh chat they opened (#161).
|
|
const threadKeyRef = useRef(thread.key);
|
|
threadKeyRef.current = thread.key;
|
|
|
|
// Error-path fallback for new-chat id adoption. When a brand-new chat's first
|
|
// turn errors BEFORE the server's `start` chunk, no authoritative chatId ever
|
|
// reaches the client, so the primary metadata adoption cannot run. We then ARM
|
|
// this ref with a snapshot of the currently-known chat ids; once the list
|
|
// refetch lands with the just-created row, the fallback effect below adopts the
|
|
// SINGLE newly-appeared id. `null` = not armed. See adopt-chat-id.ts (#137).
|
|
const pendingNewChatRef = useRef<string[] | null>(null);
|
|
|
|
// Latch: the chat id whose full persisted history has finished loading while
|
|
// its thread is mounted. Used so a later BACKGROUND refetch (the post-turn
|
|
// messages invalidation) never tears the live thread back down to the loader.
|
|
const historyLoadedKeyRef = useRef<string | null>(null);
|
|
|
|
// After a turn finishes, refresh the chat list. For a brand-new chat (no id
|
|
// yet) we adopt the server's AUTHORITATIVE streamed id (never the newest in the
|
|
// list, which races a second tab — #137; see adopt-chat-id.ts).
|
|
const onTurnFinished = useCallback(
|
|
(serverChatId?: string, finishingThreadKey?: string) => {
|
|
// Thread-aware guard (#161). A turn ABANDONED by "New chat" mid-stream still
|
|
// fires onFinish/onError after its ChatThread unmounted (@ai-sdk/react does
|
|
// not abort on unmount and proxies callbacks through a ref). If that late
|
|
// finish ran the adoption path it would set activeChatId to the abandoned
|
|
// chat's real id and yank the user out of the fresh chat they just opened.
|
|
// So adopt / arm the fallback ONLY for the still-mounted thread; an
|
|
// abandoned one merely refreshes the chat list (so the left-behind chat
|
|
// surfaces in history) and does nothing else. A missing key (undefined)
|
|
// means "current thread" — keeps old call sites/tests working.
|
|
if (
|
|
finishingThreadKey !== undefined &&
|
|
finishingThreadKey !== threadKeyRef.current
|
|
) {
|
|
onInvalidateChatList();
|
|
return;
|
|
}
|
|
// Read the live id from the ref, not the closure: on a failed turn this can
|
|
// run twice in one turn (onFinish + onError) before any re-render, and the
|
|
// primary branch below updates the ref so the second call sees the adopted id.
|
|
const current = activeChatIdRef.current;
|
|
const adopted = resolveAdoptedChatId(current, serverChatId);
|
|
if (adopted) {
|
|
// PRIMARY path. In-place adoption: set the public selection and the
|
|
// thread identity to the real id together. `adopt` keeps the SAME mount
|
|
// key, so the render-phase reconciler sees `activeChatId === thread.chatId`
|
|
// and keeps the SAME mounted thread (its useChat already holds the
|
|
// just-finished turn) instead of remounting + re-seeding from
|
|
// not-yet-persisted history.
|
|
activeChatIdRef.current = adopted; // a same-turn 2nd call now sees the id
|
|
setActiveChatId(adopted);
|
|
dispatch({ type: "adopt", chatId: adopted });
|
|
// Primary adoption won — disarm any previously-armed fallback.
|
|
pendingNewChatRef.current = null;
|
|
} else if (current === null) {
|
|
// FALLBACK path: a brand-new chat finished with NO server id (the first
|
|
// turn errored before the `start` chunk). Arm the bounded list-refetch
|
|
// fallback by snapshotting the currently-known chat ids. `chats` is still
|
|
// the pre-refetch list here, so the just-created row is NOT yet in it; the
|
|
// effect below adopts the single id that newly appears after the refetch.
|
|
pendingNewChatRef.current = chatIdSnapshot(chats);
|
|
}
|
|
onInvalidateChatList();
|
|
// Re-sync the persisted message rows for the active chat so the Markdown
|
|
// export and token counters reflect the just-finished turn. The live thread
|
|
// renders from its own useChat store (stable thread.key), so this never
|
|
// re-seeds or tears down the open thread. For a brand-new chat `current` is
|
|
// still null here; later turns hit this with the adopted id.
|
|
if (current) {
|
|
onInvalidateChatMessages(current);
|
|
}
|
|
},
|
|
[chats, setActiveChatId, onInvalidateChatList, onInvalidateChatMessages],
|
|
);
|
|
|
|
// EARLY adoption (#174): adopt the authoritative streamed chat id the moment
|
|
// the server emits it on the `start` chunk, so a brand-new chat gets its real
|
|
// `activeChatId` WHILE its first turn streams — not only at terminal
|
|
// onTurnFinished. This makes the activeChatId-gated Copy/export button
|
|
// available during the first turn. Pure in-place adoption (same mount key, like
|
|
// the primary path) with NO invalidation: the list/messages refresh stays on
|
|
// onTurnFinished at the end of the turn. Reads the live id from the ref so a
|
|
// repeat call after adoption is a no-op (resolveAdoptedChatId only fires for a
|
|
// still-new chat).
|
|
const onServerChatId = useCallback(
|
|
(serverChatId?: string) => {
|
|
const adopted = resolveAdoptedChatId(
|
|
activeChatIdRef.current,
|
|
serverChatId,
|
|
);
|
|
if (!adopted) return;
|
|
activeChatIdRef.current = adopted;
|
|
setActiveChatId(adopted);
|
|
dispatch({ type: "adopt", chatId: adopted });
|
|
// Early adoption beat the error-path fallback to it — disarm.
|
|
pendingNewChatRef.current = null;
|
|
},
|
|
[setActiveChatId],
|
|
);
|
|
|
|
// FALLBACK resolver. Armed only by onTurnFinished when a brand-new chat's first
|
|
// turn errored before the `start` chunk (no authoritative id streamed). Once
|
|
// the per-user list refetch lands with the just-created row, adopt the SINGLE
|
|
// id that newly appeared relative to the pre-refetch snapshot. Adoption is IN
|
|
// PLACE (set activeChatId + `adopt` together) like the primary path, so the
|
|
// render-phase reconciler does not remount.
|
|
useEffect(() => {
|
|
const before = pendingNewChatRef.current;
|
|
if (before === null || activeChatId !== null) return; // not armed / already adopted
|
|
const after = chatIdSnapshot(chats);
|
|
const added = newlyAddedChatIds(before, after);
|
|
// Keep waiting until a genuinely-new id appears. Set-based, so it is robust
|
|
// to an add+delete in the same window (a length compare would miss it), and
|
|
// it deliberately keeps waiting through an unrelated deletion (no new id yet)
|
|
// until the just-created row actually lands, rather than giving up early.
|
|
if (added.size === 0) return; // list not refetched yet — keep waiting
|
|
pendingNewChatRef.current = null; // resolved — disarm
|
|
if (added.size === 1) {
|
|
// single unambiguous new id; >1 = ambiguous → give up
|
|
const adopted = [...added][0];
|
|
setActiveChatId(adopted);
|
|
dispatch({ type: "adopt", chatId: adopted });
|
|
}
|
|
}, [chats, activeChatId, setActiveChatId]);
|
|
|
|
// Reconcile the thread identity against the active-chat atom during render when
|
|
// they diverge — the React-sanctioned alternative to an effect (re-renders
|
|
// before paint, no extra commit, and converges since the next render finds them
|
|
// equal). This reconciliation MUST remain: `activeChatId` is the public
|
|
// selection and is ALSO set from OUTSIDE this component (e.g. page-history opens
|
|
// a referenced chat via setActiveChatId). A divergence here is a genuine SWITCH
|
|
// (external atom change OR user switch via selectChat/startNewChat), so
|
|
// `reconcile` remounts + reseeds. In-place adoption never reaches this branch:
|
|
// it set activeChatId and thread.chatId to the same value.
|
|
if (activeChatId !== thread.chatId) {
|
|
// A genuine switch makes any pending error-path new-chat fallback moot.
|
|
pendingNewChatRef.current = null;
|
|
dispatch({
|
|
type: "reconcile",
|
|
chatId: activeChatId,
|
|
newKey: `new-${generateId()}`,
|
|
});
|
|
}
|
|
|
|
// Latch the active chat once its full history has loaded and its thread is
|
|
// mounted, so a later background refetch (the post-turn messages invalidation,
|
|
// which can transiently flip hasNextPage for a chat whose message count is an
|
|
// exact multiple of the server page size) does not tear the live thread down to
|
|
// a loader and lose its in-progress useChat state.
|
|
if (
|
|
activeChatId !== null &&
|
|
thread.key === activeChatId &&
|
|
!messagesLoading &&
|
|
historyLoadedKeyRef.current !== activeChatId
|
|
) {
|
|
historyLoadedKeyRef.current = activeChatId;
|
|
}
|
|
|
|
// Show the history loader only when freshly OPENING an existing chat (the key
|
|
// equals the chat id) whose history has not been fully loaded yet. For a live
|
|
// in-place thread that adopted its id, the key is still the "new-…" session
|
|
// key, so the live thread keeps rendering; and once a chat's history has loaded,
|
|
// a later background refetch no longer tears it down (see the latch above).
|
|
const waitingForHistory =
|
|
activeChatId !== null &&
|
|
messagesLoading &&
|
|
thread.key === activeChatId &&
|
|
historyLoadedKeyRef.current !== activeChatId;
|
|
|
|
// 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;
|
|
}, []);
|
|
|
|
// Force a fresh, empty thread regardless of `activeChatId` (#161). The render-
|
|
// phase reconciler only remounts when activeChatId diverges from thread.chatId,
|
|
// so "New chat" pressed while a brand-new chat's first turn is still streaming
|
|
// (activeChatId AND thread.chatId both null — the real id is adopted only at the
|
|
// end of the turn) is a no-op for it and the abandoned thread/stream/history
|
|
// would persist. Dispatching reconcile with a fresh key and chatId:null here
|
|
// always produces a new mount key, so React remounts ChatThread (a clean useChat
|
|
// store) and the post-dispatch state (activeChatId null === thread.chatId null)
|
|
// keeps the reconciler from interfering. Also disarms any pending fallback.
|
|
const startFreshThread = useCallback(() => {
|
|
pendingNewChatRef.current = null;
|
|
dispatch({
|
|
type: "reconcile",
|
|
chatId: null,
|
|
newKey: `new-${generateId()}`,
|
|
});
|
|
}, []);
|
|
|
|
return {
|
|
threadKey: thread.key,
|
|
waitingForHistory,
|
|
startFreshThread,
|
|
onTurnFinished,
|
|
onServerChatId,
|
|
cancelPendingAdoption,
|
|
};
|
|
}
|