From da9083240b301f3b55404765a6a1a7fa276e30f6 Mon Sep 17 00:00:00 2001 From: luxapientia Date: Fri, 10 Apr 2026 02:48:11 +0700 Subject: [PATCH] issue: the new conversation title isn't displayed in the sidebar feat: implement useNewChatSidebarSync for improved chat synchronization - Added a new hook, useNewChatSidebarSync, to manage synchronization of recent chats in the new chat sidebar. - Integrated the new hook into useVercelChat, replacing previous synchronization logic. - Enhanced the onFinish handler to conditionally refresh credits and recent chats based on chat status. --- hooks/useNewChatSidebarSync.ts | 135 +++++++++++++++++++++++++++++++++ hooks/useVercelChat.ts | 31 ++++++-- 2 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 hooks/useNewChatSidebarSync.ts diff --git a/hooks/useNewChatSidebarSync.ts b/hooks/useNewChatSidebarSync.ts new file mode 100644 index 000000000..1d722d5e4 --- /dev/null +++ b/hooks/useNewChatSidebarSync.ts @@ -0,0 +1,135 @@ +import type { QueryObserverResult } from "@tanstack/react-query"; +import type { MutableRefObject } from "react"; +import { useCallback, useEffect, useRef } from "react"; +import type { Conversation } from "@/types/Chat"; + +type RefetchConversations = () => Promise< + QueryObserverResult +>; + +/** + * Refetches recent chats (and credits on successful completion) for a new thread. + * Eligible when there is no `[roomId]` param yet **or** `pendingNewThreadSidebarSyncRef` + * is true (set after `replaceState` to `/chat/:id`, when Next may still expose `roomId` + * in `useParams` and `!routeRoomId` would incorrectly disable sync). + * Keeps sync scoped to the active chat id and only latches after a successful refetch. + */ +export function useNewChatSidebarSync({ + chatId, + routeRoomId, + pendingNewThreadSidebarSyncRef, + refetchCredits, + refetchConversations, +}: { + chatId: string; + /** Dynamic `[roomId]` param when present; undefined on `/chat` new-thread flow */ + routeRoomId: string | undefined; + /** Set when the user starts a thread from `/chat` (or `?q=`); cleared after a successful sidebar refetch */ + pendingNewThreadSidebarSyncRef: MutableRefObject; + refetchCredits: () => Promise; + refetchConversations: RefetchConversations; +}) { + const hasSyncedRecentChatsAfterFirstAssistantRef = useRef(false); + const isRefreshingRecentChatsRef = useRef(false); + const inFlightRefreshPromiseRef = useRef | null>(null); + const chatIdRef = useRef(chatId); + chatIdRef.current = chatId; + + const isSidebarSyncEligible = useCallback(() => { + return ( + pendingNewThreadSidebarSyncRef.current || routeRoomId === undefined + ); + }, [pendingNewThreadSidebarSyncRef, routeRoomId]); + + useEffect(() => { + hasSyncedRecentChatsAfterFirstAssistantRef.current = false; + isRefreshingRecentChatsRef.current = false; + inFlightRefreshPromiseRef.current = null; + }, [chatId]); + + const refreshRecentChatsOnce = useCallback(async (): Promise => { + if (hasSyncedRecentChatsAfterFirstAssistantRef.current) return false; + + const existing = inFlightRefreshPromiseRef.current; + if (existing) { + return existing; + } + + const startedForChatId = chatIdRef.current; + + const promiseRef: { current: Promise | null } = { current: null }; + promiseRef.current = (async (): Promise => { + isRefreshingRecentChatsRef.current = true; + try { + const result = await refetchConversations(); + if (result.isSuccess && chatIdRef.current === startedForChatId) { + hasSyncedRecentChatsAfterFirstAssistantRef.current = true; + pendingNewThreadSidebarSyncRef.current = false; + return true; + } + return false; + } catch (error) { + console.error("Failed to refresh recent chats:", error); + return false; + } finally { + if (chatIdRef.current === startedForChatId) { + isRefreshingRecentChatsRef.current = false; + } + const leader = promiseRef.current; + if (leader && inFlightRefreshPromiseRef.current === leader) { + inFlightRefreshPromiseRef.current = null; + } + } + })(); + + const p = promiseRef.current; + inFlightRefreshPromiseRef.current = p; + return p; + }, [refetchConversations]); + + const onFinish = useCallback( + async ({ + isError, + isAbort, + }: { + isError: boolean; + isAbort: boolean; + }) => { + const shouldRefreshCredits = !isError && !isAbort; + const shouldRefreshRecentChats = + isSidebarSyncEligible() && + shouldRefreshCredits && + !hasSyncedRecentChatsAfterFirstAssistantRef.current; + + await Promise.all([ + ...(shouldRefreshCredits ? [refetchCredits()] : []), + ...(shouldRefreshRecentChats + ? [ + (async () => { + let ok = await refreshRecentChatsOnce(); + if ( + !ok && + !hasSyncedRecentChatsAfterFirstAssistantRef.current + ) { + await refreshRecentChatsOnce(); + } + })(), + ] + : []), + ]); + }, + [isSidebarSyncEligible, refetchCredits, refreshRecentChatsOnce], + ); + + const maybeRefreshOnFirstStream = useCallback( + (chatStatus: string) => { + if (!isSidebarSyncEligible()) return; + if (chatStatus !== "streaming") return; + if (hasSyncedRecentChatsAfterFirstAssistantRef.current) return; + void refreshRecentChatsOnce(); + }, + [isSidebarSyncEligible, refreshRecentChatsOnce], + ); + + return { onFinish, maybeRefreshOnFirstStream }; +} diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index 5e66d9845..5d3d52aa5 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -22,6 +22,7 @@ import { usePrivy } from "@privy-io/react-auth"; import { TextAttachment } from "@/types/textAttachment"; import { formatTextAttachments } from "@/lib/chat/formatTextAttachments"; import { useDeleteTrailingMessages } from "./useDeleteTrailingMessages"; +import { useNewChatSidebarSync } from "./useNewChatSidebarSync"; // 30 days in seconds for Supabase signed URL expiry const SIGNED_URL_EXPIRES_SECONDS = 60 * 60 * 24 * 30; @@ -48,11 +49,15 @@ export function useVercelChat({ const { selectedArtist } = useArtistProvider(); const { selectedOrgId: organizationId } = useOrganization(); const { roomId } = useParams(); + const routeRoomId = typeof roomId === "string" ? roomId : undefined; const userId = userData?.account_id || userData?.id; // Use account_id if available, fallback to id const artistId = selectedArtist?.account_id; const messagesLengthRef = useRef(); - const { addOptimisticConversation } = useConversationsProvider(); + /** True after starting a thread from `/chat` or `?q=` until sidebar list refetch succeeds */ + const pendingNewThreadSidebarSyncRef = useRef(false); + const { addOptimisticConversation, refetchConversations } = + useConversationsProvider(); const { data: availableModels = [] } = useAvailableModels(); const [input, setInput] = useState(""); const [model, setModel] = useLocalStorage( @@ -178,6 +183,19 @@ export function useVercelChat({ [id, artistId, organizationId, accountIdOverride, model], ); + useEffect(() => { + pendingNewThreadSidebarSyncRef.current = false; + }, [id]); + + const { onFinish: onChatFinishSync, maybeRefreshOnFirstStream } = + useNewChatSidebarSync({ + chatId: id, + routeRoomId, + pendingNewThreadSidebarSyncRef, + refetchCredits, + refetchConversations, + }); + const { messages, status, stop, sendMessage, setMessages, regenerate } = useChat({ id, @@ -188,12 +206,13 @@ export function useVercelChat({ console.error("An error occurred, please try again!", e); toast.error("An error occurred, please try again!"); }, - onFinish: async () => { - // Update credits after AI response completes - await refetchCredits(); - }, + onFinish: onChatFinishSync, }); + useEffect(() => { + maybeRefreshOnFirstStream(status); + }, [status, maybeRefreshOnFirstStream]); + const earliestFailedUserMessageId = useMemo( () => getEarliestFailedUserMessageId(messages), [messages], @@ -307,6 +326,7 @@ export function useVercelChat({ handleSubmit(event); if (!roomId) { + pendingNewThreadSidebarSyncRef.current = true; // Optimistically append a temporary conversation so it appears in Recent Chats // It will be replaced by the real conversation after the updates/refetch addOptimisticConversation("New Chat", id, messageContent); @@ -316,6 +336,7 @@ export function useVercelChat({ const handleSendQueryMessages = useCallback( async (initialMessage: UIMessage) => { + pendingNewThreadSidebarSyncRef.current = true; silentlyUpdateUrl(); const headers = await getHeaders(); sendMessage(initialMessage, { body: chatRequestBody, headers });