From 904df41dee1018988250c5e25b52ad779cac7537 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 2 Jun 2026 22:52:30 +0530 Subject: [PATCH 01/12] feat(chat): wire stop button to POST /api/chat/{chatId}/stop The stop button only called the AI SDK stop() (local fetch abort), leaving the durable workflow run streaming and billing server-side. Wrap stop so it also fires POST /api/chat/{chatId}/stop (fire-and-forget, workflow chats only) to cancel the run. Co-Authored-By: Claude Opus 4.8 --- hooks/useVercelChat.ts | 15 +++++- lib/chat/__tests__/stopChatWorkflow.test.ts | 54 +++++++++++++++++++++ lib/chat/stopChatWorkflow.ts | 30 ++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 lib/chat/__tests__/stopChatWorkflow.test.ts create mode 100644 lib/chat/stopChatWorkflow.ts diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index 20d5d5713..a3e996288 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -23,6 +23,7 @@ import { formatTextAttachments } from "@/lib/chat/formatTextAttachments"; import { useDeleteTrailingMessages } from "./useDeleteTrailingMessages"; import { getFileContents } from "@/lib/sandboxes/getFileContents"; import getMimeFromPath from "@/lib/files/getMimeFromPath"; +import { stopChatWorkflow } from "@/lib/chat/stopChatWorkflow"; interface UseVercelChatProps { id: string; @@ -184,7 +185,7 @@ export function useVercelChat({ [id, artistId, organizationId, accountIdOverride, model], ); - const { messages, status, stop, sendMessage, setMessages, regenerate } = + const { messages, status, stop: aiStop, sendMessage, setMessages, regenerate } = useChat({ id, transport, @@ -200,6 +201,18 @@ export function useVercelChat({ }, }); + // Stop both the local stream (AI SDK abort) and the durable workflow + // run server-side. The backend cancel is fire-and-forget so the UI + // stops instantly; it only applies to workflow chats (sessionId set). + const stop = useCallback(async () => { + if (sessionId) { + void getAccessToken() + .catch(() => null) + .then((token) => stopChatWorkflow(id, token)); + } + await aiStop(); + }, [aiStop, sessionId, id, getAccessToken]); + const earliestFailedUserMessageId = useMemo( () => getEarliestFailedUserMessageId(messages), [messages], diff --git a/lib/chat/__tests__/stopChatWorkflow.test.ts b/lib/chat/__tests__/stopChatWorkflow.test.ts new file mode 100644 index 000000000..fedf56d89 --- /dev/null +++ b/lib/chat/__tests__/stopChatWorkflow.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { stopChatWorkflow } from "../stopChatWorkflow"; +import { NEW_API_BASE_URL } from "../../consts"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe("stopChatWorkflow", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("POSTs to the chat stop endpoint with a bearer token", async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + + await stopChatWorkflow("chat-1", "tok-123"); + + expect(mockFetch).toHaveBeenCalledWith( + `${NEW_API_BASE_URL}/api/chat/chat-1/stop`, + { + method: "POST", + headers: { Authorization: "Bearer tok-123" }, + }, + ); + }); + + it("omits the Authorization header when unauthenticated", async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + + await stopChatWorkflow("chat-1", null); + + expect(mockFetch).toHaveBeenCalledWith( + `${NEW_API_BASE_URL}/api/chat/chat-1/stop`, + { method: "POST", headers: {} }, + ); + }); + + it("url-encodes the chat id", async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + + await stopChatWorkflow("a/b c", "tok"); + + expect(mockFetch).toHaveBeenCalledWith( + `${NEW_API_BASE_URL}/api/chat/a%2Fb%20c/stop`, + expect.any(Object), + ); + }); + + it("never throws when the request fails", async () => { + mockFetch.mockRejectedValueOnce(new Error("network down")); + + await expect(stopChatWorkflow("chat-1", "tok")).resolves.toBeUndefined(); + }); +}); diff --git a/lib/chat/stopChatWorkflow.ts b/lib/chat/stopChatWorkflow.ts new file mode 100644 index 000000000..970880d04 --- /dev/null +++ b/lib/chat/stopChatWorkflow.ts @@ -0,0 +1,30 @@ +import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; + +/** + * Cancels the in-flight workflow run for a chat via recoup-api + * `POST /api/chat/{chatId}/stop`. + * + * The client AI SDK `stop()` only aborts the local fetch; the durable + * workflow run keeps streaming (and billing) server-side until it's + * cancelled here. Callers should fire this without blocking the UI stop. + * + * @param chatId - Chat row id (the workflow run is keyed off it). + * @param accessToken - Privy access token; omitted when unauthenticated. + * @returns Resolves once the request settles; never throws. + */ +export async function stopChatWorkflow( + chatId: string, + accessToken: string | null, +): Promise { + try { + await fetch( + `${getClientApiBaseUrl()}/api/chat/${encodeURIComponent(chatId)}/stop`, + { + method: "POST", + headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {}, + }, + ); + } catch { + // Best-effort: the run also self-cancels when its slot is cleared. + } +} From 3adb3236a4689833fff81f2c1708b2e923d197ad Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Wed, 3 Jun 2026 00:50:52 +0530 Subject: [PATCH 02/12] fix(chat): await stop POST and skip aiStop for workflow chats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calling aiStop() immediately after firing the stop POST tore down the local SSE before the server had finished cancelling — chunks the server kept emitting (and persisting via onStepFinish/onFinish) in that 1–1.5s window never reached the UI, so the assistant message visible at stop time differed from what got persisted. Reload showed more. Pair with the api-side change that holds /stop until the workflow run is terminal: await stopChatWorkflow, then return without aiStop. The server's runAgentWorkflow.finally closes the workflow writable, the SSE drains the remaining chunks to the client, and useChat transitions to "ready" on its own. Frontend at stop-complete now matches the DB. Legacy chats (no sessionId) keep the AI-SDK local abort — there's no backend endpoint to drive their teardown. Co-Authored-By: Claude Opus 4.8 --- hooks/useVercelChat.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index a3e996288..cd4aa6081 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -201,14 +201,19 @@ export function useVercelChat({ }, }); - // Stop both the local stream (AI SDK abort) and the durable workflow - // run server-side. The backend cancel is fire-and-forget so the UI - // stops instantly; it only applies to workflow chats (sessionId set). + // Producer-level stop for workflow chats: await the backend cancel so the + // server has time to run its cancellation, close the workflow writable, and + // drain SSE to the client. Once SSE closes naturally, the AI SDK + // transitions to "ready" on its own — calling `aiStop()` here would tear + // down the local fetch before in-flight chunks (which the server persists) + // reach the UI, so frontend and DB would disagree on reload. + // + // Legacy chats keep the AI-SDK local abort: no backend endpoint to call. const stop = useCallback(async () => { if (sessionId) { - void getAccessToken() - .catch(() => null) - .then((token) => stopChatWorkflow(id, token)); + const token = await getAccessToken().catch(() => null); + await stopChatWorkflow(id, token); + return; } await aiStop(); }, [aiStop, sessionId, id, getAccessToken]); From e470b81c12bb38679e75b56f4fcff87fcfa1495b Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Wed, 3 Jun 2026 03:28:23 +0530 Subject: [PATCH 03/12] chore(chat): trim verbose stop wrapper comment --- hooks/useVercelChat.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index cd4aa6081..25ec45a26 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -201,14 +201,9 @@ export function useVercelChat({ }, }); - // Producer-level stop for workflow chats: await the backend cancel so the - // server has time to run its cancellation, close the workflow writable, and - // drain SSE to the client. Once SSE closes naturally, the AI SDK - // transitions to "ready" on its own — calling `aiStop()` here would tear - // down the local fetch before in-flight chunks (which the server persists) - // reach the UI, so frontend and DB would disagree on reload. - // - // Legacy chats keep the AI-SDK local abort: no backend endpoint to call. + // Workflow chats: await the backend cancel and let SSE close naturally — + // calling aiStop here would tear down SSE before in-flight chunks reach + // the UI and frontend/DB would disagree on reload. Legacy chats abort locally. const stop = useCallback(async () => { if (sessionId) { const token = await getAccessToken().catch(() => null); From 1a62de34f877b576ce3d8526c454163e6d54221a Mon Sep 17 00:00:00 2001 From: ahmednahima0-beep Date: Wed, 3 Jun 2026 09:49:01 +0700 Subject: [PATCH 04/12] refactor(chat): update chat components to use chatId instead of roomId (#1765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(chat): update chat components to use chatId instead of roomId This commit removes the InstantChatRoom component and updates various hooks and components to replace references of roomId with chatId. The changes ensure that chat functionality is aligned with the new session-scoped architecture, improving consistency across the chat system. Additionally, the sessionId is now required in several components to streamline the chat transport process. * refactor(chat): integrate sessionId and update chat path utilities This commit enhances the chat components by introducing the `sessionId` in various hooks and updating the URL handling to utilize new utility functions for generating chat paths. The changes improve the consistency and maintainability of chat navigation, ensuring that the application correctly reflects the session-scoped architecture. * fix(chat): ensure newRoomId is treated as a string in URL handling This commit updates the `useCreateArtistTool` hook to explicitly cast `result.newRoomId` to a string when generating the chat path. This change prevents potential type-related issues and ensures consistent URL formatting. Additionally, it adds an import statement for testing utilities in the chat paths test file, enhancing test coverage and maintainability. * refactor(Header, OrganizationProvider): remove usePathname and replace with window.location.pathname This commit removes the usePathname hook from both Artist.tsx and OrganizationProvider.tsx, replacing it with direct access to window.location.pathname. This change simplifies the code and maintains the intended navigation behavior without relying on Next.js's router for pathname management. * refactor(chat): split chatPaths per SRP, drop dead legacy branch, simplify useParams - Remove dead `/chat/` branch from isActiveChatRoomPath — this PR makes /chat/{id} 404, so treating it as an active chat room contradicted the PR's own intent and could never fire in production. - Split lib/chat/chatPaths.ts into one-function-per-file (getChatPath, getChatUrl, isActiveChatRoomPath) per repo SRP convention; mirror tests. - Simplify useVercelChat to `const { chatId } = useParams<{ chatId?: string }>()` (KISS), consistent with chat.tsx. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Sweets Sweetman Co-authored-by: Claude Opus 4.8 (1M context) --- app/chat/[roomId]/page.tsx | 17 ---- components/Header/Artist.tsx | 20 ++--- .../Sidebar/RecentChats/useRecentChats.ts | 15 ++-- components/VercelChat/chat.tsx | 14 +--- hooks/useChatTransport.ts | 81 +++++++------------ hooks/useCreateArtistTool.ts | 10 ++- hooks/useMessageLoader.ts | 12 +-- hooks/useVercelChat.ts | 30 ++----- lib/chat/__tests__/getChatPath.test.ts | 10 +++ lib/chat/__tests__/getChatUrl.test.ts | 10 +++ .../__tests__/isActiveChatRoomPath.test.ts | 12 +++ lib/chat/getChatPath.ts | 3 + lib/chat/getChatUrl.ts | 5 ++ lib/chat/isActiveChatRoomPath.ts | 9 +++ lib/email/generateTxtFileEmail.ts | 5 +- providers/OrganizationProvider.tsx | 12 ++- providers/VercelChatProvider.tsx | 8 +- 17 files changed, 130 insertions(+), 143 deletions(-) delete mode 100644 app/chat/[roomId]/page.tsx create mode 100644 lib/chat/__tests__/getChatPath.test.ts create mode 100644 lib/chat/__tests__/getChatUrl.test.ts create mode 100644 lib/chat/__tests__/isActiveChatRoomPath.test.ts create mode 100644 lib/chat/getChatPath.ts create mode 100644 lib/chat/getChatUrl.ts create mode 100644 lib/chat/isActiveChatRoomPath.ts diff --git a/app/chat/[roomId]/page.tsx b/app/chat/[roomId]/page.tsx deleted file mode 100644 index a3aa71ba9..000000000 --- a/app/chat/[roomId]/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Chat } from "@/components/VercelChat/chat"; - -interface PageProps { - params: Promise<{ - roomId: string; - }>; -} - -export default async function InstantChatRoom({ params }: PageProps) { - const { roomId } = await params; - - return ( -
- -
- ); -} diff --git a/components/Header/Artist.tsx b/components/Header/Artist.tsx index afe5a9b61..33dabbdbd 100644 --- a/components/Header/Artist.tsx +++ b/components/Header/Artist.tsx @@ -2,12 +2,12 @@ import { useArtistProvider } from "@/providers/ArtistProvider"; import { ArtistRecord } from "@/types/Artist"; import ImageWithFallback from "../ImageWithFallback"; import { EllipsisVertical, Pin, PinOff } from "lucide-react"; -import { usePathname } from "next/navigation"; import { useState } from "react"; import { cn } from "@/lib/utils"; import { motion } from "framer-motion"; import { useArtistPinToggle } from "@/hooks/useArtistPinToggle"; import ArtistActionButton from "./ArtistActionButton"; +import { isActiveChatRoomPath } from "@/lib/chat/isActiveChatRoomPath"; const Artist = ({ artist, @@ -31,26 +31,22 @@ const Artist = ({ const isAnyArtistSelected = !!selectedArtist; const shouldHighlight = !isAnyArtistSelected; // Highlight when no artist is selected - const pathname = usePathname(); - - // True on a specific-chat URL, not bare `/chat`. We hard-nav rather - // than `router.replace` because `silentlyUpdateUrl` desyncs Next's - // router from the URL bar. - const isOnExistingChat = - /^\/sessions\/[^/]+\/chats\/[^/]+/.test(pathname) || - /^\/chat\/[^/]+/.test(pathname); - const handleClick = () => { toggleDropDown(); + const pathname = window.location.pathname; if (isSelectedArtist) { setSelectedArtist(null); - if (isOnExistingChat) window.location.href = "/chat"; + // Hard-nav on existing chat URLs — `silentlyUpdateUrl` desyncs Next's router. + if (isActiveChatRoomPath(pathname)) window.location.href = "/chat"; return; } setSelectedArtist(artist); - if (isOnExistingChat && selectedArtist?.account_id !== artist?.account_id) { + if ( + isActiveChatRoomPath(pathname) && + selectedArtist?.account_id !== artist?.account_id + ) { window.location.href = "/chat"; } }; diff --git a/components/Sidebar/RecentChats/useRecentChats.ts b/components/Sidebar/RecentChats/useRecentChats.ts index 81e64eeb3..c8480e0b5 100644 --- a/components/Sidebar/RecentChats/useRecentChats.ts +++ b/components/Sidebar/RecentChats/useRecentChats.ts @@ -21,20 +21,23 @@ export const useRecentChats = ({ toggleModal }: UseRecentChatsParams) => { const [hoveredChatId, setHoveredChatId] = useState(null); const [openMenuId, setOpenMenuId] = useState(null); const [activeChatId, setActiveChatId] = useState( - typeof params?.roomId === "string" ? params.roomId : null + typeof params?.chatId === "string" ? params.chatId : null, ); useEffect(() => { const updateActiveChatId = () => { - const urlChatId = window.location.pathname.match(/\/chat\/([^\/]+)/); + const match = window.location.pathname.match( + /\/sessions\/[^/]+\/chats\/([^/]+)/, + ); - if (urlChatId && urlChatId[1]) { - setActiveChatId(urlChatId[1]); + if (match?.[1]) { + setActiveChatId(match[1]); return; } - const roomId = typeof params?.roomId === "string" ? params.roomId : null; - setActiveChatId(roomId || null); + setActiveChatId( + typeof params?.chatId === "string" ? params.chatId : null, + ); }; updateActiveChatId(); diff --git a/components/VercelChat/chat.tsx b/components/VercelChat/chat.tsx index 6291ea656..aa6666b7e 100644 --- a/components/VercelChat/chat.tsx +++ b/components/VercelChat/chat.tsx @@ -21,14 +21,8 @@ import { useOrganization } from "@/providers/OrganizationProvider"; interface ChatProps { id: string; - /** - * Session id from the new-chat bootstrap. When present the chat - * routes through recoup-api's `/api/chat/workflow`; when absent it - * falls back to the legacy `/api/chat` (existing chats opened from - * history — they'll cut over once Phase 2 backfills `session_id` - * onto their rows, recoupable/chat#1747). - */ - sessionId?: string; + /** Session id from bootstrap or canonical session-scoped route. */ + sessionId: string; initialMessages?: UIMessage[]; } @@ -55,7 +49,7 @@ function ChatContentMemoized({ id: string; }) { const { messages, status, isLoading, hasError } = useVercelChatContext(); - const { roomId } = useParams(); + const { chatId: routeChatId } = useParams<{ chatId?: string }>(); useArtistFromRoom(id); const { getRootProps, isDragActive } = useDropzone(); @@ -65,7 +59,7 @@ function ChatContentMemoized({ }); if (isLoading) { - return roomId ? ( + return routeChatId ? ( ) : (
diff --git a/hooks/useChatTransport.ts b/hooks/useChatTransport.ts index 5f68b0fca..ee3d8f383 100644 --- a/hooks/useChatTransport.ts +++ b/hooks/useChatTransport.ts @@ -6,38 +6,15 @@ import { usePrivy } from "@privy-io/react-auth"; interface UseChatTransportOptions { /** Chat row id (also the AI SDK `useChat({ id })` instance id). */ chatId: string; - /** - * Session id from `createSession`. When provided, the transport - * targets recoup-api's `POST /api/chat/workflow` (the workflow path - * cutover tracked in recoupable/chat#1747) and injects - * `sessionId` + `chatId` + `recoupAccessToken` at the transport - * boundary. - * - * When absent, the transport targets the legacy `POST /api/chat` - * route — used by `/chat/[roomId]` pages opened from history that - * don't have a `session_id` on their chat row yet. Those rows will - * be backfilled in Phase 2 (recoupable/chat#1747), at which point - * this branch can be deleted. - */ - sessionId?: string; + /** Session id from bootstrap or canonical route — required for workflow transport. */ + sessionId: string; } /** - * Chat transport for chat.recoupable.com. Branches on `sessionId`: - * - **workflow path** (sessionId present) → recoup-api - * `/api/chat/workflow`, with `sessionId` + `chatId` + - * `recoupAccessToken` injected so per-`sendMessage` callers don't - * need to know the workflow body shape. - * - **legacy path** (no sessionId) → `/api/chat` on the same base, - * same behavior chat.recoupable.com had before the workflow - * cutover. Per-call body from `useVercelChat`'s - * `sendMessage(payload, { body })` (`roomId`, `artistId`, `model`, - * …) flows through unchanged. - * - * Both branches return the Privy JWT in the `Authorization: Bearer` - * header via `getHeaders`; the workflow branch also adds it - * transport-side so recoup-api's `validateAuthContext` accepts the - * cross-origin POST. + * Chat transport for chat.recoupable.com → recoup-api + * `POST /api/chat/workflow`. Injects `sessionId`, `chatId`, and + * `recoupAccessToken` at the transport boundary so callers only pass + * per-message fields (model, etc.) via `sendMessage`. */ export function useChatTransport({ chatId, @@ -51,31 +28,27 @@ export function useChatTransport({ return accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined; }, [getAccessToken]); - const transport = useMemo(() => { - if (!sessionId) { - return new DefaultChatTransport({ - api: `${baseUrl}/api/chat`, - }); - } - - return new DefaultChatTransport({ - api: `${baseUrl}/api/chat/workflow`, - headers: async (): Promise> => { - const accessToken = await getAccessToken().catch(() => null); - return accessToken ? { Authorization: `Bearer ${accessToken}` } : {}; - }, - body: async () => { - const recoupAccessToken = await getAccessToken().catch(() => null); - const body: { - sessionId: string; - chatId: string; - recoupAccessToken?: string; - } = { sessionId, chatId }; - if (recoupAccessToken) body.recoupAccessToken = recoupAccessToken; - return body; - }, - }); - }, [baseUrl, chatId, sessionId, getAccessToken]); + const transport = useMemo( + () => + new DefaultChatTransport({ + api: `${baseUrl}/api/chat/workflow`, + headers: async (): Promise> => { + const accessToken = await getAccessToken().catch(() => null); + return accessToken ? { Authorization: `Bearer ${accessToken}` } : {}; + }, + body: async () => { + const recoupAccessToken = await getAccessToken().catch(() => null); + const body: { + sessionId: string; + chatId: string; + recoupAccessToken?: string; + } = { sessionId, chatId }; + if (recoupAccessToken) body.recoupAccessToken = recoupAccessToken; + return body; + }, + }), + [baseUrl, chatId, sessionId, getAccessToken], + ); return { transport, getHeaders }; } diff --git a/hooks/useCreateArtistTool.ts b/hooks/useCreateArtistTool.ts index a5263662e..096d89ec4 100644 --- a/hooks/useCreateArtistTool.ts +++ b/hooks/useCreateArtistTool.ts @@ -3,6 +3,7 @@ import { useVercelChatContext } from "@/providers/VercelChatProvider"; import { useConversationsProvider } from "@/providers/ConversationsProvider"; import { CreateArtistResult } from "@/types/createArtistResult"; import copyMessages from "@/lib/messages/copyMessages"; +import { getChatPath } from "@/lib/chat/getChatPath"; import { usePrivy } from "@privy-io/react-auth"; /** @@ -10,7 +11,7 @@ import { usePrivy } from "@privy-io/react-auth"; * Handles refreshing artists, copying messages, and navigation */ export function useCreateArtistTool(result: CreateArtistResult) { - const { status, id } = useVercelChatContext(); + const { status, id, sessionId } = useVercelChatContext(); const { refetchConversations } = useConversationsProvider(); const { getAccessToken } = usePrivy(); const [isProcessing, setIsProcessing] = useState(false); @@ -54,7 +55,11 @@ export function useCreateArtistTool(result: CreateArtistResult) { if (success) { // Update the URL to point to the new conversation - window.history.replaceState({}, "", `/chat/${result.newRoomId}`); + window.history.replaceState( + {}, + "", + getChatPath(sessionId, result.newRoomId as string), + ); setIsSuccess(true); } else { console.error("Failed to copy messages"); @@ -79,6 +84,7 @@ export function useCreateArtistTool(result: CreateArtistResult) { isProcessing, refetchConversations, getAccessToken, + sessionId, ]); return { diff --git a/hooks/useMessageLoader.ts b/hooks/useMessageLoader.ts index 1bd0236e8..a826b571d 100644 --- a/hooks/useMessageLoader.ts +++ b/hooks/useMessageLoader.ts @@ -4,24 +4,20 @@ import { usePrivy } from "@privy-io/react-auth"; import { getChatMessages } from "@/lib/messages/getChatMessages"; /** - * Loads the persisted UI message stream for a session-scoped chat from - * recoup-api. Skips entirely when either id is missing — the new-chat - * bootstrap path mounts `` before a session has been minted, and - * the in-transition legacy `/chat/{roomId}` route lacks a sessionId - * until track I redirects it. + * Loads persisted UI messages for a session-scoped chat from recoup-api. */ export function useMessageLoader( - sessionId: string | undefined, + sessionId: string, chatId: string | undefined, userId: string | undefined, setMessages: (messages: UIMessage[]) => void, ) { const { getAccessToken } = usePrivy(); - const [isLoading, setIsLoading] = useState(!!(sessionId && chatId)); + const [isLoading, setIsLoading] = useState(!!chatId); const [error, setError] = useState(null); useEffect(() => { - if (!sessionId || !chatId) { + if (!chatId) { setIsLoading(false); return; } diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index 20d5d5713..976e37460 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -23,17 +23,12 @@ import { formatTextAttachments } from "@/lib/chat/formatTextAttachments"; import { useDeleteTrailingMessages } from "./useDeleteTrailingMessages"; import { getFileContents } from "@/lib/sandboxes/getFileContents"; import getMimeFromPath from "@/lib/files/getMimeFromPath"; +import { getChatPath } from "@/lib/chat/getChatPath"; interface UseVercelChatProps { id: string; - /** - * Session id from the chat-bootstrap (`createSession`). When present - * the transport targets `/api/chat/workflow`; when absent it falls - * back to the legacy `/api/chat` for chats opened from history that - * haven't been backfilled to the workflow architecture yet - * (recoupable/chat#1747 Phase 2). - */ - sessionId?: string; + /** Session id from bootstrap or `/sessions/[sessionId]/chats/[chatId]`. */ + sessionId: string; initialMessages?: UIMessage[]; attachments?: FileUIPart[]; textAttachments?: TextAttachment[]; @@ -54,7 +49,7 @@ export function useVercelChat({ const { userData } = useUserProvider(); const { selectedArtist } = useArtistProvider(); const { selectedOrgId: organizationId } = useOrganization(); - const { roomId } = useParams(); + const { chatId } = useParams<{ chatId?: string }>(); const userId = userData?.account_id || userData?.id; // Use account_id if available, fallback to id const artistId = selectedArtist?.account_id; @@ -289,21 +284,13 @@ export function useVercelChat({ setMessages, ); - // Only show loading state if: - // 1. We're loading messages - // 2. We have a roomId (meaning we're intentionally loading a chat) - // 3. We don't already have messages (important for redirects) + // Only show loading state when fetching persisted history for an existing chat. const isLoading = isMessagesLoading && !!id && messages.length === 0; const isGeneratingResponse = ["streaming", "submitted"].includes(status); const silentlyUpdateUrl = useCallback(() => { - if (!sessionId) return; - window.history.replaceState( - {}, - "", - `/sessions/${sessionId}/chats/${id}`, - ); + window.history.replaceState({}, "", getChatPath(sessionId, id)); }, [id, sessionId]); const handleSendMessage = async (event: React.FormEvent) => { @@ -322,9 +309,8 @@ export function useVercelChat({ // Submit the message handleSubmit(event); - if (!roomId) { - // Optimistically append a temporary conversation so it appears in Recent Chats - // It will be replaced by the real conversation after the updates/refetch + if (!chatId) { + // New chat from `/` or `/chat` — sidebar + URL update on first send. addOptimisticConversation("New Chat", id, sessionId, messageContent); silentlyUpdateUrl(); } diff --git a/lib/chat/__tests__/getChatPath.test.ts b/lib/chat/__tests__/getChatPath.test.ts new file mode 100644 index 000000000..8f13c02fc --- /dev/null +++ b/lib/chat/__tests__/getChatPath.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { getChatPath } from "@/lib/chat/getChatPath"; + +describe("getChatPath", () => { + it("builds a session-scoped chat path", () => { + expect(getChatPath("sess-1", "chat-2")).toBe( + "/sessions/sess-1/chats/chat-2", + ); + }); +}); diff --git a/lib/chat/__tests__/getChatUrl.test.ts b/lib/chat/__tests__/getChatUrl.test.ts new file mode 100644 index 000000000..2183fbdee --- /dev/null +++ b/lib/chat/__tests__/getChatUrl.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { getChatUrl } from "@/lib/chat/getChatUrl"; + +describe("getChatUrl", () => { + it("builds an absolute session-scoped chat URL", () => { + expect(getChatUrl("sess-1", "chat-2")).toBe( + "https://chat.recoupable.com/sessions/sess-1/chats/chat-2", + ); + }); +}); diff --git a/lib/chat/__tests__/isActiveChatRoomPath.test.ts b/lib/chat/__tests__/isActiveChatRoomPath.test.ts new file mode 100644 index 000000000..ccf11e2f7 --- /dev/null +++ b/lib/chat/__tests__/isActiveChatRoomPath.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { isActiveChatRoomPath } from "@/lib/chat/isActiveChatRoomPath"; + +describe("isActiveChatRoomPath", () => { + it("detects active chat room paths", () => { + expect(isActiveChatRoomPath("/sessions/s1/chats/c1")).toBe(true); + expect(isActiveChatRoomPath("/chat/legacy-id")).toBe(false); + expect(isActiveChatRoomPath("/chat")).toBe(false); + expect(isActiveChatRoomPath("/")).toBe(false); + expect(isActiveChatRoomPath(null)).toBe(false); + }); +}); diff --git a/lib/chat/getChatPath.ts b/lib/chat/getChatPath.ts new file mode 100644 index 000000000..d373d1341 --- /dev/null +++ b/lib/chat/getChatPath.ts @@ -0,0 +1,3 @@ +export function getChatPath(sessionId: string, chatId: string): string { + return `/sessions/${sessionId}/chats/${chatId}`; +} diff --git a/lib/chat/getChatUrl.ts b/lib/chat/getChatUrl.ts new file mode 100644 index 000000000..0bb89ce36 --- /dev/null +++ b/lib/chat/getChatUrl.ts @@ -0,0 +1,5 @@ +import { getChatPath } from "@/lib/chat/getChatPath"; + +export function getChatUrl(sessionId: string, chatId: string): string { + return `https://chat.recoupable.com${getChatPath(sessionId, chatId)}`; +} diff --git a/lib/chat/isActiveChatRoomPath.ts b/lib/chat/isActiveChatRoomPath.ts new file mode 100644 index 000000000..0ede6ab23 --- /dev/null +++ b/lib/chat/isActiveChatRoomPath.ts @@ -0,0 +1,9 @@ +const SESSION_CHAT_PATH = /^\/sessions\/[^/]+\/chats\/[^/]+/; + +/** True when the user is on an existing chat room (not `/` or `/chat` bootstrap). */ +export function isActiveChatRoomPath( + pathname: string | null | undefined, +): boolean { + if (!pathname) return false; + return SESSION_CHAT_PATH.test(pathname); +} diff --git a/lib/email/generateTxtFileEmail.ts b/lib/email/generateTxtFileEmail.ts index 6e2635e26..f795dfff0 100644 --- a/lib/email/generateTxtFileEmail.ts +++ b/lib/email/generateTxtFileEmail.ts @@ -1,5 +1,6 @@ import generateText from "@/lib/ai/generateText"; import sendEmail from "@/lib/email/sendEmail"; +import { getChatUrl } from "@/lib/chat/getChatUrl"; import { RECOUP_FROM_EMAIL } from "../consts"; import { getFetchableUrl } from "../arweave/gateway"; @@ -13,15 +14,17 @@ export default async function generateTxtFileEmail({ rawTextFile, arweaveFile, emails, + sessionId, conversationId, }: { rawTextFile: string; arweaveFile: string; emails: string[]; + sessionId: string; conversationId: string; }) { if (!emails?.length) return null; - const ctaUrl = `https://chat.recoupable.com/chat/${conversationId}`; + const ctaUrl = getChatUrl(sessionId, conversationId); const prompt = `You have a newly generated TXT file. Here is the data: Key Data diff --git a/providers/OrganizationProvider.tsx b/providers/OrganizationProvider.tsx index c11aece43..114227e7c 100644 --- a/providers/OrganizationProvider.tsx +++ b/providers/OrganizationProvider.tsx @@ -1,7 +1,7 @@ "use client"; import React, { createContext, useContext, useState, useEffect, useMemo, useCallback, useRef } from "react"; -import { useRouter, usePathname } from "next/navigation"; +import { isActiveChatRoomPath } from "@/lib/chat/isActiveChatRoomPath"; interface OrganizationContextType { selectedOrgId: string | null; @@ -27,8 +27,6 @@ const OrganizationProvider = ({ children }: { children: React.ReactNode }) => { const [isOrgSettingsOpen, setIsOrgSettingsOpen] = useState(false); const [editingOrgId, setEditingOrgId] = useState(null); const [isCreateOrgOpen, setIsCreateOrgOpen] = useState(false); - const router = useRouter(); - const pathname = usePathname(); const previousOrgId = useRef(null); // Load from localStorage on mount @@ -54,11 +52,11 @@ const OrganizationProvider = ({ children }: { children: React.ReactNode }) => { localStorage.removeItem(STORAGE_KEY); } - // Navigate to home when org changes (if on a chat room page) - if (isActualChange && pathname?.startsWith("/chat/")) { - router.push("/"); + // Hard-nav — `silentlyUpdateUrl` updates the URL bar without syncing Next's router. + if (isActualChange && isActiveChatRoomPath(window.location.pathname)) { + window.location.href = "/"; } - }, [pathname, router]); + }, []); const openOrgSettings = useCallback((orgId: string) => { setEditingOrgId(orgId); diff --git a/providers/VercelChatProvider.tsx b/providers/VercelChatProvider.tsx index d72bd0119..fbb4a6c55 100644 --- a/providers/VercelChatProvider.tsx +++ b/providers/VercelChatProvider.tsx @@ -17,6 +17,7 @@ import { TextAttachment } from "@/types/textAttachment"; // Interface for the context data interface VercelChatContextType { id: string | undefined; + sessionId: string; messages: UIMessage[]; availableModels: GatewayLanguageModelEntry[]; status: ChatStatus; @@ -59,11 +60,9 @@ interface VercelChatProviderProps { children: ReactNode; chatId: string; /** - * Session id from the new-chat bootstrap. Forwarded into - * `useVercelChat` -> `useChatTransport`; presence flips the - * transport to recoup-api's `/api/chat/workflow`. + * Session id for recoup-api `/api/chat/workflow` (required on every mount). */ - sessionId?: string; + sessionId: string; initialMessages?: UIMessage[]; } @@ -144,6 +143,7 @@ export function VercelChatProvider({ // Create the context value object const contextValue: VercelChatContextType = { id: chatId, + sessionId, messages, model, availableModels, From 08caa213a4208f84901b93296f57980d40abcac7 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Wed, 3 Jun 2026 10:02:29 -0500 Subject: [PATCH 05/12] feat(chat): defer new-chat bootstrap wait from spinner to send button (#1767) (#1774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(chat): defer new-chat bootstrap wait from spinner to send button (#1767) New chats landing on `/` or `/chat` blocked the whole view on a full-screen spinner while `POST /api/sessions` + `POST /api/sandbox` resolved (~12s cold, dominated by sandbox provisioning). Render `` immediately with a typeable input instead and provision in parallel; the Send button stays disabled with a "Preparing your workspace…" cue until the api-minted `sessionId` + `chatId` land, so the workflow transport never fires without the ids its validator requires. - NewChatBootstrap: render eagerly with a stable placeholder chat id (survives the preparing → ready transition); drop the spinner. - Thread `sessionId?`, `workflowChatId`, `isBootstrapPreparing` through Chat → VercelChatProvider → useVercelChat (provider kept inline). - useVercelChat: `transportChatId = workflowChatId ?? id` drives the transport, optimistic conversation, and URL; only load persisted history on a canonical route so no spinner flashes over the input during the ready transition. - ChatInput: gate Send on `isBootstrapPreparing`; input stays typeable. - useChatTransport / useMessageLoader: sessionId optional + guard. - useCreateArtistTool: guard the one context sessionId consumer. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(chat): send live sessionId/chatId from workflow transport (#1767) The new-chat flow mounts (and useChat) during provisioning with sessionId undefined + a placeholder chatId. useChat captures the transport from that mount render and does not swap to the rebuilt instance when the ids later resolve — so the first send POSTed `sessionId: undefined` to /api/chat/workflow and got a 400 (the URL rewrote correctly because it reads the live prop, not the transport). Read the ids from refs inside transport.body() so a single stable transport instance always sends the values current as of the request, regardless of useChat's stale capture. Removed chatId/sessionId from the memo deps since rebuilding the instance never helped. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(chat): replace preparing-text cue with a workspace status dot (#1767) Swap the flashing "Preparing your workspace…" toolbar text for a stoplight status dot in the top-right of the chat input: red = off (provisioning failed / unavailable), yellow = provisioning, green = ready. Hover shows context via the shadcn tooltip. - New `WorkspaceStatusIndicator` component in its own file (SRP) — a pure display component driven by a `status` prop, built on the existing shadcn-based `common/Tooltip`, so it's open for reuse without change. - Thread `workspaceStatus: "off" | "provisioning" | "ready"` through Chat → VercelChatProvider → context (replacing the `isBootstrapPreparing` boolean); Send is gated while it's not `ready`. - NewChatBootstrap now derives the status and renders `` even on a provisioning error (red dot + disabled Send, input still visible) instead of swapping in a full-screen error view. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(chat): simplify provisioning tooltip copy (#1767) Shorten the yellow/provisioning workspace-status tooltip to "Preparing your workspace". Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- components/VercelChat/ChatInput.tsx | 20 ++++-- components/VercelChat/NewChatBootstrap.tsx | 59 ++++++++------- .../VercelChat/WorkspaceStatusIndicator.tsx | 71 +++++++++++++++++++ components/VercelChat/chat.tsx | 32 ++++++++- hooks/useChatTransport.ts | 27 +++++-- hooks/useCreateArtistTool.ts | 16 +++-- hooks/useMessageLoader.ts | 4 +- hooks/useVercelChat.ts | 44 ++++++++++-- providers/VercelChatProvider.tsx | 19 ++++- 9 files changed, 234 insertions(+), 58 deletions(-) create mode 100644 components/VercelChat/WorkspaceStatusIndicator.tsx diff --git a/components/VercelChat/ChatInput.tsx b/components/VercelChat/ChatInput.tsx index 0340790e8..1e9eb99f0 100644 --- a/components/VercelChat/ChatInput.tsx +++ b/components/VercelChat/ChatInput.tsx @@ -13,6 +13,7 @@ import { } from "../ai-elements/prompt-input"; import ModelSelect from "@/components/ModelSelect"; import FileMentionsInput from "./FileMentionsInput"; +import WorkspaceStatusIndicator from "./WorkspaceStatusIndicator"; export function ChatInput() { const { @@ -22,6 +23,7 @@ export function ChatInput() { isLoadingSignedUrls, handleSendMessage, isGeneratingResponse, + workspaceStatus, stop, setInput, input, @@ -29,6 +31,13 @@ export function ChatInput() { } = useVercelChatContext(); // Allow typing regardless of artist selection const isDisabled = false; + // Block sending until the workspace (session + sandbox) is ready; the + // input stays typeable so users aren't stuck on a spinner meanwhile. + const isSendDisabled = + isDisabled || + hasPendingUploads || + isLoadingSignedUrls || + workspaceStatus !== "ready"; const handleSend = (event: React.FormEvent) => { event.preventDefault(); @@ -42,8 +51,7 @@ export function ChatInput() { // Only check input requirements for sending new messages // Allow sending if there are text attachments even without typed input const hasContent = input !== "" || textAttachments.length > 0; - if (!hasContent || isDisabled || hasPendingUploads || isLoadingSignedUrls) - return; + if (!hasContent || isSendDisabled) return; handleSendMessage(event); }; @@ -63,6 +71,9 @@ export function ChatInput() { animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3, ease: "easeOut" }} > +
+ +
diff --git a/components/VercelChat/NewChatBootstrap.tsx b/components/VercelChat/NewChatBootstrap.tsx index b69066421..4d00d8b2b 100644 --- a/components/VercelChat/NewChatBootstrap.tsx +++ b/components/VercelChat/NewChatBootstrap.tsx @@ -1,47 +1,54 @@ "use client"; import type { UIMessage } from "ai"; -import { Loader } from "lucide-react"; +import { useRef } from "react"; import { useNewChatBootstrap } from "@/hooks/useNewChatBootstrap"; import { Chat } from "@/components/VercelChat/chat"; +import type { WorkspaceStatus } from "@/components/VercelChat/WorkspaceStatusIndicator"; +import { generateUUID } from "@/lib/generateUUID"; interface NewChatBootstrapProps { initialMessages?: UIMessage[]; } /** - * Thin renderer over `useNewChatBootstrap`. Mounted by - * `app/page.tsx` (via HomePage) and `app/chat/page.tsx`; provisions - * a recoup-api session + sandbox before mounting `` so the - * workflow transport (`/api/chat/workflow`) has the `sessionId` + - * `chatId` its validator requires (recoupable/chat#1747). + * Thin renderer over `useNewChatBootstrap`. Mounted by `app/page.tsx` + * (via HomePage) and `app/chat/page.tsx`. Renders `` immediately + * with a typeable input and provisions a recoup-api session + sandbox in + * parallel — instead of blocking the whole view on a spinner. Send stays + * disabled (via `workspaceStatus`, surfaced as the status dot) until the + * api-minted `sessionId` + `chatId` land, so the workflow transport never + * fires without the ids its validator requires (recoupable/chat#1747, #1767). + * + * Auto-login is handled app-wide by `` in `UserProvider` + * (chat#1761) — do not call `useAutoLogin()` here. */ export default function NewChatBootstrap({ initialMessages, }: NewChatBootstrapProps) { const state = useNewChatBootstrap(); + // Stable client id so the instance (and anything typed into it) + // survives the preparing → ready transition. The api-minted chat id + // arrives via `workflowChatId` and takes over as the transport/URL + // target on the first send. + const placeholderChatId = useRef(generateUUID()).current; - if (state.status === "ready") { - return ( - - ); - } - - if (state.status === "error") { - return ( -
- {state.message} -
- ); - } + // Provisioning failures surface as the red "off" dot (input stays + // visible, Send disabled) rather than replacing the whole view. + const workspaceStatus: WorkspaceStatus = + state.status === "ready" + ? "ready" + : state.status === "error" + ? "off" + : "provisioning"; return ( -
- -
+ ); } diff --git a/components/VercelChat/WorkspaceStatusIndicator.tsx b/components/VercelChat/WorkspaceStatusIndicator.tsx new file mode 100644 index 000000000..50ab550f0 --- /dev/null +++ b/components/VercelChat/WorkspaceStatusIndicator.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Tooltip } from "@/components/common/Tooltip"; + +/** + * Lifecycle of the chat workspace (recoup-api session + sandbox) backing + * the workflow transport: + * - `off` — no workspace (provisioning failed / unavailable) + * - `provisioning` — session + sandbox being created; Send stays gated + * - `ready` — workspace live; Send enabled + */ +export type WorkspaceStatus = "off" | "provisioning" | "ready"; + +const STATUS_CONFIG: Record< + WorkspaceStatus, + { dotClassName: string; pulse: boolean; tooltip: string } +> = { + off: { + dotClassName: "bg-red-500", + pulse: false, + tooltip: "Workspace unavailable — refresh to try again.", + }, + provisioning: { + dotClassName: "bg-yellow-500", + pulse: true, + tooltip: "Preparing your workspace", + }, + ready: { + dotClassName: "bg-green-500", + pulse: false, + tooltip: "Workspace ready.", + }, +}; + +/** + * Stoplight status dot for the chat workspace: red = off, yellow = + * provisioning, green = ready. Hover for context. Pure display component — + * the status is owned by the chat context and passed in, so this stays + * open for reuse without modification (recoupable/chat#1767). + */ +export default function WorkspaceStatusIndicator({ + status, + className, +}: { + status: WorkspaceStatus; + className?: string; +}) { + const { dotClassName, pulse, tooltip } = STATUS_CONFIG[status]; + + return ( + + + + + + ); +} diff --git a/components/VercelChat/chat.tsx b/components/VercelChat/chat.tsx index aa6666b7e..7e5d7775f 100644 --- a/components/VercelChat/chat.tsx +++ b/components/VercelChat/chat.tsx @@ -18,15 +18,39 @@ import FileDragOverlay from "./FileDragOverlay"; import { Loader } from "lucide-react"; import { memo } from "react"; import { useOrganization } from "@/providers/OrganizationProvider"; +import type { WorkspaceStatus } from "./WorkspaceStatusIndicator"; interface ChatProps { id: string; - /** Session id from bootstrap or canonical session-scoped route. */ - sessionId: string; + /** + * Session id. Always present from the canonical session-scoped route; + * absent on the new-chat bootstrap path until provisioning resolves + * (see `NewChatBootstrap`). Send is gated while `workspaceStatus` is not + * `ready`, so the workflow transport never fires without it. + */ + sessionId?: string; + /** + * Api-minted chat id from bootstrap, used when `id` is a client + * placeholder. Becomes the transport/URL target once provisioning + * resolves; falls back to `id` otherwise. + */ + workflowChatId?: string; + /** + * Workspace (session + sandbox) lifecycle; gates Send and drives the + * status indicator. Defaults to `ready` for existing chats opened from + * the canonical route. + */ + workspaceStatus?: WorkspaceStatus; initialMessages?: UIMessage[]; } -export function Chat({ id, sessionId, initialMessages }: ChatProps) { +export function Chat({ + id, + sessionId, + workflowChatId, + workspaceStatus, + initialMessages, +}: ChatProps) { const { selectedOrgId } = useOrganization(); const providerKey = `${id}-${selectedOrgId ?? "personal"}`; @@ -35,6 +59,8 @@ export function Chat({ id, sessionId, initialMessages }: ChatProps) { key={providerKey} chatId={id} sessionId={sessionId} + workflowChatId={workflowChatId} + workspaceStatus={workspaceStatus} initialMessages={initialMessages} > diff --git a/hooks/useChatTransport.ts b/hooks/useChatTransport.ts index ee3d8f383..4a0054b47 100644 --- a/hooks/useChatTransport.ts +++ b/hooks/useChatTransport.ts @@ -1,4 +1,4 @@ -import { useMemo, useCallback } from "react"; +import { useMemo, useCallback, useRef } from "react"; import { DefaultChatTransport } from "ai"; import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; import { usePrivy } from "@privy-io/react-auth"; @@ -6,8 +6,12 @@ import { usePrivy } from "@privy-io/react-auth"; interface UseChatTransportOptions { /** Chat row id (also the AI SDK `useChat({ id })` instance id). */ chatId: string; - /** Session id from bootstrap or canonical route — required for workflow transport. */ - sessionId: string; + /** + * Session id from bootstrap or canonical route. Absent only while a new + * chat is still provisioning; Send is gated until it lands, so the + * transport is never invoked without it. + */ + sessionId?: string; } /** @@ -23,6 +27,17 @@ export function useChatTransport({ const { getAccessToken } = usePrivy(); const baseUrl = getClientApiBaseUrl(); + // Read the latest ids at request time via refs. `useChat` captures the + // transport from the mount render and does not swap it when `chatId` / + // `sessionId` change — so a new chat that mounts during provisioning + // (sessionId still undefined, chatId a placeholder) would otherwise POST + // those stale values on first send. Refs keep the transport instance + // stable while always sending the ids current as of the request. + const chatIdRef = useRef(chatId); + const sessionIdRef = useRef(sessionId); + chatIdRef.current = chatId; + sessionIdRef.current = sessionId; + const getHeaders = useCallback(async () => { const accessToken = await getAccessToken(); return accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined; @@ -39,15 +54,15 @@ export function useChatTransport({ body: async () => { const recoupAccessToken = await getAccessToken().catch(() => null); const body: { - sessionId: string; + sessionId: string | undefined; chatId: string; recoupAccessToken?: string; - } = { sessionId, chatId }; + } = { sessionId: sessionIdRef.current, chatId: chatIdRef.current }; if (recoupAccessToken) body.recoupAccessToken = recoupAccessToken; return body; }, }), - [baseUrl, chatId, sessionId, getAccessToken], + [baseUrl, getAccessToken], ); return { transport, getHeaders }; diff --git a/hooks/useCreateArtistTool.ts b/hooks/useCreateArtistTool.ts index 096d89ec4..fb34b316a 100644 --- a/hooks/useCreateArtistTool.ts +++ b/hooks/useCreateArtistTool.ts @@ -54,12 +54,16 @@ export function useCreateArtistTool(result: CreateArtistResult) { await refetchConversations(); if (success) { - // Update the URL to point to the new conversation - window.history.replaceState( - {}, - "", - getChatPath(sessionId, result.newRoomId as string), - ); + // Update the URL to point to the new conversation. sessionId is + // always set on an active (post-bootstrap) chat where this tool + // runs; guarded only to satisfy the optional context type. + if (sessionId) { + window.history.replaceState( + {}, + "", + getChatPath(sessionId, result.newRoomId as string), + ); + } setIsSuccess(true); } else { console.error("Failed to copy messages"); diff --git a/hooks/useMessageLoader.ts b/hooks/useMessageLoader.ts index a826b571d..fffc37d76 100644 --- a/hooks/useMessageLoader.ts +++ b/hooks/useMessageLoader.ts @@ -7,7 +7,7 @@ import { getChatMessages } from "@/lib/messages/getChatMessages"; * Loads persisted UI messages for a session-scoped chat from recoup-api. */ export function useMessageLoader( - sessionId: string, + sessionId: string | undefined, chatId: string | undefined, userId: string | undefined, setMessages: (messages: UIMessage[]) => void, @@ -17,7 +17,7 @@ export function useMessageLoader( const [error, setError] = useState(null); useEffect(() => { - if (!chatId) { + if (!chatId || !sessionId) { setIsLoading(false); return; } diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index 976e37460..4e0525af6 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -27,8 +27,18 @@ import { getChatPath } from "@/lib/chat/getChatPath"; interface UseVercelChatProps { id: string; - /** Session id from bootstrap or `/sessions/[sessionId]/chats/[chatId]`. */ - sessionId: string; + /** + * Session id from `/sessions/[sessionId]/chats/[chatId]` (always + * present) or the new-chat bootstrap (absent until provisioning + * resolves). Send is gated upstream while it's absent, so the transport + * never fires without it. + */ + sessionId?: string; + /** + * Api-minted chat id from bootstrap when `id` is a client placeholder. + * Used as the transport / message-load / URL target; falls back to `id`. + */ + workflowChatId?: string; initialMessages?: UIMessage[]; attachments?: FileUIPart[]; textAttachments?: TextAttachment[]; @@ -42,6 +52,7 @@ interface UseVercelChatProps { export function useVercelChat({ id, sessionId, + workflowChatId, initialMessages, attachments = [], textAttachments = [], @@ -59,8 +70,12 @@ export function useVercelChat({ const [input, setInput] = useState(""); const [model, setModel] = useLocalStorage("RECOUP_MODEL", DEFAULT_MODEL); const { refetchCredits } = usePaymentProvider(); + // The api-minted chat id once bootstrap resolves; before then `id` is a + // client placeholder. Drives the transport, message load, and URL so + // sends/persistence target the row recoup-api actually created. + const transportChatId = workflowChatId ?? id; const { transport, getHeaders } = useChatTransport({ - chatId: id, + chatId: transportChatId, sessionId, }); const { authenticated, getAccessToken } = usePrivy(); @@ -277,9 +292,14 @@ export function useVercelChat({ // Keep messagesRef in sync with messages messagesLengthRef.current = messages.length; + // Only load persisted history for an existing chat opened from a + // canonical `/sessions/[sessionId]/chats/[chatId]` URL (route `chatId` + // present). A new chat from the bootstrap has nothing to load, so we + // skip the fetch — avoiding a spinner flashing over the input the user + // is already typing into while provisioning resolves. const { isLoading: isMessagesLoading, hasError } = useMessageLoader( sessionId, - messages.length === 0 ? id : undefined, + messages.length === 0 && chatId ? transportChatId : undefined, userId, setMessages, ); @@ -290,8 +310,13 @@ export function useVercelChat({ const isGeneratingResponse = ["streaming", "submitted"].includes(status); const silentlyUpdateUrl = useCallback(() => { - window.history.replaceState({}, "", getChatPath(sessionId, id)); - }, [id, sessionId]); + if (!sessionId) return; + window.history.replaceState( + {}, + "", + getChatPath(sessionId, transportChatId), + ); + }, [transportChatId, sessionId]); const handleSendMessage = async (event: React.FormEvent) => { event.preventDefault(); @@ -311,7 +336,12 @@ export function useVercelChat({ if (!chatId) { // New chat from `/` or `/chat` — sidebar + URL update on first send. - addOptimisticConversation("New Chat", id, sessionId, messageContent); + addOptimisticConversation( + "New Chat", + transportChatId, + sessionId, + messageContent, + ); silentlyUpdateUrl(); } }; diff --git a/providers/VercelChatProvider.tsx b/providers/VercelChatProvider.tsx index fbb4a6c55..8bcfcaf2a 100644 --- a/providers/VercelChatProvider.tsx +++ b/providers/VercelChatProvider.tsx @@ -13,11 +13,12 @@ import { ChatStatus, FileUIPart, UIMessage } from "ai"; import { useArtistProvider } from "./ArtistProvider"; import { GatewayLanguageModelEntry } from "@ai-sdk/gateway"; import { TextAttachment } from "@/types/textAttachment"; +import type { WorkspaceStatus } from "@/components/VercelChat/WorkspaceStatusIndicator"; // Interface for the context data interface VercelChatContextType { id: string | undefined; - sessionId: string; + sessionId: string | undefined; messages: UIMessage[]; availableModels: GatewayLanguageModelEntry[]; status: ChatStatus; @@ -48,6 +49,8 @@ interface VercelChatContextType { removeTextAttachment: (index: number) => void; model: string; setModel: (model: string) => void; + /** Workspace (session + sandbox) lifecycle; gates Send + drives the status dot. */ + workspaceStatus: WorkspaceStatus; } // Create the context @@ -60,9 +63,15 @@ interface VercelChatProviderProps { children: ReactNode; chatId: string; /** - * Session id for recoup-api `/api/chat/workflow` (required on every mount). + * Session id for recoup-api `/api/chat/workflow`. Absent only on the + * new-chat bootstrap path until provisioning resolves; Send is gated on + * `isBootstrapPreparing` until it lands. */ - sessionId: string; + sessionId?: string; + /** Api-minted chat id from bootstrap when `chatId` is a placeholder. */ + workflowChatId?: string; + /** Workspace (session + sandbox) lifecycle; gates Send + drives the status dot. */ + workspaceStatus?: WorkspaceStatus; initialMessages?: UIMessage[]; } @@ -73,6 +82,8 @@ export function VercelChatProvider({ children, chatId, sessionId, + workflowChatId, + workspaceStatus = "ready", initialMessages, }: VercelChatProviderProps) { const { @@ -121,6 +132,7 @@ export function VercelChatProvider({ } = useVercelChat({ id: chatId, sessionId, + workflowChatId, initialMessages, attachments, textAttachments, @@ -170,6 +182,7 @@ export function VercelChatProvider({ setTextAttachments, addTextAttachment, removeTextAttachment, + workspaceStatus, }; // Send chat status and messages to ArtistProvider From b99ded4ca01cc010bace4d28fd83dd7265893c8a Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Thu, 4 Jun 2026 03:07:10 +0530 Subject: [PATCH 06/12] feat(chat): instant stop feedback via isStopping flag useVercelChat exposes a local isStopping state set immediately on click and cleared in finally. VercelChatProvider threads it through context. ChatInput overrides the submit button to a spinner (status="submitted") and disables re-clicks while the backend round trip is in flight, so the UI no longer looks dead for ~1-2s before SSE closes. SSE close still happens naturally via the backend watcher, preserving frontend == DB on reload. Co-Authored-By: Claude Opus 4.7 (1M context) --- components/VercelChat/ChatInput.tsx | 16 +++++++++++++--- hooks/useVercelChat.ts | 16 ++++++++++++++-- providers/VercelChatProvider.tsx | 3 +++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/components/VercelChat/ChatInput.tsx b/components/VercelChat/ChatInput.tsx index 0340790e8..fbfc904dd 100644 --- a/components/VercelChat/ChatInput.tsx +++ b/components/VercelChat/ChatInput.tsx @@ -22,6 +22,7 @@ export function ChatInput() { isLoadingSignedUrls, handleSendMessage, isGeneratingResponse, + isStopping, stop, setInput, input, @@ -33,6 +34,10 @@ export function ChatInput() { const handleSend = (event: React.FormEvent) => { event.preventDefault(); + // Already cancelling — ignore further clicks until the backend + // round-trip resolves and the SSE watcher closes the stream. + if (isStopping) return; + // Allow stop action regardless of input state if (isGeneratingResponse) { stop(); @@ -83,13 +88,18 @@ export function ChatInput() { diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index 25ec45a26..bfab56cf5 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -204,10 +204,21 @@ export function useVercelChat({ // Workflow chats: await the backend cancel and let SSE close naturally — // calling aiStop here would tear down SSE before in-flight chunks reach // the UI and frontend/DB would disagree on reload. Legacy chats abort locally. + // + // `isStopping` is set the moment the user clicks so the submit button can + // flip to a clear "stopping" state without waiting for the backend round + // trip — otherwise the UI looks dead for ~1-2s while the workflow detects + // the cancel and the SSE watcher closes the stream. + const [isStopping, setIsStopping] = useState(false); const stop = useCallback(async () => { if (sessionId) { - const token = await getAccessToken().catch(() => null); - await stopChatWorkflow(id, token); + setIsStopping(true); + try { + const token = await getAccessToken().catch(() => null); + await stopChatWorkflow(id, token); + } finally { + setIsStopping(false); + } return; } await aiStop(); @@ -384,6 +395,7 @@ export function useVercelChat({ isLoading, hasError, isGeneratingResponse, + isStopping, model, isLoadingSignedUrls, diff --git a/providers/VercelChatProvider.tsx b/providers/VercelChatProvider.tsx index d72bd0119..7c6b2764f 100644 --- a/providers/VercelChatProvider.tsx +++ b/providers/VercelChatProvider.tsx @@ -23,6 +23,7 @@ interface VercelChatContextType { isLoading: boolean; hasError: boolean; isGeneratingResponse: boolean; + isStopping: boolean; isLoadingSignedUrls: boolean; handleSendMessage: (event: React.FormEvent) => Promise; stop: UseChatHelpers["stop"]; @@ -108,6 +109,7 @@ export function VercelChatProvider({ isLoading, hasError, isGeneratingResponse, + isStopping, isLoadingSignedUrls, handleSendMessage, stop, @@ -151,6 +153,7 @@ export function VercelChatProvider({ isLoading, hasError, isGeneratingResponse, + isStopping, isLoadingSignedUrls, handleSendMessage: handleSendMessageWithClear, stop, From 1c696842b8acd429d69cadf4b9b50ad3d439c2f5 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Thu, 4 Jun 2026 03:35:39 +0530 Subject: [PATCH 07/12] fix(chat): render cancelled tool-calls with stop icon, not spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MessageParts only distinguished output-available vs everything-else, so any tool part in state output-error or output-denied fell through to getToolCallComponent and painted the spinning skeleton — making a cancelled tool look like it was still running, both live and on reload. Add a getCancelledToolComponent renderer (OctagonX icon + "Cancelled {toolName}" / "Failed {toolName}" label) and route output-error / output-denied parts to it. Co-Authored-By: Claude Opus 4.7 (1M context) --- components/VercelChat/MessageParts.tsx | 16 ++++++++++++--- components/VercelChat/ToolComponents.tsx | 26 +++++++++++++++++++++++- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/components/VercelChat/MessageParts.tsx b/components/VercelChat/MessageParts.tsx index 1e522c846..1383ade1c 100644 --- a/components/VercelChat/MessageParts.tsx +++ b/components/VercelChat/MessageParts.tsx @@ -10,7 +10,11 @@ import { Dispatch, SetStateAction } from "react"; import { cn } from "@/lib/utils"; import ViewingMessage from "./ViewingMessage"; import EditingMessage from "./EditingMessage"; -import { getToolCallComponent, getToolResultComponent } from "./ToolComponents"; +import { + getCancelledToolComponent, + getToolCallComponent, + getToolResultComponent, +} from "./ToolComponents"; import MessageFileViewer from "./message-file-viewer"; import { EnhancedReasoning } from "@/components/reasoning/EnhancedReasoning"; import { Actions, Action } from "@/components/actions"; @@ -107,11 +111,17 @@ export function MessageParts({ message, mode, setMode }: MessagePartsProps) { if (isToolOrDynamicToolUIPart(part)) { const { state } = part as ToolUIPart; + // Terminal-error states (user-cancel via stop, approval-denied, + // tool-thrown errors) need their own renderer — falling through + // to getToolCallComponent paints the spinning skeleton, which + // makes a cancelled tool look like it's still running. + if (state === "output-error" || state === "output-denied") { + return getCancelledToolComponent(part as ToolUIPart); + } if (state !== "output-available") { return getToolCallComponent(part as ToolUIPart); - } else { - return getToolResultComponent(part as ToolUIPart); } + return getToolResultComponent(part as ToolUIPart); } } )} diff --git a/components/VercelChat/ToolComponents.tsx b/components/VercelChat/ToolComponents.tsx index 763a69408..9267072b2 100644 --- a/components/VercelChat/ToolComponents.tsx +++ b/components/VercelChat/ToolComponents.tsx @@ -23,7 +23,7 @@ import UpdateArtistSocialsSuccess from "./tools/UpdateArtistSocialsSuccess"; import { UpdateArtistSocialsResult } from "./tools/UpdateArtistSocialsSuccess"; import { TxtFileResult } from "@/components/ui/TxtFileResult"; import { TxtFileGenerationResult } from "@/components/ui/TxtFileResult"; -import { Loader } from "lucide-react"; +import { Loader, OctagonX } from "lucide-react"; import GenericSuccess from "./tools/GenericSuccess"; import getToolInfo from "@/lib/utils/getToolsInfo"; import { isSearchProgressUpdate } from "@/lib/search/searchProgressUtils"; @@ -220,6 +220,30 @@ export function getToolCallComponent(part: ToolUIPart) { ); } +/** + * Render a tool-call that terminated in `output-error` or `output-denied` + * (cancelled by the user, denied by approval flow, or errored). Without + * this branch the renderer falls through to the spinning skeleton and + * the pill keeps "running" forever. + */ +export function getCancelledToolComponent(part: ToolUIPart | DynamicToolUIPart) { + const { toolCallId } = part; + const toolName = getToolOrDynamicToolName(part); + const label = + (part as { errorText?: string }).errorText === "Cancelled" + ? `Cancelled ${toolName}` + : `Failed ${toolName}`; + return ( +
+ + {label} +
+ ); +} + export function getToolResultComponent(part: ToolUIPart | DynamicToolUIPart) { const { toolCallId, output, type } = part; const isMcp = type === "dynamic-tool"; From 81a45498c61744e1f76470c28e481ddd5da32dd0 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Thu, 4 Jun 2026 03:57:23 +0530 Subject: [PATCH 08/12] refactor(chat): extract useStopChatWorkflow hook useVercelChat was managing isStopping state, token fetch, and the backend round-trip inline. Pull it out into a dedicated hook so the stop concern is encapsulated, useVercelChat thins out, and the hook is reusable from any consumer that needs workflow-stop semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- hooks/useStopChatWorkflow.ts | 30 ++++++++++++++++++++++++++++++ hooks/useVercelChat.ts | 22 ++++++---------------- 2 files changed, 36 insertions(+), 16 deletions(-) create mode 100644 hooks/useStopChatWorkflow.ts diff --git a/hooks/useStopChatWorkflow.ts b/hooks/useStopChatWorkflow.ts new file mode 100644 index 000000000..4b05bb48d --- /dev/null +++ b/hooks/useStopChatWorkflow.ts @@ -0,0 +1,30 @@ +import { useCallback, useState } from "react"; +import { usePrivy } from "@privy-io/react-auth"; +import { stopChatWorkflow } from "@/lib/chat/stopChatWorkflow"; + +/** + * Wraps the `POST /api/chat/{chatId}/stop` round-trip with a local + * `isStopping` flag. Consumers (e.g. the submit-button) read `isStopping` + * to flip to a "stopping" state the instant the user clicks, so the UI + * doesn't sit dead for the 1–2s while the backend cancels the workflow + * and the SSE watcher closes the stream. + * + * Workflow-chat-only: legacy `/api/chat` aborts locally via the AI SDK's + * `stop()` and never hits this hook. + */ +export function useStopChatWorkflow(chatId: string) { + const { getAccessToken } = usePrivy(); + const [isStopping, setIsStopping] = useState(false); + + const stop = useCallback(async () => { + setIsStopping(true); + try { + const token = await getAccessToken().catch(() => null); + await stopChatWorkflow(chatId, token); + } finally { + setIsStopping(false); + } + }, [chatId, getAccessToken]); + + return { stop, isStopping }; +} diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index bfab56cf5..78416a7d2 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -23,7 +23,7 @@ import { formatTextAttachments } from "@/lib/chat/formatTextAttachments"; import { useDeleteTrailingMessages } from "./useDeleteTrailingMessages"; import { getFileContents } from "@/lib/sandboxes/getFileContents"; import getMimeFromPath from "@/lib/files/getMimeFromPath"; -import { stopChatWorkflow } from "@/lib/chat/stopChatWorkflow"; +import { useStopChatWorkflow } from "./useStopChatWorkflow"; interface UseVercelChatProps { id: string; @@ -203,26 +203,16 @@ export function useVercelChat({ // Workflow chats: await the backend cancel and let SSE close naturally — // calling aiStop here would tear down SSE before in-flight chunks reach - // the UI and frontend/DB would disagree on reload. Legacy chats abort locally. - // - // `isStopping` is set the moment the user clicks so the submit button can - // flip to a clear "stopping" state without waiting for the backend round - // trip — otherwise the UI looks dead for ~1-2s while the workflow detects - // the cancel and the SSE watcher closes the stream. - const [isStopping, setIsStopping] = useState(false); + // the UI and frontend/DB would disagree on reload. Legacy chats abort + // locally via the AI SDK's `stop()`. + const { stop: stopWorkflow, isStopping } = useStopChatWorkflow(id); const stop = useCallback(async () => { if (sessionId) { - setIsStopping(true); - try { - const token = await getAccessToken().catch(() => null); - await stopChatWorkflow(id, token); - } finally { - setIsStopping(false); - } + await stopWorkflow(); return; } await aiStop(); - }, [aiStop, sessionId, id, getAccessToken]); + }, [aiStop, sessionId, stopWorkflow]); const earliestFailedUserMessageId = useMemo( () => getEarliestFailedUserMessageId(messages), From 8f9d9f41711732c1acb536d6bf960bad0ec430c5 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Thu, 4 Jun 2026 04:03:38 +0530 Subject: [PATCH 09/12] refactor(chat): useStopChatWorkflow on react-query useMutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the established hook pattern (useDeleteChat, useCreateTask, usePulseToggle, etc.) and drop the hand-rolled useState. Same public shape — { stop, isStopping } — so useVercelChat is untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- hooks/useStopChatWorkflow.ts | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/hooks/useStopChatWorkflow.ts b/hooks/useStopChatWorkflow.ts index 4b05bb48d..21c02ba0e 100644 --- a/hooks/useStopChatWorkflow.ts +++ b/hooks/useStopChatWorkflow.ts @@ -1,30 +1,29 @@ -import { useCallback, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; import { usePrivy } from "@privy-io/react-auth"; import { stopChatWorkflow } from "@/lib/chat/stopChatWorkflow"; /** - * Wraps the `POST /api/chat/{chatId}/stop` round-trip with a local - * `isStopping` flag. Consumers (e.g. the submit-button) read `isStopping` - * to flip to a "stopping" state the instant the user clicks, so the UI - * doesn't sit dead for the 1–2s while the backend cancels the workflow - * and the SSE watcher closes the stream. + * Wraps the `POST /api/chat/{chatId}/stop` round-trip with a React Query + * mutation. Consumers read `isStopping` (`mutation.isPending`) to flip + * the submit button to a "stopping" state the instant the user clicks, + * so the UI doesn't sit dead for the 1–2s while the backend cancels the + * workflow and the SSE watcher closes the stream. * * Workflow-chat-only: legacy `/api/chat` aborts locally via the AI SDK's * `stop()` and never hits this hook. */ export function useStopChatWorkflow(chatId: string) { const { getAccessToken } = usePrivy(); - const [isStopping, setIsStopping] = useState(false); - const stop = useCallback(async () => { - setIsStopping(true); - try { + const mutation = useMutation({ + mutationFn: async () => { const token = await getAccessToken().catch(() => null); await stopChatWorkflow(chatId, token); - } finally { - setIsStopping(false); - } - }, [chatId, getAccessToken]); + }, + }); - return { stop, isStopping }; + return { + stop: mutation.mutateAsync, + isStopping: mutation.isPending, + }; } From d985c87bdfeb98ea8c193bb855206f9a3274a5b9 Mon Sep 17 00:00:00 2001 From: ahmednahima0-beep Date: Wed, 3 Jun 2026 16:13:06 -0700 Subject: [PATCH 10/12] fix(chat): drop legacy GET /api/chats/{id}/artist caller (#1767) (#1768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(chat): drop legacy GET /api/chats/{id}/artist caller (#1767) * fix(chat): reactive conversations cache + user-scoped session query * refactor(chat): drop dead /chat/:id artist cache fallback (post-#1765) The conversations-cache fallback in useArtistFromChat only existed for the legacy /chat/:id deep link (chatId but no sessionId). That route was removed in #1765 — every chat is now reached via the canonical /sessions/{sessionId}/chats/{chatId} URL, so sessionId is always present. Reduce to the session path: GET /api/sessions/{sessionId} -> session.artistId -> select. Removes useSyncExternalStore reactive-cache machinery, findArtistIdInConversationsCache (+ its test), and the unused chatId param. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chat): validate getSessionById response with zod (PR #1768 review) Replace type-casting with boundary zod validation, matching the getConversations convention. Drops the `as GetSessionByIdResponse`/ `as GetSessionByIdErrorResponse` casts in favor of safeParse/parse. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chat): extract shared safeJsonParse into lib/api (PR #1768 review) Replace the locally-defined safeJsonParse in getSessionById with a shared helper in lib/api/, alongside the other HTTP helpers. DRY/SRP. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Sweets Sweetman Co-authored-by: Claude Opus 4.8 (1M context) --- components/VercelChat/chat.tsx | 13 ++++--- hooks/useArtistFromChat.ts | 64 ++++++++++++++++++++++++++++++++++ hooks/useArtistFromRoom.ts | 42 ---------------------- lib/api/safeJsonParse.ts | 13 +++++++ lib/chats/getChatArtist.ts | 30 ---------------- lib/sessions/getSessionById.ts | 50 ++++++++++++++++++++++++++ 6 files changed, 135 insertions(+), 77 deletions(-) create mode 100644 hooks/useArtistFromChat.ts delete mode 100644 hooks/useArtistFromRoom.ts create mode 100644 lib/api/safeJsonParse.ts delete mode 100644 lib/chats/getChatArtist.ts create mode 100644 lib/sessions/getSessionById.ts diff --git a/components/VercelChat/chat.tsx b/components/VercelChat/chat.tsx index 7e5d7775f..204abd1f7 100644 --- a/components/VercelChat/chat.tsx +++ b/components/VercelChat/chat.tsx @@ -7,7 +7,7 @@ import ChatSkeleton from "../Chat/ChatSkeleton"; import ChatGreeting from "../Chat/ChatGreeting"; import useVisibilityDelay from "@/hooks/useVisibilityDelay"; import { useParams } from "next/navigation"; -import { useArtistFromRoom } from "@/hooks/useArtistFromRoom"; +import { useArtistFromChat } from "@/hooks/useArtistFromChat"; import { VercelChatProvider, useVercelChatContext, @@ -63,20 +63,21 @@ export function Chat({ workspaceStatus={workspaceStatus} initialMessages={initialMessages} > - + ); } // Inner component that uses the context function ChatContentMemoized({ - id, + sessionId, }: { id: string; + sessionId?: string; }) { const { messages, status, isLoading, hasError } = useVercelChatContext(); const { chatId: routeChatId } = useParams<{ chatId?: string }>(); - useArtistFromRoom(id); + useArtistFromChat({ sessionId }); const { getRootProps, isDragActive } = useDropzone(); const { isVisible } = useVisibilityDelay({ @@ -144,5 +145,7 @@ function ChatContentMemoized({ } const ChatContent = memo(ChatContentMemoized, (prevProps, nextProps) => { - return prevProps.id === nextProps.id; + return ( + prevProps.id === nextProps.id && prevProps.sessionId === nextProps.sessionId + ); }); diff --git a/hooks/useArtistFromChat.ts b/hooks/useArtistFromChat.ts new file mode 100644 index 000000000..b87074ea7 --- /dev/null +++ b/hooks/useArtistFromChat.ts @@ -0,0 +1,64 @@ +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { usePrivy } from "@privy-io/react-auth"; +import { useArtistProvider } from "@/providers/ArtistProvider"; +import { useUserProvider } from "@/providers/UserProvder"; +import type { ArtistRecord } from "@/types/Artist"; +import { getSessionById } from "@/lib/sessions/getSessionById"; + +interface UseArtistFromChatParams { + sessionId?: string; +} + +/** + * Selects the artist linked to the open chat from its session's `artistId` + * (`GET /api/sessions/{sessionId}`), so opening a chat switches the active + * artist to that chat's artist. Replaces the legacy + * `GET /api/chats/{chatId}/artist` call, which 404s on workflow chats. + * + * Every chat is reached via the canonical + * `/sessions/{sessionId}/chats/{chatId}` route, so `sessionId` is always + * available — no chatId-only fallback is needed (the legacy `/chat/:id` + * route was removed in chat#1765). + */ +export function useArtistFromChat({ sessionId }: UseArtistFromChatParams) { + const { getAccessToken } = usePrivy(); + const { userData } = useUserProvider(); + const userId = userData?.id; + const { selectedArtist, artists, setSelectedArtist, getArtists } = + useArtistProvider(); + + const { data: sessionData } = useQuery({ + queryKey: ["session", userId, sessionId], + queryFn: async () => { + const accessToken = await getAccessToken(); + if (!accessToken) { + throw new Error("No access token"); + } + return getSessionById(sessionId as string, accessToken); + }, + enabled: !!sessionId && !!userId, + staleTime: Infinity, + retry: 1, + }); + + const artistAccountId = sessionData?.session.artistId ?? undefined; + + useEffect(() => { + if (!artistAccountId || selectedArtist?.account_id === artistAccountId) { + return; + } + + const artistList = artists as ArtistRecord[]; + const artist = artistList.find( + (entry) => entry.account_id === artistAccountId, + ); + + if (artist) { + setSelectedArtist(artist); + return; + } + + getArtists(artistAccountId); + }, [artistAccountId, selectedArtist, artists, setSelectedArtist, getArtists]); +} diff --git a/hooks/useArtistFromRoom.ts b/hooks/useArtistFromRoom.ts deleted file mode 100644 index 351629309..000000000 --- a/hooks/useArtistFromRoom.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useEffect } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { usePrivy } from "@privy-io/react-auth"; -import { useArtistProvider } from "@/providers/ArtistProvider"; -import { useUserProvider } from "@/providers/UserProvder"; -import type { ArtistRecord } from "@/types/Artist"; -import { getChatArtist } from "@/lib/chats/getChatArtist"; - -/** - * A hook that automatically selects the artist associated with a room. - * @param roomId The ID of the room to get the artist for - */ -export function useArtistFromRoom(roomId: string) { - const { getAccessToken } = usePrivy(); - const { userData } = useUserProvider(); - const { selectedArtist, artists, setSelectedArtist, getArtists } = useArtistProvider(); - - const { data } = useQuery({ - queryKey: ["chatArtist", roomId], - queryFn: async () => { - const accessToken = await getAccessToken(); - if (!accessToken) throw new Error("No access token"); - return getChatArtist(roomId, accessToken); - }, - enabled: !!roomId && !!userData?.id, - staleTime: Infinity, - retry: 2, - }); - - useEffect(() => { - if (!data?.artist_id || selectedArtist?.account_id === data.artist_id) return; - - const artistList = artists as ArtistRecord[]; - const artist = artistList.find(a => a.account_id === data.artist_id); - - if (artist) { - setSelectedArtist(artist); - } else { - getArtists(data.artist_id); - } - }, [data, selectedArtist, artists, setSelectedArtist, getArtists]); -} diff --git a/lib/api/safeJsonParse.ts b/lib/api/safeJsonParse.ts new file mode 100644 index 000000000..6ae5fdb29 --- /dev/null +++ b/lib/api/safeJsonParse.ts @@ -0,0 +1,13 @@ +/** + * Parses an HTTP response body as JSON, returning `null` instead of + * throwing on invalid input. Returns `unknown` so callers validate the + * shape themselves (e.g. with a zod schema) rather than trusting an + * unchecked cast. + */ +export function safeJsonParse(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return null; + } +} diff --git a/lib/chats/getChatArtist.ts b/lib/chats/getChatArtist.ts deleted file mode 100644 index f8751b66a..000000000 --- a/lib/chats/getChatArtist.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; - -interface ChatArtistResponse { - status: string; - room_id: string; - artist_id: string | null; - artist_exists: boolean; -} - -/** - * Fetches the artist associated with a chat room. - */ -export async function getChatArtist( - roomId: string, - accessToken: string, -): Promise { - const url = getClientApiBaseUrl(); - - const response = await fetch(`${url}/api/chats/${encodeURIComponent(roomId)}/artist`, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - - if (!response.ok) { - throw new Error("Failed to fetch chat artist"); - } - - return response.json(); -} diff --git a/lib/sessions/getSessionById.ts b/lib/sessions/getSessionById.ts new file mode 100644 index 000000000..b5ec818a6 --- /dev/null +++ b/lib/sessions/getSessionById.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; +import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; +import { safeJsonParse } from "@/lib/api/safeJsonParse"; + +/** + * Wire shape of recoup-api `GET /api/sessions/{sessionId}`, validated at + * the boundary. Only `session.artistId` is consumed here (to resolve the + * active artist when opening a canonical session chat URL), so the schema + * is intentionally narrow and ignores the rest of the payload. + */ +const sessionResponseSchema = z.object({ + session: z.object({ + artistId: z.string().nullable(), + }), +}); + +export type GetSessionByIdResponse = z.infer; + +const errorResponseSchema = z.object({ error: z.string() }).partial(); + +/** + * Fetches a single session from recoup-api `GET /api/sessions/{sessionId}`. + * Used to resolve `artistId` when opening a canonical session chat URL. + */ +export async function getSessionById( + sessionId: string, + recoupAccessToken: string, +): Promise { + const response = await fetch( + `${getClientApiBaseUrl()}/api/sessions/${encodeURIComponent(sessionId)}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${recoupAccessToken}`, + }, + cache: "no-store", + }, + ); + + if (!response.ok) { + const rawBody = await response.text().catch(() => ""); + const parsed = errorResponseSchema.safeParse(safeJsonParse(rawBody)); + const message = + (parsed.success ? parsed.data.error : undefined) ?? + "Failed to fetch session"; + throw new Error(message); + } + + return sessionResponseSchema.parse(await response.json()); +} From c711516f0c5b9d67b2f87dc86278cd4092574f4e Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Thu, 4 Jun 2026 18:16:28 -0500 Subject: [PATCH 11/12] fix(chat): persist selected model before send so the workflow bills it (#1781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(chat): persist selected model before send so the workflow bills it The chat-workflow path bills the model it reads from chats.model_id at request time. The picker selection was never written there, so every new chat (and any model change) billed the chats.model_id default instead of the chosen model. Before sending, await a PATCH of the selected model to chats.model_id when it changed for the active chat (shouldPersistChatModel guards redundant writes). The chat row already exists from new-chat bootstrap, so this covers the first turn race-free without an api change. Reuses buildPatchChatBody + updateChat(modelId) (originally from #1779). Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(chat): extract model-persist-before-send into usePersistSelectedModel Addresses OCP/SRP review feedback on #1781: instead of inlining the persist-before-send block in useVercelChat, encapsulate it in a dedicated usePersistSelectedModel hook (owns the lastPersisted ref + guard + PATCH). useVercelChat now just calls `await persistSelectedModel()` before send. Behavior unchanged; pure decision logic still covered by shouldPersistChatModel tests. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(chat): persist selected model on retry/edit + append paths Addresses review feedback on #1781: - Retry/Edit call reload() → regenerate (and append() calls sendMessage) bypassing handleSubmit, so the selected model was not persisted before those sends — the regenerated turn could bill the previous/default model. Now await persistSelectedModel() in handleReload and append too. - Clarify updateChat JSDoc: it patches title and/or modelId (not just rename). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- hooks/usePersistSelectedModel.ts | 52 +++++++++++++++++++ hooks/useVercelChat.ts | 23 +++++++- .../__tests__/buildPatchChatBody.test.ts | 25 +++++++++ .../__tests__/shouldPersistChatModel.test.ts | 38 ++++++++++++++ lib/chats/buildPatchChatBody.ts | 22 ++++++++ lib/chats/shouldPersistChatModel.ts | 25 +++++++++ lib/chats/updateChat.ts | 9 ++-- 7 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 hooks/usePersistSelectedModel.ts create mode 100644 lib/chats/__tests__/buildPatchChatBody.test.ts create mode 100644 lib/chats/__tests__/shouldPersistChatModel.test.ts create mode 100644 lib/chats/buildPatchChatBody.ts create mode 100644 lib/chats/shouldPersistChatModel.ts diff --git a/hooks/usePersistSelectedModel.ts b/hooks/usePersistSelectedModel.ts new file mode 100644 index 000000000..2218a85ea --- /dev/null +++ b/hooks/usePersistSelectedModel.ts @@ -0,0 +1,52 @@ +"use client"; + +import { useCallback, useRef } from "react"; +import { updateChat } from "@/lib/chats/updateChat"; +import { + shouldPersistChatModel, + type PersistedChatModel, +} from "@/lib/chats/shouldPersistChatModel"; + +interface UsePersistSelectedModelInput { + sessionId: string | undefined; + chatId: string; + model: string; + getAccessToken: () => Promise; +} + +/** + * Returns a function that persists the picker's selected model to + * `chats.model_id` before a send. + * + * The chat-workflow path bills the model it reads from `chats.model_id` at + * request time, so the write must land first — the returned function is + * meant to be `await`ed before `sendMessage` so a new chat's very first turn + * bills the selected model, not the default. Redundant writes are skipped + * via `shouldPersistChatModel`, and a failed PATCH never blocks the send. + */ +export function usePersistSelectedModel({ + sessionId, + chatId, + model, + getAccessToken, +}: UsePersistSelectedModelInput): () => Promise { + // The {chatId, model} we last wrote, so we only PATCH when the selection + // actually changes for the active chat. + const lastPersistedModelRef = useRef(null); + + return useCallback(async () => { + if (!sessionId || !chatId) return; + if (!shouldPersistChatModel(lastPersistedModelRef.current, chatId, model)) { + return; + } + + try { + const accessToken = await getAccessToken(); + if (!accessToken) return; + await updateChat({ accessToken, sessionId, chatId, modelId: model }); + lastPersistedModelRef.current = { chatId, model }; + } catch (error) { + console.error("Failed to persist selected model before send:", error); + } + }, [sessionId, chatId, model, getAccessToken]); +} diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index 4e0525af6..ab9d202c2 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -24,6 +24,7 @@ import { useDeleteTrailingMessages } from "./useDeleteTrailingMessages"; import { getFileContents } from "@/lib/sandboxes/getFileContents"; import getMimeFromPath from "@/lib/files/getMimeFromPath"; import { getChatPath } from "@/lib/chat/getChatPath"; +import { usePersistSelectedModel } from "./usePersistSelectedModel"; interface UseVercelChatProps { id: string; @@ -80,6 +81,16 @@ export function useVercelChat({ }); const { authenticated, getAccessToken } = usePrivy(); + // Persists the picker's selected model to chats.model_id; awaited before + // send so the workflow bills the selected model (it reads chats.model_id + // at request time), not the default. + const persistSelectedModel = usePersistSelectedModel({ + sessionId, + chatId: transportChatId, + model, + getAccessToken, + }); + // Load artist files for mentions (from Supabase) const { files: allArtistFiles = [] } = useArtistFilesForMentions(); @@ -275,19 +286,29 @@ export function useVercelChat({ files: nonAudioAttachments.length > 0 ? nonAudioAttachments : undefined, }; + // Land the selected model in chats.model_id before the send fires. + await persistSelectedModel(); + sendMessage(payload, { body: chatRequestBody, headers }); setInput(""); }; const append = async (message: UIMessage) => { const headers = await getHeaders(); + // Retry/Edit and programmatic sends bypass handleSubmit, so persist the + // selected model here too before the workflow reads chats.model_id. + await persistSelectedModel(); sendMessage(message, { body: chatRequestBody, headers }); }; const handleReload = useCallback(async () => { 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. + await persistSelectedModel(); await regenerate({ body: chatRequestBody, headers }); - }, [getHeaders, regenerate, chatRequestBody]); + }, [getHeaders, regenerate, chatRequestBody, persistSelectedModel]); // Keep messagesRef in sync with messages messagesLengthRef.current = messages.length; diff --git a/lib/chats/__tests__/buildPatchChatBody.test.ts b/lib/chats/__tests__/buildPatchChatBody.test.ts new file mode 100644 index 000000000..ff24013ed --- /dev/null +++ b/lib/chats/__tests__/buildPatchChatBody.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { buildPatchChatBody } from "@/lib/chats/buildPatchChatBody"; + +describe("buildPatchChatBody", () => { + it("includes only provided fields", () => { + expect(buildPatchChatBody({ title: "My chat" })).toEqual({ + title: "My chat", + }); + expect( + buildPatchChatBody({ modelId: "anthropic/claude-opus-4.6" }), + ).toEqual({ modelId: "anthropic/claude-opus-4.6" }); + expect( + buildPatchChatBody({ + title: "My chat", + modelId: "openai/gpt-5.4-mini", + }), + ).toEqual({ title: "My chat", modelId: "openai/gpt-5.4-mini" }); + }); + + it("throws when no fields are provided", () => { + expect(() => buildPatchChatBody({})).toThrow( + "At least one of title or modelId is required", + ); + }); +}); diff --git a/lib/chats/__tests__/shouldPersistChatModel.test.ts b/lib/chats/__tests__/shouldPersistChatModel.test.ts new file mode 100644 index 000000000..f921793c1 --- /dev/null +++ b/lib/chats/__tests__/shouldPersistChatModel.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { shouldPersistChatModel } from "@/lib/chats/shouldPersistChatModel"; + +describe("shouldPersistChatModel", () => { + it("persists when nothing has been persisted yet (new chat, first send)", () => { + expect(shouldPersistChatModel(null, "chat-1", "anthropic/claude-opus-4.6")).toBe(true); + }); + + it("persists when the chat changed since the last persist", () => { + expect( + shouldPersistChatModel( + { chatId: "chat-1", model: "anthropic/claude-opus-4.6" }, + "chat-2", + "anthropic/claude-opus-4.6", + ), + ).toBe(true); + }); + + it("persists when the model changed for the same chat", () => { + expect( + shouldPersistChatModel( + { chatId: "chat-1", model: "openai/gpt-5.4-mini" }, + "chat-1", + "anthropic/claude-opus-4.6", + ), + ).toBe(true); + }); + + it("does not persist when the same model is already persisted for the same chat", () => { + expect( + shouldPersistChatModel( + { chatId: "chat-1", model: "anthropic/claude-opus-4.6" }, + "chat-1", + "anthropic/claude-opus-4.6", + ), + ).toBe(false); + }); +}); diff --git a/lib/chats/buildPatchChatBody.ts b/lib/chats/buildPatchChatBody.ts new file mode 100644 index 000000000..26d21be77 --- /dev/null +++ b/lib/chats/buildPatchChatBody.ts @@ -0,0 +1,22 @@ +export type PatchChatBody = { + title?: string; + modelId?: string; +}; + +/** + * Builds the JSON body for `PATCH /api/sessions/{sessionId}/chats/{chatId}`. + * At least one field is required (matches api `PatchSessionChatBody`). + */ +export function buildPatchChatBody(fields: PatchChatBody): PatchChatBody { + const body: PatchChatBody = {}; + if (fields.title !== undefined) { + body.title = fields.title; + } + if (fields.modelId !== undefined) { + body.modelId = fields.modelId; + } + if (Object.keys(body).length === 0) { + throw new Error("At least one of title or modelId is required"); + } + return body; +} diff --git a/lib/chats/shouldPersistChatModel.ts b/lib/chats/shouldPersistChatModel.ts new file mode 100644 index 000000000..77e61bc29 --- /dev/null +++ b/lib/chats/shouldPersistChatModel.ts @@ -0,0 +1,25 @@ +export type PersistedChatModel = { chatId: string; model: string } | null; + +/** + * Decides whether the picker's selected model must be persisted to + * `chats.model_id` before the next send. + * + * The chat-workflow path bills the model it reads from `chats.model_id` at + * request time, so the selection has to be written before the send fires. + * We persist when we haven't persisted for this chat yet (covers a new + * chat's first turn) or when the model changed since the last persist for + * this chat — and skip the redundant PATCH otherwise. + * + * @param last - The {chatId, model} we last persisted, or null if none yet. + * @param chatId - The chat about to receive the message. + * @param model - The currently selected model id. + */ +export function shouldPersistChatModel( + last: PersistedChatModel, + chatId: string, + model: string, +): boolean { + if (!last) return true; + if (last.chatId !== chatId) return true; + return last.model !== model; +} diff --git a/lib/chats/updateChat.ts b/lib/chats/updateChat.ts index af932a995..4acf554d7 100644 --- a/lib/chats/updateChat.ts +++ b/lib/chats/updateChat.ts @@ -1,10 +1,12 @@ import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; +import { buildPatchChatBody } from "@/lib/chats/buildPatchChatBody"; export type UpdateChatParams = { accessToken: string; sessionId: string; chatId: string; - title: string; + title?: string; + modelId?: string; }; export type UpdateChatResponse = { @@ -22,7 +24,7 @@ export type UpdateChatResponse = { }; /** - * Renames (or otherwise patches) a session-scoped chat via recoup-api + * Patches a session-scoped chat (title and/or modelId) via recoup-api * `PATCH /api/sessions/{sessionId}/chats/{chatId}`. Returns the updated * row under `{ chat }`. */ @@ -31,6 +33,7 @@ export async function updateChat({ sessionId, chatId, title, + modelId, }: UpdateChatParams): Promise { const response = await fetch( `${getClientApiBaseUrl()}/api/sessions/${encodeURIComponent(sessionId)}/chats/${encodeURIComponent(chatId)}`, @@ -40,7 +43,7 @@ export async function updateChat({ "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, }, - body: JSON.stringify({ title }), + body: JSON.stringify(buildPatchChatBody({ title, modelId })), }, ); From 9798ef303afa7e331cba38cefa2c560519795d29 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Thu, 4 Jun 2026 19:16:08 -0500 Subject: [PATCH 12/12] feat(chat): repoint chat transport to canonical POST /api/chat (#1783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 of the /api/chat/workflow → /api/chat rename (chat#1767), against the merged docs contract (docs#235) and the api endpoint (api#645, on test). useChatTransport now POSTs to ${baseUrl}/api/chat instead of /api/chat/workflow. Comment-only refs in VercelChatProvider updated too. ⚠️ Deploy coordination: requires api#645 (which removes /api/chat/workflow) to be live in the same environment. Must deploy together with the api promotion and the open-agents repoint (step 4), or chat 404s. Co-authored-by: Claude Opus 4.8 (1M context) --- hooks/useChatTransport.ts | 4 ++-- providers/VercelChatProvider.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/hooks/useChatTransport.ts b/hooks/useChatTransport.ts index 4a0054b47..b4439e534 100644 --- a/hooks/useChatTransport.ts +++ b/hooks/useChatTransport.ts @@ -16,7 +16,7 @@ interface UseChatTransportOptions { /** * Chat transport for chat.recoupable.com → recoup-api - * `POST /api/chat/workflow`. Injects `sessionId`, `chatId`, and + * `POST /api/chat`. Injects `sessionId`, `chatId`, and * `recoupAccessToken` at the transport boundary so callers only pass * per-message fields (model, etc.) via `sendMessage`. */ @@ -46,7 +46,7 @@ export function useChatTransport({ const transport = useMemo( () => new DefaultChatTransport({ - api: `${baseUrl}/api/chat/workflow`, + api: `${baseUrl}/api/chat`, headers: async (): Promise> => { const accessToken = await getAccessToken().catch(() => null); return accessToken ? { Authorization: `Bearer ${accessToken}` } : {}; diff --git a/providers/VercelChatProvider.tsx b/providers/VercelChatProvider.tsx index 8bcfcaf2a..d085a8f4c 100644 --- a/providers/VercelChatProvider.tsx +++ b/providers/VercelChatProvider.tsx @@ -63,7 +63,7 @@ interface VercelChatProviderProps { children: ReactNode; chatId: string; /** - * Session id for recoup-api `/api/chat/workflow`. Absent only on the + * Session id for recoup-api `/api/chat`. Absent only on the * new-chat bootstrap path until provisioning resolves; Send is gated on * `isBootstrapPreparing` until it lands. */