diff --git a/hooks/useInitialMessageAutoSend.ts b/hooks/useInitialMessageAutoSend.ts new file mode 100644 index 000000000..66b804de8 --- /dev/null +++ b/hooks/useInitialMessageAutoSend.ts @@ -0,0 +1,88 @@ +import { useEffect, useRef } from "react"; +import type { UIMessage } from "ai"; +import { usePrivy } from "@privy-io/react-auth"; +import { useUserProvider } from "@/providers/UserProvder"; +import { generateUUID } from "@/lib/generateUUID"; +import { getInitialMessageText } from "@/lib/chat/getInitialMessageText"; +import { shouldAutoSendInitialMessage } from "@/lib/chat/shouldAutoSendInitialMessage"; + +interface UseInitialMessageAutoSendParams { + /** The ?q= deep-link message from /chat?q=… (AGENTS cards). */ + initialMessages?: UIMessage[]; + /** useChat transport status — only "ready" may send. */ + status: string; + messagesLength: number; + /** Api-minted session id; absent until the bootstrap provisions. */ + sessionId?: string; + input: string; + setInput: (value: string) => void; + /** The programmatic send path (handleSendQueryMessages). */ + send: (message: UIMessage) => void; +} + +/** + * The whole ?q= deep-link behavior, self-contained so useVercelChat + * stays closed against changes here (OCP — this hook is the extension + * point, like useChatTransport / usePersistSelectedModel). Auth state + * is sourced from usePrivy/useUserProvider directly; params carry only + * what is chat-instance-scoped. + * + * 1. Prefills the input with the prompt immediately — instant feedback + * that the AGENTS click landed while the workspace provisions. + * 2. Auto-fires once provisioning lands (chat#1847: sending earlier + * POSTs /api/chat without a sessionId and 400s), sending whatever is + * in the input at that moment — an edit made while waiting sends the + * edited text, a cleared input sends nothing — exactly like + * pressing Send. One-shot refs guard re-prefill and double-sends. + */ +export function useInitialMessageAutoSend({ + initialMessages, + status, + messagesLength, + sessionId, + input, + setInput, + send, +}: UseInitialMessageAutoSendParams): void { + const { authenticated } = usePrivy(); + const { userData } = useUserProvider(); + const userId = userData?.account_id || userData?.id; + + const initialMessageText = getInitialMessageText(initialMessages); + const didPrefillRef = useRef(false); + const didAutoFireRef = useRef(false); + + useEffect(() => { + if (!initialMessageText || didPrefillRef.current) return; + if (messagesLength > 0 || input) return; + didPrefillRef.current = true; + setInput(initialMessageText); + }, [initialMessageText, messagesLength, input, setInput]); + + useEffect(() => { + const mayAutoSend = shouldAutoSendInitialMessage({ + hasInitialMessages: Boolean(initialMessageText), + status, + messagesLength, + userId, + authenticated, + sessionId, + }); + if (!mayAutoSend || didAutoFireRef.current) return; + didAutoFireRef.current = true; + const text = input.trim() ? input : ""; + if (!text) return; + send({ id: generateUUID(), role: "user", parts: [{ type: "text", text }] }); + setInput(""); + }, [ + initialMessageText, + status, + messagesLength, + userId, + authenticated, + sessionId, + input, + setInput, + send, + ]); +} diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index 1a8ee70a6..9c615117b 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -24,7 +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 { shouldAutoSendInitialMessage } from "@/lib/chat/shouldAutoSendInitialMessage"; +import { useInitialMessageAutoSend } from "./useInitialMessageAutoSend"; import { usePersistSelectedModel } from "./usePersistSelectedModel"; interface UseVercelChatProps { @@ -384,32 +384,17 @@ export function useVercelChat({ [silentlyUpdateUrl, sendMessage, chatRequestBody, getHeaders], ); - useEffect(() => { - // Gates mirror the manual Send button, including the provisioned - // sessionId — without it the transport POSTs /api/chat with no - // sessionId and 400s (chat#1847). The effect re-runs when the - // bootstrap lands, so the ?q= message queues instead of failing. - const mayAutoSend = shouldAutoSendInitialMessage({ - hasInitialMessages: Boolean( - initialMessages && initialMessages.length > 0, - ), - status, - messagesLength: messages.length, - userId, - authenticated, - sessionId, - }); - if (!mayAutoSend || !initialMessages) return; - handleSendQueryMessages(initialMessages[0]); - }, [ + // The ?q= deep-link behavior (prefill + provisioning-gated auto-fire) + // lives entirely in this hook — extend it there, not here (chat#1847). + useInitialMessageAutoSend({ initialMessages, status, - userId, - handleSendQueryMessages, - messages.length, - authenticated, + messagesLength: messages.length, sessionId, - ]); + input, + setInput, + send: handleSendQueryMessages, + }); return { // States diff --git a/lib/chat/__tests__/getInitialMessageText.test.ts b/lib/chat/__tests__/getInitialMessageText.test.ts new file mode 100644 index 000000000..ec35d0c1f --- /dev/null +++ b/lib/chat/__tests__/getInitialMessageText.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import type { UIMessage } from "ai"; +import { getInitialMessageText } from "@/lib/chat/getInitialMessageText"; + +const message = (parts: UIMessage["parts"]): UIMessage[] => [ + { id: "m1", role: "user", parts }, +]; + +describe("getInitialMessageText", () => { + it("extracts the text of the first text part", () => { + expect( + getInitialMessageText(message([{ type: "text", text: "hello" }])), + ).toBe("hello"); + }); + + it("skips non-text parts", () => { + expect( + getInitialMessageText( + message([ + { type: "file", url: "https://x/y.png", mediaType: "image/png" }, + { type: "text", text: "caption" }, + ]), + ), + ).toBe("caption"); + }); + + it("returns undefined when there is nothing to prefill", () => { + expect(getInitialMessageText(undefined)).toBeUndefined(); + expect(getInitialMessageText([])).toBeUndefined(); + expect(getInitialMessageText(message([]))).toBeUndefined(); + expect( + getInitialMessageText(message([{ type: "text", text: "" }])), + ).toBeUndefined(); + }); +}); diff --git a/lib/chat/getInitialMessageText.ts b/lib/chat/getInitialMessageText.ts new file mode 100644 index 000000000..9f34c8e91 --- /dev/null +++ b/lib/chat/getInitialMessageText.ts @@ -0,0 +1,17 @@ +import type { UIMessage } from "ai"; + +/** + * Text of the first text part of a ?q= deep-link's initial message — + * used to prefill the chat input while the workspace provisions, so the + * click gives instant feedback instead of dead space (chat#1848 + * follow-up). Undefined when there is nothing meaningful to prefill. + */ +export function getInitialMessageText( + initialMessages?: UIMessage[], +): string | undefined { + const parts = initialMessages?.[0]?.parts ?? []; + for (const part of parts) { + if (part.type === "text" && part.text) return part.text; + } + return undefined; +}