Next.js
Easily integrate your agent into your Next.js frontend
Initializing a project
If you have not set up a project yet, you can easily initialize a ShadCN / Next.js project with the following command:
npx shadcn@latest init
Or if you prefer not to use ShadCN:
npx create-next-app@latest
Overview
The logic of the websocket chat is handled by a React provider. You can install it with this command:
npm i @pompeii-labs/react-obsidian
We support multiple project setups out of the box.
Using ShadCN
After you have installed the provider, copy this component into your ShadCN project:
'use client';
import { useCallback, useEffect, useRef, useState } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { ArrowDown, ArrowUp } from "lucide-react";
import { useChat } from "@pompeii-labs/react-obsidian";
import { MagmaAssistantMessage, MagmaUserMessage } from "@pompeii-labs/magma/types";
import { cn } from "@/lib/utils";
const SCROLL_THRESHOLD = 100;
const MIN_TEXTAREA_HEIGHT = 96;
const MAX_TEXTAREA_HEIGHT = 192;
export default function Chat() {
const { sendMessage, messages, awaitingResponse } = useChat();
const scrollAreaRef = useRef<HTMLDivElement>(null);
const textareaContainerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [message, setMessage] = useState('');
const [isAtBottom, setIsAtBottom] = useState(true);
// Send the message to the websocket and clear the message state
const handleSubmit = useCallback(() => {
if (message.trim().length === 0 || awaitingResponse) return;
sendMessage(message);
setMessage('');
textareaRef.current?.focus();
}, [message, sendMessage, awaitingResponse]);
// Auto resize the textarea
useEffect(() => {
const textareaContainer = textareaContainerRef.current;
if (!textareaContainer) return;
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight;
const newHeight = Math.min(Math.max(scrollHeight, MIN_TEXTAREA_HEIGHT), MAX_TEXTAREA_HEIGHT);
textareaContainer.style.height = `${newHeight}px`;
textarea.style.height = '100%';
}, [message]);
// Handle key press
function handleKeyPress(event: React.KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
}
}
// Scroll to the bottom of the chat
function scrollToBottom() {
const scrollArea = scrollAreaRef.current;
if (!scrollArea) return;
scrollArea.scrollTo({
top: scrollArea.scrollHeight,
behavior: 'smooth'
});
}
// Scroll to the bottom of the chat when a new message is added
useEffect(() => {
const lastMessage = messages[messages.length - 1];
if (lastMessage?.type === 'message' || lastMessage?.type === 'loading') {
scrollToBottom();
}
}, [messages]);
// Add scroll and touch event listeners
useEffect(() => {
const scrollArea = scrollAreaRef.current;
if (!scrollArea) return;
const handleScrollAndTouch = () => {
const distanceFromBottom = scrollArea.scrollHeight - (scrollArea.scrollTop + scrollArea.clientHeight);
setIsAtBottom(distanceFromBottom <= SCROLL_THRESHOLD);
};
scrollArea.addEventListener('scroll', handleScrollAndTouch);
scrollArea.addEventListener('touchmove', handleScrollAndTouch);
return () => {
scrollArea.removeEventListener('scroll', handleScrollAndTouch);
scrollArea.removeEventListener('touchmove', handleScrollAndTouch);
};
}, []);
return (
<div className='h-full overflow-hidden w-full flex flex-col'>
<div className="flex-1 flex flex-col overflow-hidden relative container px-0 mx-auto max-w-5xl">
<div
ref={scrollAreaRef}
className="flex-1 flex flex-col overflow-y-auto"
>
<div className="flex flex-col mt-auto gap-4 py-4 px-4">
{messages.map((message, index) => {
switch (message.type) {
case 'message':
const data = message.data as MagmaUserMessage | MagmaAssistantMessage;
return <div key={index} className={`${data.role === 'user' ? 'bg-foreground/10 text-foreground ml-auto' : 'text-foreground mr-auto'} p-2 rounded-md max-w-[85%]`}>
{data.content}
</div>
}
}
)}
{awaitingResponse && <div className="size-3 my-3.5 bg-foreground rounded-full animate-pulse" />}
</div>
<div className={cn("absolute bottom-3 right-7 transition-all duration-150 ease-out",
isAtBottom
? "pointer-events-none translate-y-4 scale-95 opacity-0"
: "translate-y-0 scale-100 opacity-100",
)}>
<Button variant={'outline'} size={'icon'} onClick={scrollToBottom}>
<ArrowDown />
</Button>
</div>
</div>
</div>
<div ref={textareaContainerRef} className="relative max-w-2xl container px-0 mx-auto">
<Textarea
ref={textareaRef}
placeholder="Type a message..."
value={message}
onChange={(e) => setMessage(e.target.value)}
className="w-full h-24 pr-16 resize-none border"
onKeyDown={handleKeyPress}
/>
<div className={"absolute bottom-2 right-6"}>
<Button size={'icon'} onClick={handleSubmit} disabled={awaitingResponse || message.trim().length === 0}>
<ArrowUp />
</Button>
</div>
</div>
</div>
)
}
You can then use the component inside the MagmaChatProvider
from the react-obsidian
package you installed earlier:
import Chat from "@/components/chat";
import MagmaChatProvider from '@pompeii-labs/react-obsidian';
export default function Home() {
return (
<div className="h-screen w-screen">
<MagmaChatProvider agentId="YOUR_AGENT_ID" apiKey="YOUR_API_KEY">
<Chat />
</MagmaChatProvider>
</div>
);
}
Using TailwindCSS
This example assumes you have background and foreground tailwind color variables defined
After you have installed the provider, copy this component into your TailwindCSS project:
'use client';
import { useCallback, useEffect, useRef, useState } from "react";
import { useChat } from "@pompeii-labs/react-obsidian";
import { MagmaAssistantMessage, MagmaUserMessage } from "@pompeii-labs/magma/types";
const SCROLL_THRESHOLD = 100;
const MIN_TEXTAREA_HEIGHT = 96;
const MAX_TEXTAREA_HEIGHT = 192;
export default function Chat() {
const { sendMessage, messages, awaitingResponse } = useChat();
const scrollAreaRef = useRef<HTMLDivElement>(null);
const textareaContainerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [message, setMessage] = useState('');
const [isAtBottom, setIsAtBottom] = useState(true);
// Send the message to the websocket and clear the message state
const handleSubmit = useCallback(() => {
if (message.trim().length === 0 || awaitingResponse) return;
sendMessage(message);
setMessage('');
textareaRef.current?.focus();
}, [message, sendMessage, awaitingResponse]);
// Auto resize the textarea
useEffect(() => {
const textareaContainer = textareaContainerRef.current;
if (!textareaContainer) return;
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight;
const newHeight = Math.min(Math.max(scrollHeight, MIN_TEXTAREA_HEIGHT), MAX_TEXTAREA_HEIGHT);
textareaContainer.style.height = `${newHeight}px`;
textarea.style.height = '100%';
}, [message]);
// Handle key press
function handleKeyPress(event: React.KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
}
}
// Scroll to the bottom of the chat
function scrollToBottom() {
const scrollArea = scrollAreaRef.current;
if (!scrollArea) return;
scrollArea.scrollTo({
top: scrollArea.scrollHeight,
behavior: 'smooth'
});
}
// Scroll to the bottom of the chat when a new message is added
useEffect(() => {
const lastMessage = messages[messages.length - 1];
if (lastMessage?.type === 'message' || lastMessage?.type === 'loading') {
scrollToBottom();
}
}, [messages]);
// Add scroll and touch event listeners
useEffect(() => {
const scrollArea = scrollAreaRef.current;
if (!scrollArea) return;
const handleScrollAndTouch = () => {
const distanceFromBottom = scrollArea.scrollHeight - (scrollArea.scrollTop + scrollArea.clientHeight);
setIsAtBottom(distanceFromBottom <= SCROLL_THRESHOLD);
};
scrollArea.addEventListener('scroll', handleScrollAndTouch);
scrollArea.addEventListener('touchmove', handleScrollAndTouch);
return () => {
scrollArea.removeEventListener('scroll', handleScrollAndTouch);
scrollArea.removeEventListener('touchmove', handleScrollAndTouch);
};
}, []);
return (
<div className='h-full overflow-hidden w-full flex flex-col'>
<div className="flex-1 flex flex-col overflow-hidden relative container px-0 mx-auto max-w-5xl">
<div
ref={scrollAreaRef}
className="flex-1 flex flex-col overflow-y-auto"
>
<div className="flex flex-col mt-auto gap-4 py-4 px-4">
{messages.map((message, index) => {
switch (message.type) {
case 'message':
const data = message.data as MagmaUserMessage | MagmaAssistantMessage;
return <div key={index} className={`${data.role === 'user' ? 'bg-foreground/10 text-foreground ml-auto' : 'text-foreground mr-auto'} p-2 rounded-md max-w-[85%]`}>
{data.content}
</div>
}
}
)}
{awaitingResponse && <div className="size-3 my-3.5 bg-foreground rounded-full animate-pulse" />}
</div>
<div className={`absolute bottom-3 right-7 transition-all duration-150 ease-out ${isAtBottom ? "pointer-events-none translate-y-4 scale-95 opacity-0" : "translate-y-0 scale-100 opacity-100"}`}>
<button className="border rounded-md size-9 inline-flex items-center justify-center bg-background" onClick={scrollToBottom}>
v
</button>
</div>
</div>
</div>
<div ref={textareaContainerRef} className="relative max-w-2xl container px-0 mx-auto">
<textarea
ref={textareaRef}
placeholder="Type a message..."
value={message}
onChange={(e) => setMessage(e.target.value)}
className="w-full px-4 py-2 rounded-md h-24 pr-16 resize-none border"
onKeyDown={handleKeyPress}
/>
<div className={"absolute bottom-2 right-6"}>
<button className="border bg-foreground text-background inline-flex items-center justify-center rounded-md h-9 px-4 py-2 disabled:opacity-50" onClick={handleSubmit} disabled={awaitingResponse || message.trim().length === 0}>
Send
</button>
</div>
</div>
</div>
)
}
You can then use the component inside the MagmaChatProvider
from the react-obsidian
package you installed earlier:
import Chat from "@/components/chat";
import MagmaChatProvider from '@pompeii-labs/react-obsidian';
export default function Home() {
return (
<div className="h-screen w-screen">
<MagmaChatProvider agentId="YOUR_AGENT_ID" apiKey="YOUR_API_KEY">
<Chat />
</MagmaChatProvider>
</div>
);
}
Using CSS Modules
This example assumes you have background and foreground css color variables defined
After you have installed the provider, copy this component into your CSS Modules project:
'use client';
import { useCallback, useEffect, useRef, useState } from "react";
import { useChat } from "@pompeii-labs/react-obsidian";
import { MagmaAssistantMessage, MagmaUserMessage } from "@pompeii-labs/magma/types";
import styles from "./chat.module.css";
const SCROLL_THRESHOLD = 100;
const MIN_TEXTAREA_HEIGHT = 96;
const MAX_TEXTAREA_HEIGHT = 192;
export default function Chat() {
const { sendMessage, messages, awaitingResponse } = useChat();
const scrollAreaRef = useRef<HTMLDivElement>(null);
const textareaContainerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [message, setMessage] = useState('');
const [isAtBottom, setIsAtBottom] = useState(true);
// Send the message to the websocket and clear the message state
const handleSubmit = useCallback(() => {
if (message.trim().length === 0 || awaitingResponse) return;
sendMessage(message);
setMessage('');
textareaRef.current?.focus();
}, [message, sendMessage, awaitingResponse]);
// Auto resize the textarea
useEffect(() => {
const textareaContainer = textareaContainerRef.current;
if (!textareaContainer) return;
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight;
const newHeight = Math.min(Math.max(scrollHeight, MIN_TEXTAREA_HEIGHT), MAX_TEXTAREA_HEIGHT);
textareaContainer.style.height = `${newHeight}px`;
textarea.style.height = '100%';
}, [message]);
// Handle key press
function handleKeyPress(event: React.KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
}
}
// Scroll to the bottom of the chat
function scrollToBottom() {
const scrollArea = scrollAreaRef.current;
if (!scrollArea) return;
scrollArea.scrollTo({
top: scrollArea.scrollHeight,
behavior: 'smooth'
});
}
// Scroll to the bottom of the chat when a new message is added
useEffect(() => {
const lastMessage = messages[messages.length - 1];
if (lastMessage?.type === 'message' || lastMessage?.type === 'loading') {
scrollToBottom();
}
}, [messages]);
// Add scroll and touch event listeners
useEffect(() => {
const scrollArea = scrollAreaRef.current;
if (!scrollArea) return;
const handleScrollAndTouch = () => {
const distanceFromBottom = scrollArea.scrollHeight - (scrollArea.scrollTop + scrollArea.clientHeight);
setIsAtBottom(distanceFromBottom <= SCROLL_THRESHOLD);
};
scrollArea.addEventListener('scroll', handleScrollAndTouch);
scrollArea.addEventListener('touchmove', handleScrollAndTouch);
return () => {
scrollArea.removeEventListener('scroll', handleScrollAndTouch);
scrollArea.removeEventListener('touchmove', handleScrollAndTouch);
};
}, []);
return (
<div className={styles['chat-container']}>
<div className={styles['scroll-container']}>
<div
ref={scrollAreaRef}
className={styles['scroll-area']}
>
<div className={styles['messages-container']}>
{messages.map((message, index) => {
switch (message.type) {
case 'message':
const data = message.data as MagmaUserMessage | MagmaAssistantMessage;
return <div key={index} className={`${styles['message']} ${data.role === 'user' ? styles['user-message'] : styles['assistant-message']}`}>
{data.content}
</div>
}
}
)}
{awaitingResponse && <div className={styles['loading-bubble']} />}
</div>
<div className={`${styles['scroll-button-container']} ${isAtBottom ? styles['scroll-button-container-hidden'] : styles['scroll-button-container-visible']}`}>
<button className={styles['scroll-button']} onClick={scrollToBottom}>
v
</button>
</div>
</div>
</div>
<div ref={textareaContainerRef} className={styles['textarea-container']}>
<textarea
ref={textareaRef}
placeholder="Type a message..."
value={message}
onChange={(e) => setMessage(e.target.value)}
className={styles['textarea']}
onKeyDown={handleKeyPress}
/>
<div className={styles['send-button-container']}>
<button className={styles['send-button']} onClick={handleSubmit} disabled={awaitingResponse || message.trim().length === 0}>
Send
</button>
</div>
</div>
</div>
)
}
Also copy the corresponding styles:
.chat-container {
height: 100%;
overflow: hidden;
width: 100%;
display: flex;
flex-direction: column;
}
.scroll-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
width: 100%;
margin-inline: auto;
max-width: 64rem;
}
.scroll-area {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.messages-container {
display: flex;
flex-direction: column;
margin-top: auto;
gap: 1rem;
padding: 1rem;
}
.message {
padding: 0.5rem;
border-radius: 0.375rem;
max-width: 85%;
line-height: 1.5;
}
.user-message {
background-color: color-mix(in oklab, var(--foreground) 10%, transparent);
color: var(--foreground);
margin-left: auto;
}
.assistant-message {
color: var(--foreground);
margin-right: auto;
}
.loading-bubble {
width: 0.75rem;
height: 0.75rem;
margin: 0.875rem 0rem;
background-color: var(--foreground);
border-radius: calc(infinity * 1px);
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: .5;
}
}
.scroll-button-container {
position: absolute;
bottom: 0.75rem;
right: 1.75rem;
transition-property: all;
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
transition-duration: 150ms;
}
.scroll-button-container-hidden {
pointer-events: none;
translate: 0 1rem;
scale: 0.95;
opacity: 0;
}
.scroll-button-container-visible {
translate: 0 0;
scale: 1;
opacity: 1;
}
.scroll-button {
border-style: solid;
border-width: 1px;
border-radius: 0.375rem;
width: 2.25rem;
height: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
background-color: var(--background);
}
.textarea-container {
position: relative;
max-width: 42rem;
width: 100%;
margin-inline: auto;
}
.textarea {
width: 100%;
padding-inline: 1rem;
padding-block: 0.5rem;
border-radius: 0.375rem;
height: 6rem;
padding-right: 4rem;
resize: none;
border-style: solid;
border-width: 1px;
}
.send-button-container {
position: absolute;
bottom: 0.5rem;
right: 1.5rem;
}
.send-button {
border-style: solid;
border-width: 1px;
background-color: var(--foreground);
color: var(--background);
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
height: 2.25rem;
padding-inline: 1rem;
padding-block: 0.5rem;
&:disabled {
opacity: 0.5;
}
}
You can then use the component inside the MagmaChatProvider
from the react-obsidian
package you installed earlier:
import Chat from "@/components/chat";
import MagmaChatProvider from '@pompeii-labs/react-obsidian';
export default function Home() {
return (
<div style={{ height: '100vh', width: '100vw' }}>
<MagmaChatProvider agentId="YOUR_AGENT_ID" apiKey="YOUR_API_KEY">
<Chat />
</MagmaChatProvider>
</div>
);
}