The Svelte package we provide is designed for Svelte 5. If you are interested in support for Svelte 4, please contact us

Initializing a project

If you have not set up a project yet, you can easily initialize a ShadCN-Svelte / Svelte.js project by following the instructions here

Or if you prefer not to use ShadCN-Svelte:

npx sv create

Overview

The logic of the websocket chat is handled by a Context provider. You can install it with this command:

npm i @pompeii-labs/svelte-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.svelte
<script lang="ts">
	import { Button } from '$lib/components/ui/button';
	import { Textarea } from '$lib/components/ui/textarea';
	import { getChatContext } from '@pompeii-labs/svelte-obsidian';
	import { cn } from '$lib/utils';
	import type { MagmaUserMessage, MagmaAssistantMessage } from '@pompeii-labs/magma/types';
    import ArrowDown from 'lucide-svelte/icons/arrow-down';
    import ArrowUp from 'lucide-svelte/icons/arrow-up';

	const { sendMessage, messages, awaitingResponse } = $derived(getChatContext());

    let scrollArea: HTMLDivElement | null = $state(null);
    let textareaContainer: HTMLDivElement | null = $state(null);
    let textarea: HTMLTextAreaElement | null = $state(null);
    let message = $state('');

    const SCROLL_THRESHOLD = 196;
    const MIN_TEXTAREA_HEIGHT = 96;
    const MAX_TEXTAREA_HEIGHT = 192;

    let isAtBottom = $state(true);

    // Send the message to the websocket and clear the message state
    function handleSubmit() {
        if (message.trim().length === 0 || awaitingResponse) return;
        sendMessage(message);
        message = '';
        textarea?.focus();
    }

    // Auto resize the textarea
    $effect(() => {
        message;
        if (!textareaContainer) return;
        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%';
    });

    // Handle key press
    function handleKeyPress(event: KeyboardEvent) {
        if (event.key === 'Enter' && !event.shiftKey) {
            event.preventDefault();
            handleSubmit();
        }
    }

    // Scroll to the bottom of the chat
    function scrollToBottom() {
        if (!scrollArea) return;

        scrollArea.scrollTo({
            top: scrollArea.scrollHeight,
            behavior: 'smooth'
        });
    }

    // Scroll to the bottom of the chat when a new message is added
    $effect(() => {
        const lastMessage = messages[messages.length - 1];

        if (lastMessage?.type === 'message' || lastMessage?.type === 'loading') {
            scrollToBottom();
        }
    });

    // Add scroll and touch event listeners
    $effect(() => {
        if (!scrollArea) return;

        const handleScrollAndTouch = () => {
            if (!scrollArea) return;
            const distanceFromBottom = scrollArea.scrollHeight - (scrollArea.scrollTop + scrollArea.clientHeight);
            isAtBottom = distanceFromBottom <= SCROLL_THRESHOLD;
        };

        scrollArea.addEventListener('scroll', handleScrollAndTouch);
        scrollArea.addEventListener('touchmove', handleScrollAndTouch);
        
        return () => {
            if (!scrollArea) return;
            scrollArea.removeEventListener('scroll', handleScrollAndTouch);
            scrollArea.removeEventListener('touchmove', handleScrollAndTouch);
        };
    })
</script>

