From f36c460970cbc647eb41436a2f28b935cac4e6d7 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Sat, 4 Jul 2026 12:04:14 -0500 Subject: [PATCH 1/3] feat: prefill the input with the ?q= prompt while provisioning (chat#1848 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After #1848 the deep-link message correctly waits for the session, but the wait is dead space: nothing on screen tells the customer their AGENTS click worked. Prefill the input with the prompt immediately, then auto-fire when the workspace goes ready — sending whatever is in the input at that moment, so an edit made while waiting sends the edited text and a cleared input sends nothing, exactly like pressing Send. - lib/chat/getInitialMessageText.ts (SRP, unit-tested): first text part of the deep-link message, undefined when nothing meaningful. - useVercelChat: one-shot prefill effect (never clobbers an existing conversation or typed text) + auto-fire builds the message from the live input; one-shot ref prevents double-sends. TDD RED->GREEN (3-case extractor suite); full suite 83 passed; tsc no new errors vs main; prettier clean. Co-Authored-By: Claude Fable 5 --- hooks/useVercelChat.ts | 39 ++++++++++++++++--- .../__tests__/getInitialMessageText.test.ts | 35 +++++++++++++++++ lib/chat/getInitialMessageText.ts | 17 ++++++++ 3 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 lib/chat/__tests__/getInitialMessageText.test.ts create mode 100644 lib/chat/getInitialMessageText.ts diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index 1a8ee70a6..fd7620cda 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -25,6 +25,7 @@ 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 { getInitialMessageText } from "@/lib/chat/getInitialMessageText"; import { usePersistSelectedModel } from "./usePersistSelectedModel"; interface UseVercelChatProps { @@ -384,31 +385,57 @@ export function useVercelChat({ [silentlyUpdateUrl, sendMessage, chatRequestBody, getHeaders], ); + const initialMessageText = getInitialMessageText(initialMessages); + const didPrefillInputRef = useRef(false); + const didAutoFireRef = useRef(false); + + useEffect(() => { + // Prefill the input with the ?q= prompt so the click gives instant + // feedback while the workspace provisions — no dead space wondering + // whether it worked. Runs once, and never over an existing + // conversation or anything already typed. + if (!initialMessageText || didPrefillInputRef.current) return; + if (messages.length > 0 || input) return; + didPrefillInputRef.current = true; + setInput(initialMessageText); + }, [initialMessageText, messages.length, input, setInput]); + 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, - ), + hasInitialMessages: Boolean(initialMessageText), status, messagesLength: messages.length, userId, authenticated, sessionId, }); - if (!mayAutoSend || !initialMessages) return; - handleSendQueryMessages(initialMessages[0]); + if (!mayAutoSend || didAutoFireRef.current) return; + didAutoFireRef.current = true; + // Fire whatever is in the input — the customer may have edited the + // prefilled prompt while waiting (send their version) or cleared it + // (they opted out; send nothing), exactly as if they pressed Send. + const text = input.trim() ? input : ""; + if (!text) return; + handleSendQueryMessages({ + id: generateUUID(), + role: "user", + parts: [{ type: "text", text }], + }); + setInput(""); }, [ - initialMessages, + initialMessageText, status, userId, handleSendQueryMessages, messages.length, authenticated, sessionId, + input, + setInput, ]); return { 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; +} From 886e82d43d6fe8c9f07a70519eb2c52127c5db0b Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Sat, 4 Jul 2026 12:11:21 -0500 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20extract=20useInitialMessageAuto?= =?UTF-8?q?Send=20=E2=80=94=20deep-link=20behavior=20as=20an=20extension?= =?UTF-8?q?=20hook=20(OCP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #1848/#1849: useVercelChat grew in both PRs, being modified for every deep-link change instead of extended. Move the whole ?q= behavior (prefill + provisioning-gated auto-fire + one-shot guards) into hooks/useInitialMessageAutoSend, composed like the existing extension hooks (useChatTransport, usePersistSelectedModel, useMessageLoader). useVercelChat now carries one hook call and is smaller than it was before #1848 (422 lines vs 424); future deep-link changes touch only the dedicated hook. No behavior change: the hook is a verbatim move of the two effects over the tested pure helpers (shouldAutoSendInitialMessage, getInitialMessageText). Full suite 83 passed; tsc no new errors vs main; prettier clean. Co-Authored-By: Claude Fable 5 --- hooks/useInitialMessageAutoSend.ts | 84 ++++++++++++++++++++++++++++++ hooks/useVercelChat.ts | 56 +++----------------- 2 files changed, 92 insertions(+), 48 deletions(-) create mode 100644 hooks/useInitialMessageAutoSend.ts diff --git a/hooks/useInitialMessageAutoSend.ts b/hooks/useInitialMessageAutoSend.ts new file mode 100644 index 000000000..a7370fa12 --- /dev/null +++ b/hooks/useInitialMessageAutoSend.ts @@ -0,0 +1,84 @@ +import { useEffect, useRef } from "react"; +import type { UIMessage } from "ai"; +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; + userId?: string; + authenticated: boolean; + /** 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): + * + * 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, + userId, + authenticated, + sessionId, + input, + setInput, + send, +}: UseInitialMessageAutoSendParams): void { + 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 fd7620cda..2826091b6 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -24,8 +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 { getInitialMessageText } from "@/lib/chat/getInitialMessageText"; +import { useInitialMessageAutoSend } from "./useInitialMessageAutoSend"; import { usePersistSelectedModel } from "./usePersistSelectedModel"; interface UseVercelChatProps { @@ -385,58 +384,19 @@ export function useVercelChat({ [silentlyUpdateUrl, sendMessage, chatRequestBody, getHeaders], ); - const initialMessageText = getInitialMessageText(initialMessages); - const didPrefillInputRef = useRef(false); - const didAutoFireRef = useRef(false); - - useEffect(() => { - // Prefill the input with the ?q= prompt so the click gives instant - // feedback while the workspace provisions — no dead space wondering - // whether it worked. Runs once, and never over an existing - // conversation or anything already typed. - if (!initialMessageText || didPrefillInputRef.current) return; - if (messages.length > 0 || input) return; - didPrefillInputRef.current = true; - setInput(initialMessageText); - }, [initialMessageText, messages.length, input, setInput]); - - 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(initialMessageText), - status, - messagesLength: messages.length, - userId, - authenticated, - sessionId, - }); - if (!mayAutoSend || didAutoFireRef.current) return; - didAutoFireRef.current = true; - // Fire whatever is in the input — the customer may have edited the - // prefilled prompt while waiting (send their version) or cleared it - // (they opted out; send nothing), exactly as if they pressed Send. - const text = input.trim() ? input : ""; - if (!text) return; - handleSendQueryMessages({ - id: generateUUID(), - role: "user", - parts: [{ type: "text", text }], - }); - setInput(""); - }, [ - initialMessageText, + // 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, + messagesLength: messages.length, userId, - handleSendQueryMessages, - messages.length, authenticated, sessionId, input, setInput, - ]); + send: handleSendQueryMessages, + }); return { // States From 1a4f98af0255f54de23a957a231ef25f2ba8677b Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Sat, 4 Jul 2026 12:16:23 -0500 Subject: [PATCH 3/3] refactor: source auth state inside useInitialMessageAutoSend (KISS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit authenticated/userId come from usePrivy/useUserProvider directly instead of riding through params — the hook's params now carry only what is chat-instance-scoped (initialMessages, status, messagesLength, sessionId, input/setInput, send). Suite 83 passed; tsc clean vs main. Co-Authored-By: Claude Fable 5 --- hooks/useInitialMessageAutoSend.ts | 14 +++++++++----- hooks/useVercelChat.ts | 2 -- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/hooks/useInitialMessageAutoSend.ts b/hooks/useInitialMessageAutoSend.ts index a7370fa12..66b804de8 100644 --- a/hooks/useInitialMessageAutoSend.ts +++ b/hooks/useInitialMessageAutoSend.ts @@ -1,5 +1,7 @@ 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"; @@ -10,8 +12,6 @@ interface UseInitialMessageAutoSendParams { /** useChat transport status — only "ready" may send. */ status: string; messagesLength: number; - userId?: string; - authenticated: boolean; /** Api-minted session id; absent until the bootstrap provisions. */ sessionId?: string; input: string; @@ -23,7 +23,9 @@ interface UseInitialMessageAutoSendParams { /** * 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): + * 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. @@ -37,13 +39,15 @@ export function useInitialMessageAutoSend({ initialMessages, status, messagesLength, - userId, - authenticated, 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); diff --git a/hooks/useVercelChat.ts b/hooks/useVercelChat.ts index 2826091b6..9c615117b 100644 --- a/hooks/useVercelChat.ts +++ b/hooks/useVercelChat.ts @@ -390,8 +390,6 @@ export function useVercelChat({ initialMessages, status, messagesLength: messages.length, - userId, - authenticated, sessionId, input, setInput,