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 ecd0dcad..07a7e09c 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 @@ -6,6 +6,7 @@ import { useRef, useState, } from "react"; +import { generateId } from "ai"; import { Group, Loader, Tooltip } from "@mantine/core"; import { IconArrowsDiagonal, @@ -134,6 +135,21 @@ export default function AiChatWindow() { // can adopt it once the chat list refreshes after the first turn finishes. const adoptNewChat = useRef(false); + // 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 + // in-place adoption of a brand-new chat's server id: the adopt effect moves + // `liveThreadChatId` to the new id TOGETHER with `activeChatId`, so the switch + // check below does not fire and the SAME thread stays mounted (its useChat + // already holds the just-finished turn) instead of being re-seeded from + // not-yet-persisted history. + const [threadKey, setThreadKey] = useState( + () => activeChatId ?? `new-${generateId()}`, + ); + const [liveThreadChatId, setLiveThreadChatId] = useState( + activeChatId, + ); + const { data: chats } = useAiChatsQuery(); // Roles for the new-chat picker (any member may list them). Only fetched while // the window is open. @@ -167,6 +183,9 @@ export default function AiChatWindow() { : null; const startNewChat = useCallback((): void => { + // Cancel any pending adoption so a just-finished new chat can't yank the user + // back here after they explicitly started a fresh one. + adoptNewChat.current = false; setActiveChatId(null); setHistoryOpen(false); setDraft(""); @@ -176,6 +195,8 @@ export default function AiChatWindow() { const selectChat = useCallback( (chatId: string): void => { + // Cancel any pending adoption so it can't override an explicit selection. + adoptNewChat.current = false; setActiveChatId(chatId); setHistoryOpen(false); setDraft(""); @@ -237,15 +258,35 @@ export default function AiChatWindow() { const newest = chats?.items?.[0]; if (newest) { adoptNewChat.current = false; + // In-place adoption: move the active chat AND the live-thread marker to the + // new id together, so the threadKey derivation below sees no "switch" and + // keeps the SAME mounted thread (its useChat already holds the finished + // turn) instead of remounting and re-seeding from not-yet-persisted history. + // ASSUMPTION: these two updates (jotai atom + useState) must land in ONE + // render so the render-phase guard never observes the new activeChatId with + // a stale liveThreadChatId (which would wrongly remount). React 18 automatic + // batching inside this effect callback guarantees that; if the store/atom + // mechanism ever changes, gate adoption on an explicit flag instead. + setLiveThreadChatId(newest.id); setActiveChatId(newest.id); } }, [chats, setActiveChatId]); - // The thread is remounted when the active chat changes so initial messages - // re-seed. For a new chat we key on "new"; adopting the id remounts the - // thread with the persisted history loaded. - const threadKey = activeChatId ?? "new"; - const waitingForHistory = activeChatId !== null && messagesLoading; + // Adjust the derived thread state during render when the active chat genuinely + // changes — the React-sanctioned alternative to an effect (it re-renders before + // paint, no extra commit, and converges since the next render finds them equal). + // In-place adoption of a new chat's id never reaches here because the adopt + // effect moves liveThreadChatId in lockstep with activeChatId. + if (activeChatId !== liveThreadChatId) { + setLiveThreadChatId(activeChatId); + setThreadKey(activeChatId ?? `new-${generateId()}`); + } + // 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. + const waitingForHistory = + activeChatId !== null && messagesLoading && threadKey === 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/components/chat-thread.tsx b/apps/client/src/features/ai-chat/components/chat-thread.tsx index 0799784f..0b14b646 100644 --- a/apps/client/src/features/ai-chat/components/chat-thread.tsx +++ b/apps/client/src/features/ai-chat/components/chat-thread.tsx @@ -129,7 +129,13 @@ export default function ChatThread({ // The id only needs to be stable per mount — the parent remounts this via // `key` on chat switch, which re-seeds cleanly. const stableIdRef = useRef(chatId ?? `new-${generateId()}`); - const chatStoreId = chatId ?? stableIdRef.current; + // Stable for the LIFETIME of this mount. When a brand-new chat adopts its + // server id, the parent now updates the `chatId` prop WITHOUT remounting this + // thread, so the store id must NOT follow `chatId`: recreating the useChat + // store would wipe the live (just-finished) turn. The server still resolves + // the real chat from `chatId` in the request body (see chatIdRef / + // prepareSendMessagesRequest), so this purely-client store key can stay fixed. + const chatStoreId = stableIdRef.current; const transport = useMemo( () => @@ -170,7 +176,12 @@ export default function ChatThread({ // saves the error message). Run the same post-turn path on error so the // failed chat appears in history immediately instead of after a manual // refresh. The error itself is still surfaced via `error` below. - onError: () => onTurnFinished(), + onError: (streamError) => { + // Surface the raw failure in the browser console (devtools) for debugging; + // the UI separately shows a friendly classified banner (see errorView). + console.error("AI chat stream error:", streamError); + onTurnFinished(); + }, }); const isStreaming = status === "submitted" || status === "streaming";