import { useEffect, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { getConversation, getStoredConversationId, HttpError, sendChatStream, setStoredConversationId, startNewConversation, type NavigationHint, type SupportMessage, type UIGuidance, } from "../../api/support"; import { useAuth } from "../../hooks/useAuth"; import SupportAuthModal from "../SupportAuthModal"; import { useSupportTour } from "./SupportTourContext"; type LocalMessage = { role: "user" | "assistant"; content: string; citations?: string[]; ui_guidance?: UIGuidance[]; navigation?: NavigationHint | null; pending?: boolean; streaming?: boolean; }; function fromServer(msg: SupportMessage): LocalMessage { return { role: msg.role, content: msg.content, citations: msg.cited_docs ?? undefined, ui_guidance: msg.ui_guidance ?? undefined, }; } const SUGGESTED_QUESTIONS = [ "How do I turn an article into a video?", "What templates are available?", "How do I render and download my video?", ]; function TypingIndicator() { return (
{[0, 1, 2].map((i) => ( ))}
); } function MarkdownMessage({ content }: { content: string }) { return (

{children}

, h2: ({ children }) =>

{children}

, h3: ({ children }) =>

{children}

, p: ({ children }) =>

{children}

, strong: ({ children }) => {children}, ul: ({ children }) => , ol: ({ children }) =>
    {children}
, li: ({ children }) =>
  • {children}
  • , code: ({ children }) => {children}, pre: ({ children }) =>
    {children}
    , }} > {content}
    ); } export function SupportChat({ onClose }: { onClose: () => void }) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [sending, setSending] = useState(false); const [showSignIn, setShowSignIn] = useState(false); const [pendingMessage, setPendingMessage] = useState(null); const { user, logout } = useAuth(); const location = useLocation(); const navigate = useNavigate(); const { startTour } = useSupportTour(); const scrollRef = useRef(null); const conversationIdRef = useRef(getStoredConversationId()); useEffect(() => { let cancelled = false; const cid = conversationIdRef.current; if (cid == null) return; (async () => { const conv = await getConversation(cid); if (cancelled) return; if (!conv) { setStoredConversationId(null); conversationIdRef.current = null; return; } setMessages(conv.messages.map(fromServer)); })(); return () => { cancelled = true; }; }, []); useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); }, [messages]); // If the user becomes signed-in through any path (not just this modal's own // onSuccess — e.g. they already had a session, or signed in elsewhere while // this panel was mounted), never leave a stale sign-in prompt showing. useEffect(() => { if (user && showSignIn) { setShowSignIn(false); setPendingMessage(null); } }, [user, showSignIn]); const submitMessage = async (text: string) => { if (!text || sending) return; if (!user) { setPendingMessage(text); setShowSignIn(true); return; } setSending(true); // Index of the assistant placeholder we're about to append. Tracked explicitly // (not "last message") because input re-enables on answer_done, so the user may // append new messages before this request's `done` event arrives. const assistantIndex = messages.length + 1; setMessages((m) => [ ...m, { role: "user", content: text }, { role: "assistant", content: "", pending: true, streaming: true }, ]); const updateAssistant = (patch: LocalMessage) => { setMessages((m) => { if (assistantIndex >= m.length) return m; const copy = m.slice(); copy[assistantIndex] = patch; return copy; }); }; let accumulated = ""; await sendChatStream(text, location.pathname, conversationIdRef.current, { onToken: (token) => { accumulated += token; updateAssistant({ role: "assistant", content: accumulated, pending: false, streaming: true }); }, onAnswerDone: (data) => { // Visible answer is complete — stop the cursor and unfreeze the input now. // Citations and "Show me" buttons attach when `done` arrives a moment later. conversationIdRef.current = data.conversation_id; updateAssistant({ role: "assistant", content: accumulated.trim() || "Sorry — I couldn't form an answer.", streaming: false }); setSending(false); }, onDone: (data) => { conversationIdRef.current = data.conversation_id; const answer = accumulated.trim() || "Sorry — I couldn't form an answer."; updateAssistant({ role: "assistant", content: answer, citations: data.citations, ui_guidance: data.ui_guidance, navigation: data.navigation, streaming: false }); if (data.ui_guidance.length > 0 && !data.navigation) { const steps = data.ui_guidance.flatMap((g) => g.steps); if (steps.length > 0) startTour(steps); } }, onError: (err) => { console.error("support chat stream error", err); if (err instanceof HttpError && err.status === 401) { void logout(); updateAssistant({ role: "assistant", content: "Your session expired.", streaming: false }); setPendingMessage(text); setShowSignIn(true); return; } updateAssistant({ role: "assistant", content: "Sorry — something went wrong. Please try again in a moment.", streaming: false }); }, }); setSending(false); }; const handleSend = () => { const text = input.trim(); if (!text) return; setInput(""); void submitMessage(text); }; const handleNavigate = (nav: NavigationHint, guidance: UIGuidance[]) => { const steps = guidance.flatMap((g) => g.steps); if (nav.requires_project_id) { const projectMatch = location.pathname.match(/\/projects?\/([^/]+)/); const projectId = projectMatch?.[1]; if (projectId) { const resolved = nav.target_route.replace(":id", projectId); navigate(resolved); if (steps.length > 0) window.setTimeout(() => startTour(steps), 1500); } else { // No project in URL — send to dashboard so user can pick a project navigate("/dashboard"); } } else { navigate(nav.target_route); if (steps.length > 0) window.setTimeout(() => startTour(steps), 1500); } }; const handleNewConversation = async () => { await startNewConversation(); conversationIdRef.current = null; setMessages([]); }; const sendSuggested = (q: string) => { void submitMessage(q); }; return ( // Mobile: full screen. Desktop: fixed bottom-right panel.
    {showSignIn && ( { setShowSignIn(false); setPendingMessage(null); }} onSuccess={() => { setShowSignIn(false); const text = pendingMessage; setPendingMessage(null); if (text) void submitMessage(text); }} /> )} {/* Header */}

    Blog2Video Support

    Ask anything about turning content into video

    {/* Messages */}
    {messages.length === 0 && (

    How can I help you today?

    {SUGGESTED_QUESTIONS.map((q) => ( ))}
    )} {messages.map((m, i) => (
    {m.pending ? ( ) : m.role === "user" ? (

    {m.content}

    ) : m.streaming ? (
    ) : ( )} {(() => { if (m.pending || m.role !== "assistant") return null; const steps = m.ui_guidance?.flatMap((g) => g.steps) ?? []; const nav = m.navigation; if (nav) { // If tour requires a project ID, we can only show it when already on a project page const onProjectPage = /\/projects?\/[^/]+/.test(location.pathname); const canNavigate = !nav.requires_project_id; const canShowTour = nav.requires_project_id ? onProjectPage : true; if (canNavigate) { // Non-project page action (e.g. /pricing) — can navigate directly return (

    {nav.description}

    ); } if (canShowTour && steps.length > 0) { // Already on a project page — show tour directly return ( ); } // requires_project_id but not on a project — just show the hint text, no button return (

    {nav.description} Open a project to use this feature.

    ); } if (steps.length > 0) { return ( ); } return null; })()}
    ))}
    {/* Input */}
    { e.preventDefault(); handleSend(); }} className="border-t border-gray-200 px-3 py-2 flex gap-2 shrink-0" > setInput(e.target.value)} placeholder="Ask a question…" className="flex-1 text-sm px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500" disabled={sending} />
    ); }