fix(ai-chat): keep the live thread on new-chat adoption; log stream errors
A brand-new chat's first turn streamed and finished successfully, but the whole assistant response vanished from the UI. On finish the window adopts the server-created chat id, which changed the <ChatThread> key and remounted it — discarding the live useChat store (the full answer) and re-seeding from not-yet-persisted history, so only the user message remained. - chat-thread: pin the useChat store id to a per-mount value so adopting the chatId prop no longer recreates the store and wipes the live turn. - ai-chat-window: derive the thread mount key via setState-during-render and move the live-thread marker in lockstep with the adopted id, so in-place adoption keeps the same mounted thread while real chat switches still remount and re-seed; gate the history loader to a freshly opened chat. - cancel a pending adoption on New chat / explicit chat selection. - log the raw stream error to the browser console for debugging. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string>(
|
||||
() => activeChatId ?? `new-${generateId()}`,
|
||||
);
|
||||
const [liveThreadChatId, setLiveThreadChatId] = useState<string | null>(
|
||||
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.
|
||||
|
||||
@@ -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<string>(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";
|
||||
|
||||
Reference in New Issue
Block a user