From 057674daf0762fe5086da2cefbca11910d4236e6 Mon Sep 17 00:00:00 2001 From: pradipthaadhi Date: Tue, 9 Jun 2026 02:06:25 +0700 Subject: [PATCH 1/2] fix(chat): trailing delete uses transportChatId for api #636 --- components/VercelChat/message-editor.tsx | 6 ++-- hooks/useVercelChat.ts | 36 ++++++++++++++++++++++-- providers/VercelChatProvider.tsx | 4 +++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/components/VercelChat/message-editor.tsx b/components/VercelChat/message-editor.tsx index 34867ecfa..987101a9a 100644 --- a/components/VercelChat/message-editor.tsx +++ b/components/VercelChat/message-editor.tsx @@ -9,8 +9,8 @@ import { useVercelChatContext } from "@/providers/VercelChatProvider"; import { TextUIPart, UIMessage } from "ai"; export function MessageEditor({ message, setMode }: EditingMessageProps) { - const { id, setMessages, reload } = useVercelChatContext(); - if (!id) { + const { transportChatId, setMessages, reload } = useVercelChatContext(); + if (!transportChatId) { throw new Error("MessageEditor requires an active chat id"); } const text = (message.parts[0] as TextUIPart)?.text || ""; @@ -83,7 +83,7 @@ export function MessageEditor({ message, setMode }: EditingMessageProps) { disabled={isDeletingTrailingMessages} onClick={() => { void deleteTrailingMessages({ - chatId: id, + chatId: transportChatId, fromMessageId: message.id, }); }} diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index ab9d202c2..0dab97097 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -21,6 +21,7 @@ import { usePrivy } from "@privy-io/react-auth"; import { TextAttachment } from "@/types/textAttachment"; import { formatTextAttachments } from "@/lib/chat/formatTextAttachments"; import { useDeleteTrailingMessages } from "./useDeleteTrailingMessages"; +import { deleteTrailingMessages as deleteTrailingMessagesApi } from "@/lib/messages/deleteTrailingMessages"; import { getFileContents } from "@/lib/sandboxes/getFileContents"; import getMimeFromPath from "@/lib/files/getMimeFromPath"; import { getChatPath } from "@/lib/chat/getChatPath"; @@ -307,8 +308,38 @@ export function useVercelChat({ // bypassing handleSubmit; persist the selected model first so the // regenerated turn bills it, not the previous/default model. await persistSelectedModel(); + + const lastMessage = messages.at(-1); + if (lastMessage?.role === "assistant") { + const accessToken = await getAccessToken(); + if (!accessToken) { + toast.error("Please sign in to regenerate."); + return; + } + + try { + await deleteTrailingMessagesApi({ + chatId: transportChatId, + fromMessageId: lastMessage.id, + accessToken, + }); + } catch (error) { + console.error("Failed to delete trailing messages before regenerate:", error); + toast.error("Failed to regenerate. Please try again."); + return; + } + } + await regenerate({ body: chatRequestBody, headers }); - }, [getHeaders, regenerate, chatRequestBody, persistSelectedModel]); + }, [ + getHeaders, + getAccessToken, + regenerate, + chatRequestBody, + persistSelectedModel, + messages, + transportChatId, + ]); // Keep messagesRef in sync with messages messagesLengthRef.current = messages.length; @@ -344,7 +375,7 @@ export function useVercelChat({ if (earliestFailedUserMessageId) { await deleteTrailingMessages({ - chatId: id, + chatId: transportChatId, fromMessageId: earliestFailedUserMessageId, }); } @@ -403,6 +434,7 @@ export function useVercelChat({ return { // States messages, + transportChatId, status, input, isLoading, diff --git a/providers/VercelChatProvider.tsx b/providers/VercelChatProvider.tsx index d085a8f4c..0226a3728 100644 --- a/providers/VercelChatProvider.tsx +++ b/providers/VercelChatProvider.tsx @@ -18,6 +18,8 @@ import type { WorkspaceStatus } from "@/components/VercelChat/WorkspaceStatusInd // Interface for the context data interface VercelChatContextType { id: string | undefined; + /** Api-facing chat id (`workflowChatId ?? id`) for recoup-api calls. */ + transportChatId: string; sessionId: string | undefined; messages: UIMessage[]; availableModels: GatewayLanguageModelEntry[]; @@ -114,6 +116,7 @@ export function VercelChatProvider({ // Use the useVercelChat hook to get the chat state and functions const { messages, + transportChatId, status, isLoading, hasError, @@ -155,6 +158,7 @@ export function VercelChatProvider({ // Create the context value object const contextValue: VercelChatContextType = { id: chatId, + transportChatId, sessionId, messages, model, From a87a0aac7f7d113d48394bcd04dd245465bea467 Mon Sep 17 00:00:00 2001 From: pradipthaadhi Date: Tue, 9 Jun 2026 02:18:21 +0700 Subject: [PATCH 2/2] fix(chat): skip duplicate trailing delete on edit reload --- components/VercelChat/message-editor.tsx | 2 +- hooks/reloadOptions.ts | 5 ++ hooks/useVercelChat.ts | 61 +++++++++++++++--------- lib/messages/deleteTrailingMessages.ts | 15 +++++- providers/VercelChatProvider.tsx | 10 ++-- 5 files changed, 65 insertions(+), 28 deletions(-) create mode 100644 hooks/reloadOptions.ts diff --git a/components/VercelChat/message-editor.tsx b/components/VercelChat/message-editor.tsx index 987101a9a..eebf5a5c9 100644 --- a/components/VercelChat/message-editor.tsx +++ b/components/VercelChat/message-editor.tsx @@ -31,7 +31,7 @@ export function MessageEditor({ message, setMode }: EditingMessageProps) { }); setMode("view"); if (text !== draftContent) { - reload(); + void reload({ skipTrailingDelete: true }); } }, }); diff --git a/hooks/reloadOptions.ts b/hooks/reloadOptions.ts new file mode 100644 index 000000000..5eba48f71 --- /dev/null +++ b/hooks/reloadOptions.ts @@ -0,0 +1,5 @@ +/** Options for {@link useVercelChat}'s reload (retry / edit-and-resubmit). */ +export type ReloadOptions = { + /** When true, skip server trailing delete (caller already deleted). */ + skipTrailingDelete?: boolean; +}; diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index 0dab97097..aad08ed35 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -21,7 +21,11 @@ import { usePrivy } from "@privy-io/react-auth"; import { TextAttachment } from "@/types/textAttachment"; import { formatTextAttachments } from "@/lib/chat/formatTextAttachments"; import { useDeleteTrailingMessages } from "./useDeleteTrailingMessages"; -import { deleteTrailingMessages as deleteTrailingMessagesApi } from "@/lib/messages/deleteTrailingMessages"; +import { + deleteTrailingMessages as deleteTrailingMessagesApi, + TrailingDeleteError, +} from "@/lib/messages/deleteTrailingMessages"; +import type { ReloadOptions } from "./reloadOptions"; import { getFileContents } from "@/lib/sandboxes/getFileContents"; import getMimeFromPath from "@/lib/files/getMimeFromPath"; import { getChatPath } from "@/lib/chat/getChatPath"; @@ -302,31 +306,44 @@ export function useVercelChat({ sendMessage(message, { body: chatRequestBody, headers }); }; - const handleReload = useCallback(async () => { + const handleReload = useCallback(async (options?: ReloadOptions) => { const headers = await getHeaders(); - // Retry/Edit (MessageParts + message-editor) call reload() → regenerate, - // bypassing handleSubmit; persist the selected model first so the - // regenerated turn bills it, not the previous/default model. + // MessageParts Retry calls reload() to delete the assistant tail then + // regenerate. MessageEditor already deleted trailing server-side and + // passes skipTrailingDelete (messages state may still list the assistant). await persistSelectedModel(); - const lastMessage = messages.at(-1); - if (lastMessage?.role === "assistant") { - const accessToken = await getAccessToken(); - if (!accessToken) { - toast.error("Please sign in to regenerate."); - return; - } + if (!options?.skipTrailingDelete) { + const lastMessage = messages.at(-1); + if (lastMessage?.role === "assistant") { + const accessToken = await getAccessToken(); + if (!accessToken) { + toast.error("Please sign in to regenerate."); + return; + } - try { - await deleteTrailingMessagesApi({ - chatId: transportChatId, - fromMessageId: lastMessage.id, - accessToken, - }); - } catch (error) { - console.error("Failed to delete trailing messages before regenerate:", error); - toast.error("Failed to regenerate. Please try again."); - return; + try { + await deleteTrailingMessagesApi({ + chatId: transportChatId, + fromMessageId: lastMessage.id, + accessToken, + }); + } catch (error) { + // Boundary already removed (e.g. race) — still regenerate. + if ( + error instanceof TrailingDeleteError && + error.status === 404 + ) { + // no-op + } else { + console.error( + "Failed to delete trailing messages before regenerate:", + error, + ); + toast.error("Failed to regenerate. Please try again."); + return; + } + } } } diff --git a/lib/messages/deleteTrailingMessages.ts b/lib/messages/deleteTrailingMessages.ts index 8c1d0994e..812da7133 100644 --- a/lib/messages/deleteTrailingMessages.ts +++ b/lib/messages/deleteTrailingMessages.ts @@ -1,5 +1,15 @@ import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; +export class TrailingDeleteError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "TrailingDeleteError"; + this.status = status; + } +} + /** * Deletes trailing messages in a chat from a given message ID onward. */ @@ -24,6 +34,9 @@ export async function deleteTrailingMessages({ ); if (!response.ok) { - throw new Error("Failed to delete trailing messages"); + throw new TrailingDeleteError( + "Failed to delete trailing messages", + response.status, + ); } } diff --git a/providers/VercelChatProvider.tsx b/providers/VercelChatProvider.tsx index 0226a3728..f11ac3363 100644 --- a/providers/VercelChatProvider.tsx +++ b/providers/VercelChatProvider.tsx @@ -14,6 +14,7 @@ import { useArtistProvider } from "./ArtistProvider"; import { GatewayLanguageModelEntry } from "@ai-sdk/gateway"; import { TextAttachment } from "@/types/textAttachment"; import type { WorkspaceStatus } from "@/components/VercelChat/WorkspaceStatusIndicator"; +import type { ReloadOptions } from "@/hooks/reloadOptions"; // Interface for the context data interface VercelChatContextType { @@ -33,7 +34,7 @@ interface VercelChatContextType { setInput: (input: string) => void; input: string; setMessages: UseChatHelpers["setMessages"]; - reload: () => void; + reload: (options?: ReloadOptions) => Promise; append: (message: UIMessage) => void; attachments: FileUIPart[]; pendingAttachments: FileUIPart[]; @@ -141,9 +142,10 @@ export function VercelChatProvider({ textAttachments, }); - const reload = useCallback(() => { - return originalReload(); - }, [originalReload]); + const reload = useCallback( + (options?: ReloadOptions) => originalReload(options), + [originalReload], + ); // When a message is sent successfully, clear the attachments const handleSendMessageWithClear = async (