This guide covers the useChatbot React hook for managing chat functionality in the OpenRails application. This hook encapsulates complex chat management logic to provide a clean, reusable interface for various chat interfaces throughout the application.
The useChatbot hook is the core hook for managing chat interactions with AI bots. It handles message streaming, chat history, feedback submission, and all real-time chat state management.
RemixUI/app/plugins/openrails/hooks/useChatbot.ts
Use this hook whenever you need to:
useChatbot(
chatbotId: string | undefined,
sessionId: string | undefined = undefined,
initialData?: {
chatbot: ChatBotDTO,
template: ChatTemplateModel
},
useCompanyStream: boolean = false,
jwtToken?: string
)
chatbotId (required)
sessionId (optional)
initialData (optional)
{ chatbot: ChatBotDTO, template: ChatTemplateModel }useCompanyStream (optional, default: false)
true, uses /api/chat/company/stream endpointfalse, uses /api/bot/chat/stream endpointjwtToken (optional)
The hook returns an object with the following properties and methods:
<think> tags)text: The message contentonChunk: Callback for each chunk of streamed responseisIntro: Boolean flag for intro messagesattachedImageIdsindex: Message index in messages arraydata: Feedback data (thumbs, ratings, text, etc.)<think> tags)const { chatbotId, chatbot, chatbotTemplate } = useLoaderData<typeof loader>();
const {
messages,
sendMessage,
isStreaming,
loading,
scrollToBottom,
messagesEndRef,
} = useChatbot(chatbotId, undefined, { chatbot, template: chatbotTemplate });
const handleSend = async () => {
await sendMessage(input, (_chunk, meta) => {
// Optional: handle metadata like thoughts
if (meta?.thought) setCurrentThought(meta.thought);
});
};
const { token } = useJwt(); // Get JWT from context
const {
messages,
sendMessage,
chatbot,
chatbotTemplate,
loading,
} = useChatbot(chatbotId, sessionId, undefined, false, token);
const {
messages,
chatbotHistory,
sendMessage,
selectHistoryChat,
loadHistory,
createNewChat,
pinChat,
renameChat,
deleteChat,
} = useChatbot(selectedBotId, undefined, undefined, true); // useCompanyStream = true
// Load history when component mounts
useEffect(() => {
loadHistory();
}, [loadHistory]);
Chat history is not loaded by default for performance reasons. You must explicitly call loadHistory() to enable it:
useEffect(() => {
loadHistory();
}, [loadHistory]);
For lazy-loaded UI (like modals), call it when opening:
const handleOpenHistory = () => {
loadHistory();
setShowHistoryModal(true);
};
When chatbotId changes, the hook automatically resets all state including:
This prevents state contamination when switching between bots.
The hook handles both direct template objects and PipelineResult wrapped responses:
const tpl = (tplResp as any)?.Result ?? tplResp; // Unwrap if needed
This makes it resilient to API response format changes.
The hook processes Server-Sent Events (SSE) with special handling for:
Thought Tags: Content between <think> and </think> is separated into currentThought instead of message content.
Stream Updates: Processing stages are captured in streamUpdates for showing progress to users.
Message Metadata: Extracts ChatId, MessageId, file info, and timing data from stream.
Feedback is only allowed for assistant messages that:
const canSubmitFeedback = isFeedbackAllowed(messageIndex);
Images must be uploaded first via handleImagesUploaded(fileIds), then they're automatically included with the next message sent. They're cleared after sending.
The hook maintains an AbortController to cancel in-flight requests. When cancelStream() is called:
Required Components:
Minimal Implementation:
const ChatInterface = () => {
const { chatbotId } = useParams();
const [input, setInput] = useState("");
const {
messages,
sendMessage,
isStreaming,
messagesEndRef,
scrollToBottom,
loading,
} = useChatbot(chatbotId);
const handleSend = async () => {
if (!input.trim() || isStreaming) return;
const text = input;
setInput("");
await sendMessage(text, () => {});
scrollToBottom();
};
if (loading) return <div>Loading...</div>;
return (
<div>
<div className="messages">
{messages.map((msg, i) => (
<div key={i}>{msg.content}</div>
))}
<div ref={messagesEndRef} />
</div>
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === "Enter" && handleSend()}
/>
<button onClick={handleSend} disabled={isStreaming}>
Send
</button>
</div>
);
};
The application uses a sophisticated scroll system that:
Pattern:
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
const scrollViewportRef = useRef<HTMLDivElement>(null);
const checkIfAtBottom = useCallback(() => {
const viewport = scrollViewportRef.current;
if (!viewport) return true;
const threshold = 150; // pixels from bottom
const isAtBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= threshold;
return isAtBottom;
}, []);
// Track scroll position
useEffect(() => {
const viewport = scrollViewportRef.current;
if (!viewport) return;
const handleScroll = () => {
const atBottom = checkIfAtBottom();
setIsUserAtBottom(atBottom);
};
viewport.addEventListener('scroll', handleScroll, { passive: true });
return () => viewport.removeEventListener('scroll', handleScroll);
}, [checkIfAtBottom]);
// Auto-scroll when new messages arrive
useEffect(() => {
requestAnimationFrame(() => {
const currentlyAtBottom = checkIfAtBottom();
if (currentlyAtBottom) {
scrollToBottom();
}
});
}, [messages, scrollToBottom, checkIfAtBottom]);
// Force scroll on send if user was at bottom
const handleSend = async () => {
const wasAtBottom = checkIfAtBottom();
await sendMessage(input, () => {});
if (wasAtBottom) {
setIsUserAtBottom(true);
setTimeout(() => scrollToBottom(), 0);
setTimeout(() => scrollToBottom(), 100);
}
};
Why requestAnimationFrame?: Ensures DOM has updated before checking scroll position.
Why two setTimeout calls?: First call (0ms) runs after current call stack, second (100ms) catches delayed DOM updates.
Why 150px threshold?: More forgiving than strict bottom detection, accounts for streaming content changes.
Pattern from Test Route:
const {
chatbotHistory,
selectHistoryChat,
loadHistory,
pinChat,
renameChat,
deleteChat,
} = useChatbot(chatbotId);
// Load history on mount
useEffect(() => {
loadHistory();
}, [loadHistory]);
// Render history list
{chatbotHistory.map((chat) => (
<div key={chat.Id}>
<button onClick={() => selectHistoryChat(chat.Id)}>
{chat.Title}
</button>
<button onClick={() => pinChat.mutate({ chatId: chat.Id, isPinned: !chat.IsPinned })}>
{chat.IsPinned ? "Unpin" : "Pin"}
</button>
</div>
))}
Lazy Loading Pattern (Widget):
const handleHistoryToggle = () => {
const newShowHistory = !showHistory;
setShowHistory(newShowHistory);
// Only load when opening
if (newShowHistory && loadHistory) {
loadHistory();
}
};
const {
messages,
isFeedbackAllowed,
submitFeedback,
chatbotTemplate,
} = useChatbot(chatbotId);
const handleFeedback = async (index: number, data: any) => {
const message = messages[index];
const messageId = message?.id;
if (!messageId) {
toast({ title: "Feedback failed", description: "Message ID not available." });
return;
}
await submitFeedback(index, {
...data,
messageId: messageId,
selectedText: data.selectedText ?? undefined,
});
};
// In render
{messages.map((msg, i) => (
<div key={i}>
{msg.content}
{isFeedbackAllowed(i) && (
<FeedbackBar
feedbackType={chatbotTemplate?.ChatFeedbackStyleId}
onFeedback={(data) => handleFeedback(i, data)}
/>
)}
</div>
))}
const { loading, historyLoading, isStreaming } = useChatbot(chatbotId);
if (loading) return <div>Loading chat...</div>;
<button disabled={isStreaming}>
{isStreaming ? "Sending..." : "Send"}
</button>
const {
currentThought,
showCurrentThought,
setShowCurrentThought,
isThinking,
hasMessageStarted,
} = useChatbot(chatbotId);
// Show thought window
{!hasMessageStarted && isThinking && (
<div onClick={() => setShowCurrentThought(true)}>
<div className="bounce-animation" />
<div className="bounce-animation" />
<div className="bounce-animation" />
</div>
)}
{showCurrentThought && (
<div className="thought-window" onClick={() => setShowCurrentThought(false)}>
{currentThought || "(No thoughts yet...)"}
</div>
)}
Auto-close pattern: Thought window automatically closes when actual message content starts.
dashboard.inference.chatbot.$chatbotId.test.route.tsx)
dashboard.inference.company-chat.chat.route.tsx)
useCompanyStream = truecomponents/OpenRailsChatLandingPage.tsx)
widget/widget.chat.route.tsx)
The hook exposes loading states. Show appropriate UI:
if (loading) return <LoadingSpinner />;
Don't assume history is loaded. Call it when needed:
useEffect(() => {
loadHistory();
}, [loadHistory]);
When implementing auto-scroll, respect user intent:
const wasAtBottom = checkIfAtBottom();
// ... send message ...
if (wasAtBottom) {
scrollToBottom();
}
The hook returns complex objects. Destructure only what you need:
const {
messages,
sendMessage,
// Only include what you actually use
} = useChatbot(chatbotId);
The hook handles this automatically, but be aware that:
Check: Is loading true? Wait for it to become false.
Check: Is the welcome message showing? Template might not be loaded.
Check: Console for streaming errors. Network issues can cause silent failures.
Check: Did you call loadHistory()? It's not automatic.
Check: Is historyLoading stuck on true? API might be failing.
Check: Is isFeedbackAllowed(index) returning true?
EnableFeedback = trueCheck: Is messagesEndRef attached to a DOM element?
Check: Is the scroll container ref properly set?
Check: Try calling scrollToBottom() directly to test.
You can provide a custom onChunk callback to process streaming data:
await sendMessage(input, (chunk, meta) => {
if (meta?.thought) {
// Custom thought handling
console.log("Bot is thinking:", meta.thought);
}
// Process chunk
processChunk(chunk);
});
When implementing bot switching, the hook handles state reset automatically:
const [selectedBotId, setSelectedBotId] = useState("1");
const { messages } = useChatbot(selectedBotId);
// Changing selectedBotId clears messages, history, etc.
<select onChange={(e) => setSelectedBotId(e.target.value)}>
{bots.map(bot => <option value={bot.Id}>{bot.Name}</option>)}
</select>
const { handleImagesUploaded, attachedImageIds } = useChatbot(chatbotId);
// After uploading images
const uploadImages = async (files: File[]) => {
const fileIds = await uploadToServer(files);
handleImagesUploaded(fileIds);
// Images will be sent with next message
};
// Clear if user cancels
clearAttachedImages();
const {
replyText,
setReplyText,
setReplyMessageParent,
} = useChatbot(chatbotId);
const handleReply = (messageId: string, text: string) => {
setReplyMessageParent(messageId);
setReplyText(text);
};
// Next message sent will include reply context
// Clear after sending is automatic
GET /bot/chatbot/get/{id} - Fetch chatbot configGET /bot/template/get/{id} - Fetch template configGET /bot/chat/history - Fetch chat historyPOST /api/bot/chat/stream - Send message and stream responsePOST /api/chat/company/stream - Company chat stream (when useCompanyStream = true)GET /bot/chat/messages - Load specific chat sessionPOST /bot/chatbot/feedback - Submit feedbackPOST /bot/chat/pin - Pin/unpin chatPOST /chat/message/rename/title - Rename chatPOST /chat/delete - Delete chatloadHistory() calledrequestAnimationFrameThe hook internally uses:
useRef for abort controllers and animation framesStream updates use requestAnimationFrame to throttle thought updates:
const rafRef = useRef<number | null>(null);
function setThoughtThrottled(text: string) {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
setCurrentThought(text);
rafRef.current = null;
});
}
When testing components using this hook:
loading = truejest.mock("@openrails/hooks/useChatbot", () => ({
useChatbot: () => ({
messages: mockMessages,
sendMessage: jest.fn(),
loading: false,
isStreaming: false,
// ... other values
}),
}));
The useChatbot hook provides a comprehensive, production-ready solution for chat management. Key takeaways:
loadHistory() when neededFor additional help, refer to the existing implementations in the routes listed above.