import { useAgent } from "agents/react"; import { useAgentChat } from "@cloudflare/ai-chat/react"; import { useCustomer } from "autumn-js/react"; import { useState } from "react"; import { ChatMessage, messageHasVisibleContent, type ResolveToolLabel, } from "@/client/components/chat/ChatMessage"; import { useStickToBottom } from "@/client/components/chat/useStickToBottom"; import { captureClientEvent } from "@/client/lib/posthog"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { buildCheckoutSuccessUrl } from "@/client/features/billing/checkout-url"; import { AUTUMN_CHECKOUT_SESSION_PARAMS, AUTUMN_PAID_PLAN_ID, } from "@/shared/billing"; import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat"; import { ChatComposer, ChatGate, SuggestedQuestions, UpgradeSidebar, WelcomeMessage, } from "./OnboardingChatParts"; // Friendly labels for each tool Sam can run, so the chat shows what it's doing // rather than going silent while it gathers site data. `running` shows while the // call is in flight; `done` stays as a persistent badge once it finishes. const TOOL_LABELS: Record = { "tool-read_website": { running: "Reading site", done: "Read site" }, "tool-get_seo_metrics": { running: "Getting SEO metrics", done: "SEO metrics", }, "tool-research_keywords": { running: "Researching keywords", done: "Keyword research", }, "tool-get_domain_overview": { running: "Analyzing domain", done: "Domain overview", }, "tool-get_serp_results": { running: "Checking search results", done: "Search results", }, "tool-find_serp_competitors": { running: "Finding competitors", done: "Competitors", }, "tool-get_competitor_keywords": { running: "Analyzing competitor", done: "Competitor keywords", }, "tool-get_backlinks_overview": { running: "Checking backlinks", done: "Backlinks overview", }, }; // Onboarding curates a label per tool and hides any tool it hasn't named, so // the pre-paywall preview only shows the handful it means to surface. const resolveToolLabel: ResolveToolLabel = (partType) => TOOL_LABELS[partType] ?? null; const SUGGESTED_QUESTIONS = [ "How will OpenSEO help me get more traffic?", "Compare OpenSEO and Claude", "What do I get after I upgrade?", "How does the Google Search Console integration work?", "Right fit for consultants and agencies?", ]; // Highlighted (primary) chips shown first, before the general questions. // STRATEGY_SUGGESTION drops out once the user has asked for their strategy. const STRATEGY_SUGGESTION = "What do you recommend for my site?"; const COMPETITOR_SUGGESTION = "Compare against my competitors"; const PRIMARY_SUGGESTIONS = [STRATEGY_SUGGESTION, COMPETITOR_SUGGESTION]; export function OnboardingChatConversation({ projectId, domain, }: { projectId: string; domain: string; }) { // The conversation lives in a Durable Object (Agents SDK), keyed by projectId, // so history persists across reloads. The WebSocket connection is authorized // in the Worker (src/server.ts) before it reaches the DO; billing gates come // back as normal assistant messages rather than HTTP errors. const agent = useAgent({ agent: "onboarding-chat", name: projectId }); // Same constraint as SAM: dense tool-input deltas fan out one store update // per chunk and trip React #185 unthrottled (cloudflare/agents#1361). const { messages, sendMessage, status } = useAgentChat({ agent, experimental_throttle: 50, }); // This chat is only ever the pre-upgrade free preview: once a user upgrades // they are routed into the GSC onboarding step and never return here, so // there's no "paid" state to model — the question cap always applies. const customerQuery = useCustomer(); const [checkoutError, setCheckoutError] = useState(null); const [isStartingCheckout, setIsStartingCheckout] = useState(false); const [usedSuggestions, setUsedSuggestions] = useState([]); // Set once the user asks for their strategy (welcome CTA or the strategy // chip) so we don't keep offering the "What do you recommend" chip. const [strategyRequested, setStrategyRequested] = useState(false); const questionsUsed = messages.filter((m) => m.role === "user").length; const remaining = Math.max(0, FREE_ONBOARDING_QUESTION_LIMIT - questionsUsed); const isLocked = remaining <= 0; // Nudge once they're within the last few questions, not from the start. const showRemainingHint = remaining > 0 && remaining <= 3; const isBusy = status === "submitted" || status === "streaming"; const { scrollRef, onScroll, pinToBottom } = useStickToBottom( messages, status, ); const sendText = (text: string) => { pinToBottom(); void sendMessage({ text }); }; async function startCheckout() { setCheckoutError(null); setIsStartingCheckout(true); try { captureClientEvent("billing:checkout_start"); // After payment, re-enter onboarding at the GSC step (not back into // this chat) so the user finishes connecting Search Console + MCP. await customerQuery.attach({ planId: AUTUMN_PAID_PLAN_ID, redirectMode: "always", successUrl: buildCheckoutSuccessUrl("/onboarding?step=3"), checkoutSessionParams: AUTUMN_CHECKOUT_SESSION_PARAMS, }); } catch (checkoutErr) { setCheckoutError( getStandardErrorMessage( checkoutErr, "We couldn't start checkout. Please refresh and try again.", ), ); setIsStartingCheckout(false); } } const lastMessage = messages[messages.length - 1]; const suggestionPool = [ ...(strategyRequested ? [] : [STRATEGY_SUGGESTION]), COMPETITOR_SUGGESTION, ...SUGGESTED_QUESTIONS, ]; const remainingSuggestions = suggestionPool.filter( (question) => !usedSuggestions.includes(question), ); // Show the typing indicator from the moment the user sends until the // assistant's reply shows something — covers the "submitted" wait (last // message is still the user's own) and the gap before any text or tool badge // renders. Once a tool badge is in flight, it carries the progress, so the // dots would just double up. const showTyping = isBusy && (lastMessage?.role !== "assistant" || !messageHasVisibleContent(lastMessage)); // Show the chips up front (before the first message) and after each assistant // reply, but not while a reply is mid-flight. const showSuggestions = remainingSuggestions.length > 0 && !isBusy && (messages.length === 0 || lastMessage?.role === "assistant"); return (
void startCheckout()} />
void startCheckout()} /> {messages.map((message, index) => ( ))} {showTyping ? (
) : null} {status === "error" ? (

{/* Billing gates (free-question cap / out-of-credits) come back as normal assistant messages now, so this only covers genuine failures. */} Something went wrong. Please refresh and try again.

) : null} {showSuggestions ? ( { setUsedSuggestions((current) => current.includes(question) ? current : [...current, question], ); if (question === STRATEGY_SUGGESTION) { setStrategyRequested(true); } sendText(question); }} /> ) : null}
{isLocked ? ( void startCheckout()} /> ) : (
{showRemainingHint ? (

{remaining} free question{remaining === 1 ? "" : "s"} left.{" "}

) : null}
)}
); }