import { useAgent } from "agents/react"; // Think speaks the same chat protocol as @cloudflare/ai-chat, but its hook // variant skips the client->server transcript sync Think doesn't support. import { useAgentChat } from "@cloudflare/think/react"; import { useEffect, useRef } from "react"; import { ChatComposer } from "@/client/features/onboarding/OnboardingChatParts"; import { invalidateSamSessions } from "@/client/features/sam/samQueries"; import { ChatMessage, humanizeToolLabel, messageHasVisibleContent, } from "@/client/components/chat/ChatMessage"; const SUGGESTIONS = [ "What keywords should I focus on next?", "Who are my top SERP competitors?", "How is my Search Console traffic trending?", "Find quick-win keywords I already rank for", ]; export function SamConversation({ projectId, sessionId, }: { projectId: string; sessionId: string; }) { // The conversation lives in the SamChatAgent Durable Object, keyed by the // session id. The WebSocket is authorized in the Worker (src/server.ts) before // it reaches the DO; billing gates come back as normal assistant messages. const agent = useAgent({ agent: "sam-chat", name: sessionId }); const { messages, sendMessage, setMessages, clearHistory, status } = useAgentChat({ agent }); const isBusy = status === "submitted" || status === "streaming"; const sendText = (text: string) => void sendMessage({ text }); // Rewind the server-side conversation to before `messageId`: the DO aborts // any in-flight turn, then deletes the message and everything after it. Sync // the local view from the server afterwards rather than slicing locally — // an aborted turn may have persisted (or removed) more than we can see, and // on Think setMessages is local-only, so this is a pure view update. const rewindTo = async (messageId: string) => { const response = await fetch(`/agents/sam-chat/${sessionId}/rewind`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ messageId }), }); if (!response.ok) return false; const fresh = await fetch( `/agents/sam-chat/${sessionId}/get-messages`, ).then((res) => (res.ok ? res.json() : null)); if (Array.isArray(fresh)) setMessages(fresh); return true; }; const undoFrom = (messageId: string) => void rewindTo(messageId); const editAndResend = async (messageId: string, newText: string) => { if (await rewindTo(messageId)) void sendMessage({ text: newText }); }; // The DO names the session from its first message during the turn, so refresh // the side-panel once the turn settles (busy -> idle) to pick up the title. const wasBusyRef = useRef(false); useEffect(() => { if (isBusy) { wasBusyRef.current = true; return; } if (wasBusyRef.current) { wasBusyRef.current = false; invalidateSamSessions(projectId); } }, [isBusy, projectId]); // Pin to the bottom while the user follows along. const scrollRef = useRef(null); useEffect(() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }, [messages, status]); const lastMessage = messages[messages.length - 1]; const showTyping = isBusy && (lastMessage?.role !== "assistant" || !messageHasVisibleContent(lastMessage)); const showSuggestions = messages.length === 0 && !isBusy; return (
{import.meta.env.DEV ? ( // Dev-only escape hatch: wipes this session's persisted transcript on // the server (Think's cf_agent_chat_clear), for testing fresh-session // behavior without creating a new chat. ) : null}
{messages.length === 0 ? (

Hey, I’m SAM — your in-app SEO agent. I can research keywords, size up competitors, read your SERPs, backlinks, rank tracking and Search Console, and turn it into next steps for this project.

Ask me anything, or start with one of these:

) : null} {messages.map((message, index) => ( undoFrom(message.id) : undefined } onEdit={ message.role === "user" ? (newText) => void editAndResend(message.id, newText) : undefined } /> ))} {showTyping ? (
) : null} {status === "error" ? (

Something went wrong. Please try again.

) : null} {showSuggestions ? (
{SUGGESTIONS.map((question) => ( ))}
) : null}
); }