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:

chat.tsx
'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:

page.tsx
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:

chat.tsx
'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:

page.tsx
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:

chat.tsx
'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.module.css
.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:

page.tsx
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>
    );
}