<div class="h-full overflow-hidden w-full flex flex-col pb-2">
    <div class="flex-1 flex flex-col overflow-hidden relative container px-0 mx-auto max-w-5xl">
        <div bind:this={scrollArea} class="flex-1 flex flex-col overflow-y-auto">
            <div class="flex flex-col mt-auto gap-4 p-4">
                {#each messages as message}
                    {#if message.type === 'message'}
                        {@const data = message.data as MagmaUserMessage | MagmaAssistantMessage}
                        <div class="{data.role === 'user' ? 'bg-secondary text-secondary-foreground ml-auto' : 'text-foreground mr-auto'} p-2 rounded-md max-w-[85%]">
                            {data.content}
                        </div>
                    {/if}
                {/each}
                {#if awaitingResponse}
                    <div class="size-3 my-3.5 bg-foreground rounded-full animate-pulse"></div>
                {/if}
            </div>
            <div class={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 bind:this={textareaContainer} class="relative max-w-2xl container px-0 mx-auto">
        <Textarea
            bind:ref={textarea}
            placeholder="Type a message..."
            bind:value={message}
            class="w-full h-24 pr-16 resize-none border"
            onkeydown={handleKeyPress}
        />
        <div class="absolute bottom-2 right-6">
            <Button size="icon" onclick={handleSubmit} disabled={awaitingResponse || message.trim().length === 0} class="transition-opacity">
                <ArrowUp />
            </Button>
        </div>
    </div>
</div>

You can then use the component inside the MagmaChatProvider from the svelte-obsidian package you installed earlier:

+page.svelte
<script lang="ts">
    import Chat from '$lib/components/chat.svelte';
    import MagmaChatProvider from '@pompeii-labs/svelte-obsidian';
</script>

<div class="w-screen h-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.svelte
<script lang="ts">
	import { getChatContext } from '@pompeii-labs/svelte-obsidian';
	import type { MagmaUserMessage, MagmaAssistantMessage } from '@pompeii-labs/magma/types';

	const { sendMessage, messages, awaitingResponse } = $derived(getChatContext());

    let scrollArea: HTMLDivElement | null = $state(null);
    let textareaContainer: HTMLDivElement | null = $state(null);
    let textarea: HTMLTextAreaElement | null = $state(null);
    let message = $state('');

    const SCROLL_THRESHOLD = 196;
    const MIN_TEXTAREA_HEIGHT = 96;
    const MAX_TEXTAREA_HEIGHT = 192;

    let isAtBottom = $state(true);

    // Send the message to the websocket and clear the message state
    function handleSubmit() {
        if (message.trim().length === 0 || awaitingResponse) return;
        sendMessage(message);
        message = '';
        textarea?.focus();
    }

    // Auto resize the textarea
    $effect(() => {
        message;
        if (!textareaContainer) return;
        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%';
    });

    // Handle key press
    function handleKeyPress(event: KeyboardEvent) {
        if (event.key === 'Enter' && !event.shiftKey) {
            event.preventDefault();
            handleSubmit();
        }
    }

    // Scroll to the bottom of the chat
    function scrollToBottom() {
        if (!scrollArea) return;

        scrollArea.scrollTo({
            top: scrollArea.scrollHeight,
            behavior: 'smooth'
        });
    }

    // Scroll to the bottom of the chat when a new message is added
    $effect(() => {
        const lastMessage = messages[messages.length - 1];

        if (lastMessage?.type === 'message' || lastMessage?.type === 'loading') {
            scrollToBottom();
        }
    });

    // Add scroll and touch event listeners
    $effect(() => {
        if (!scrollArea) return;

        const handleScrollAndTouch = () => {
            if (!scrollArea) return;
            const distanceFromBottom = scrollArea.scrollHeight - (scrollArea.scrollTop + scrollArea.clientHeight);
            isAtBottom = distanceFromBottom <= SCROLL_THRESHOLD;
        };

        scrollArea.addEventListener('scroll', handleScrollAndTouch);
        scrollArea.addEventListener('touchmove', handleScrollAndTouch);
        
        return () => {
            if (!scrollArea) return;
            scrollArea.removeEventListener('scroll', handleScrollAndTouch);
            scrollArea.removeEventListener('touchmove', handleScrollAndTouch);
        };
    })
</script>

<div class="h-full overflow-hidden w-full flex flex-col pb-2">
    <div class="flex-1 flex flex-col overflow-hidden relative container px-0 mx-auto max-w-5xl">
        <div bind:this={scrollArea} class="flex-1 flex flex-col overflow-y-auto">
            <div class="flex flex-col mt-auto gap-4 p-4">
                {#each messages as message}
                    {#if message.type === 'message'}
                        {@const data = message.data as MagmaUserMessage | MagmaAssistantMessage}
                        <div class="{data.role === 'user' ? 'bg-foreground/10 text-foreground ml-auto' : 'text-foreground mr-auto'} p-2 rounded-md max-w-[85%]">
                            {data.content}
                        </div>
                    {/if}
                {/each}
                {#if awaitingResponse}
                    <div class="size-3 my-3.5 bg-foreground rounded-full animate-pulse"></div>
                {/if}
            </div>
            <div class="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 class="border rounded-md size-9 inline-flex items-center justify-center bg-background" onclick={scrollToBottom}>
                    v
                </button>
            </div>
        </div>
    </div>
    <div bind:this={textareaContainer} class="relative max-w-2xl container px-0 mx-auto">
        <textarea
            bind:this={textarea}
            placeholder="Type a message..."
            bind:value={message}
            class="w-full px-4 py-2 rounded-md h-24 pr-16 resize-none border"
            onkeydown={handleKeyPress}
        ></textarea>
        <div class="absolute bottom-2 right-6">
            <button class="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 svelte-obsidian package you installed earlier:

+page.svelte
<script lang="ts">
    import Chat from '$lib/components/chat.svelte';
    import MagmaChatProvider from '@pompeii-labs/svelte-obsidian';
</script>

<div class="w-screen h-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.svelte
<script lang="ts">
	import { getChatContext } from '@pompeii-labs/svelte-obsidian';
	import type { MagmaUserMessage, MagmaAssistantMessage } from '@pompeii-labs/magma/types';
    import styles from './chat.module.css';

	const { sendMessage, messages, awaitingResponse } = $derived(getChatContext());

    let scrollArea: HTMLDivElement | null = $state(null);
    let textareaContainer: HTMLDivElement | null = $state(null);
    let textarea: HTMLTextAreaElement | null = $state(null);
    let message = $state('');

    const SCROLL_THRESHOLD = 196;
    const MIN_TEXTAREA_HEIGHT = 96;
    const MAX_TEXTAREA_HEIGHT = 192;

    let isAtBottom = $state(true);

    // Send the message to the websocket and clear the message state
    function handleSubmit() {
        if (message.trim().length === 0 || awaitingResponse) return;
        sendMessage(message);
        message = '';
        textarea?.focus();
    }

    // Auto resize the textarea
    $effect(() => {
        message;
        if (!textareaContainer) return;
        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%';
    });

    // Handle key press
    function handleKeyPress(event: KeyboardEvent) {
        if (event.key === 'Enter' && !event.shiftKey) {
            event.preventDefault();
            handleSubmit();
        }
    }

    // Scroll to the bottom of the chat
    function scrollToBottom() {
        if (!scrollArea) return;

        scrollArea.scrollTo({
            top: scrollArea.scrollHeight,
            behavior: 'smooth'
        });
    }

    // Scroll to the bottom of the chat when a new message is added
    $effect(() => {
        const lastMessage = messages[messages.length - 1];

        if (lastMessage?.type === 'message' || lastMessage?.type === 'loading') {
            scrollToBottom();
        }
    });

    // Add scroll and touch event listeners
    $effect(() => {
        if (!scrollArea) return;

        const handleScrollAndTouch = () => {
            if (!scrollArea) return;
            const distanceFromBottom = scrollArea.scrollHeight - (scrollArea.scrollTop + scrollArea.clientHeight);
            isAtBottom = distanceFromBottom <= SCROLL_THRESHOLD;
        };

        scrollArea.addEventListener('scroll', handleScrollAndTouch);
        scrollArea.addEventListener('touchmove', handleScrollAndTouch);
        
        return () => {
            if (!scrollArea) return;
            scrollArea.removeEventListener('scroll', handleScrollAndTouch);
            scrollArea.removeEventListener('touchmove', handleScrollAndTouch);
        };
    })
</script>

<div class={styles['chat-container']}>
    <div class={styles['scroll-container']}>
        <div bind:this={scrollArea} class={styles['scroll-area']}>
            <div class={styles['messages-container']}>
                {#each messages as message}
                    {#if message.type === 'message'}
                        {@const data = message.data as MagmaUserMessage | MagmaAssistantMessage}
                        <div class="{styles['message']} {data.role === 'user' ? styles['user-message'] : styles['assistant-message']}">
                            {data.content}
                        </div>
                    {/if}
                {/each}
                {#if awaitingResponse}
                    <div class={styles['loading-bubble']}></div>
                {/if}
            </div>
            <div class="{styles['scroll-button-container']} {isAtBottom ? styles['scroll-button-container-hidden'] : styles['scroll-button-container-visible']}">
                <button class={styles['scroll-button']} onclick={scrollToBottom}>
                    v
                </button>
            </div>
        </div>
    </div>
    <div bind:this={textareaContainer} class={styles['textarea-container']}>
        <textarea
            bind:this={textarea}
            placeholder="Type a message..."
            bind:value={message}
            class={styles['textarea']}
            onkeydown={handleKeyPress}
        ></textarea>
        <div class={styles['send-button-container']}>
            <button class={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 svelte-obsidian package you installed earlier:

+page.svelte
<script lang="ts">
    import Chat from '$lib/components/chat.svelte';
    import MagmaChatProvider from '@pompeii-labs/svelte-obsidian';
</script>

<div style="width: 100vw; height: 100vh;">
    <MagmaChatProvider agentId="YOUR_AGENT_ID" apiKey="YOUR_API_KEY">
        <Chat />
    </MagmaChatProvider>
</div>