Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions hooks/useNewChatSidebarSync.ts
Original file line number Diff line number Diff line change
@@ -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<Conversation[], Error>
>;

/**
* 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<boolean>;
refetchCredits: () => Promise<unknown>;
refetchConversations: RefetchConversations;
}) {
const hasSyncedRecentChatsAfterFirstAssistantRef = useRef(false);
const isRefreshingRecentChatsRef = useRef(false);
const inFlightRefreshPromiseRef = useRef<Promise<boolean> | 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<boolean> => {
if (hasSyncedRecentChatsAfterFirstAssistantRef.current) return false;

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const existing = inFlightRefreshPromiseRef.current;
if (existing) {
return existing;
}

const startedForChatId = chatIdRef.current;

const promiseRef: { current: Promise<boolean> | null } = { current: null };
promiseRef.current = (async (): Promise<boolean> => {
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 };
}
31 changes: 26 additions & 5 deletions hooks/useVercelChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<number>();
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(
Expand Down Expand Up @@ -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,
Expand All @@ -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(() => {

@cubic-dev-ai cubic-dev-ai Bot Apr 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: Code Structure and Size Limits for Readability and Single Responsibility

useVercelChat.ts remains far over the 100-line cap (412 LOC) and this PR adds more hook logic instead of splitting responsibilities.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At hooks/useVercelChat.ts, line 218:

<comment>`useVercelChat.ts` remains far over the 100-line cap (412 LOC) and this PR adds more hook logic instead of splitting responsibilities.</comment>

<file context>
@@ -216,6 +215,18 @@ export function useVercelChat({
       },
     });
 
+  useEffect(() => {
+    const shouldRefreshRecentChatsOnFirstStream =
+      !roomId &&
</file context>
Fix with Cubic

maybeRefreshOnFirstStream(status);
}, [status, maybeRefreshOnFirstStream]);

const earliestFailedUserMessageId = useMemo(
() => getEarliestFailedUserMessageId(messages),
[messages],
Expand Down Expand Up @@ -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);
Expand All @@ -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 });
Expand Down
Loading