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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions hooks/useInitialMessageAutoSend.ts
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P0: The auto-send can silently fail when the session is already provisioned before the prefill's setInput commits. Move didAutoFireRef.current = true below the if (!text) return guard so the effect can fire on the next render when the prefilled text is available.

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

<comment>The auto-send can silently fail when the session is already provisioned before the prefill's `setInput` commits. Move `didAutoFireRef.current = true` below the `if (!text) return` guard so the effect can fire on the next render when the prefilled text is available.</comment>

<file context>
@@ -0,0 +1,84 @@
+      sessionId,
+    });
+    if (!mayAutoSend || didAutoFireRef.current) return;
+    didAutoFireRef.current = true;
+    const text = input.trim() ? input : "";
+    if (!text) return;
</file context>

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,
]);
}
33 changes: 9 additions & 24 deletions hooks/useVercelChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions lib/chat/__tests__/getInitialMessageText.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
17 changes: 17 additions & 0 deletions lib/chat/getInitialMessageText.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading