diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx index 07a7e09c..3d8cf55d 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx @@ -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(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. diff --git a/apps/client/src/features/ai-chat/queries/ai-chat-query.ts b/apps/client/src/features/ai-chat/queries/ai-chat-query.ts index bf104f34..ca0786e9 100644 --- a/apps/client/src/features/ai-chat/queries/ai-chat-query.ts +++ b/apps/client/src/features/ai-chat/queries/ai-chat-query.ts @@ -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(() => { if (!query.data) return undefined; return query.data.pages.flatMap((p) => p.items);