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
New
×
{/* Messages */}
{messages.length === 0 && (
How can I help you today?
{SUGGESTED_QUESTIONS.map((q) => (
sendSuggested(q)}
className="text-left text-xs px-3 py-2 rounded-xl border border-purple-200 bg-white text-purple-700 hover:bg-purple-50 hover:border-purple-400 transition-colors shadow-sm"
>
{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}
{ onClose(); handleNavigate(nav, m.ui_guidance ?? []); }}
className="shrink-0 px-2.5 py-1 rounded bg-purple-600 text-white hover:bg-purple-700 whitespace-nowrap"
>
{steps.length > 0 ? "Show me →" : "Take me there →"}
);
}
if (canShowTour && steps.length > 0) {
// Already on a project page — show tour directly
return (
{ onClose(); startTour(steps); }}
className="mt-2 text-xs px-2 py-1 rounded bg-purple-100 text-purple-700 hover:bg-purple-200"
>
Show me
);
}
// 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 (
{ onClose(); startTour(steps); }}
className="mt-2 text-xs px-2 py-1 rounded bg-purple-100 text-purple-700 hover:bg-purple-200"
>
Show me
);
}
return null;
})()}
))}
{/* Input */}
);
}