fix(ai-chat): copy/export the full chat history
The "copy chat" button serialized `messageRows` (persisted rows loaded via `useAiChatMessagesQuery`), which were incomplete in two ways, so the exported Markdown dropped messages (e.g. "Messages: 2" for a multi-turn chat). - Exhaust pagination: `useAiChatMessagesQuery` is a useInfiniteQuery that only ever loaded the first page (server page size 50, oldest-first), silently truncating longer chats. Add an effect that calls `fetchNextPage()` until `hasNextPage` is false. Guard on `isFetchNextPageError` so a failed page fetch does not loop on the app's global `retry: false`. - Re-sync after each turn: `onTurnFinished` invalidated only the chat-list query, never the per-chat messages query, so `messageRows` went stale during a live session. Also invalidate `AI_CHAT_MESSAGES_RQ_KEY(activeChatId)` so the export and token counters reflect the just-finished turn. - Avoid tearing down the live thread: a render-phase latch (`historyLoadedKeyRef`) keeps the history loader gating the FIRST mount only, so the post-turn background refetch (which can transiently flip `hasNextPage` for a chat whose message count is an exact multiple of the page size) no longer unmounts the open thread and loses its in-progress useChat state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import {
|
||||
AI_CHATS_RQ_KEY,
|
||||
AI_CHAT_MESSAGES_RQ_KEY,
|
||||
useAiChatMessagesQuery,
|
||||
useAiChatsQuery,
|
||||
useAiRolesQuery,
|
||||
@@ -135,6 +136,11 @@ export default function AiChatWindow() {
|
||||
// can adopt it once the chat list refreshes after the first turn finishes.
|
||||
const adoptNewChat = useRef(false);
|
||||
|
||||
// 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);
|
||||
|
||||
// Mount key for ChatThread + the chat the currently-mounted thread represents.
|
||||
// `threadKey` normally tracks the active chat, so selecting a different chat
|
||||
// (incl. from page history) remounts and re-seeds. The ONE exception is
|
||||
@@ -214,6 +220,18 @@ export default function AiChatWindow() {
|
||||
const onTurnFinished = useCallback(() => {
|
||||
if (activeChatId === null) adoptNewChat.current = true;
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
||||
// Re-sync the persisted message rows for the active chat so the Markdown
|
||||
// export and the token counters reflect the turn that just finished. The
|
||||
// live thread renders from its own useChat store (stable threadKey / store
|
||||
// id), so refetching these rows never re-seeds or tears down the open
|
||||
// thread. For a brand-new chat activeChatId is still null here; that chat's
|
||||
// first row load happens right after id adoption, and every later turn hits
|
||||
// this invalidation with the adopted id.
|
||||
if (activeChatId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(activeChatId),
|
||||
});
|
||||
}
|
||||
}, [activeChatId, queryClient]);
|
||||
|
||||
// The active chat object (for its title) and an export gate: only enable the
|
||||
@@ -281,12 +299,31 @@ export default function AiChatWindow() {
|
||||
setLiveThreadChatId(activeChatId);
|
||||
setThreadKey(activeChatId ?? `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 &&
|
||||
threadKey === 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). For a live in-place thread that adopted its id, the key
|
||||
// is still the "new-…" session key, so we keep showing the live thread instead
|
||||
// of unmounting it behind a loader.
|
||||
// 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 we keep showing the live thread instead of unmounting it behind a
|
||||
// loader; and once a chat's history has loaded, a later background refetch no
|
||||
// longer tears the thread back down (see the latch above).
|
||||
const waitingForHistory =
|
||||
activeChatId !== null && messagesLoading && threadKey === activeChatId;
|
||||
activeChatId !== null &&
|
||||
messagesLoading &&
|
||||
threadKey === activeChatId &&
|
||||
historyLoadedKeyRef.current !== activeChatId;
|
||||
|
||||
// Current context size for the active chat: how much the conversation now
|
||||
// occupies in the model's context window — NOT the cumulative tokens spent.
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
@@ -75,6 +75,31 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
|
||||
enabled: !!chatId,
|
||||
});
|
||||
|
||||
// useInfiniteQuery only fetches the first page on its own. The hook's contract
|
||||
// (and both the Markdown export and the model-history seed) require the
|
||||
// COMPLETE thread, so keep pulling subsequent pages until the server reports
|
||||
// none remain. The isFetchingNextPage guard issues one request at a time;
|
||||
// when chatId is undefined the query is disabled and hasNextPage is false, so
|
||||
// this is a no-op. The isFetchNextPageError guard is critical: the app sets a
|
||||
// global `retry: false`, so a rejected fetchNextPage leaves hasNextPage true
|
||||
// and isFetchingNextPage false — without this guard the effect would re-fire
|
||||
// immediately and hammer the endpoint in a tight loop. isFetchNextPageError
|
||||
// latches the last next-page failure and clears once a fetch succeeds.
|
||||
useEffect(() => {
|
||||
if (
|
||||
query.hasNextPage &&
|
||||
!query.isFetchingNextPage &&
|
||||
!query.isFetchNextPageError
|
||||
) {
|
||||
void query.fetchNextPage();
|
||||
}
|
||||
}, [
|
||||
query.hasNextPage,
|
||||
query.isFetchingNextPage,
|
||||
query.isFetchNextPageError,
|
||||
query.fetchNextPage,
|
||||
]);
|
||||
|
||||
const data = useMemo<IAiChatMessageRow[] | undefined>(() => {
|
||||
if (!query.data) return undefined;
|
||||
return query.data.pages.flatMap((p) => p.items);
|
||||
|
||||
Reference in New Issue
Block a user