Skip to content

Migrate from chatscope to aparté

Move an AI chat built on @chatscope/chat-ui-kit-react to aparté — what each component becomes, how the message model maps, and how to keep your own backend or stream a model.

On this page

@chatscope/chat-ui-kit-react is a kit for chats between people: you assemble the components, and the messages and the sending stay in your code. If you built an AI chat on it, <AparteChat> from @aparte/react is the whole chat in one component, and it draws what a model’s reply needs: tokens as they stream, tool calls, reasoning.

You can move in two steps: change the view and keep your own backend call, then stream the reply.

bash
npm uninstall @chatscope/chat-ui-kit-react @chatscope/chat-ui-kit-styles
npm install @aparte/react @aparte/core

A typical chatscope chat holds the messages and the typing flag itself:

jsx
import { useState } from 'react';
import '@chatscope/chat-ui-kit-styles/dist/default/styles.min.css';
import { MainContainer, ChatContainer, MessageList, Message, MessageInput, TypingIndicator } from '@chatscope/chat-ui-kit-react';

export function Chat() {
  const [messages, setMessages] = useState([]);
  const [typing, setTyping] = useState(false);

  async function send(_html, text) {
    setMessages((m) => [...m, { message: text, direction: 'outgoing', position: 'single' }]);
    setTyping(true);
    const answer = await askMyServer(text);
    setMessages((m) => [...m, { message: answer, direction: 'incoming', position: 'single' }]);
    setTyping(false);
  }

  return (
    <div style={{ position: 'relative', height: 500 }}>
      <MainContainer>
        <ChatContainer>
          <MessageList typingIndicator={typing ? <TypingIndicator content="Typing…" /> : null}>
            {messages.map((m, i) => <Message key={i} model={m} />)}
          </MessageList>
          <MessageInput placeholder="Type message here" onSend={send} />
        </ChatContainer>
      </MainContainer>
    </div>
  );
}

With aparté, the person’s message is added for you and the waiting dots show until the first word:

tsx
import { useCallback } from 'react';
import { uuid } from '@aparte/core';
import { AparteChat, useAparteChat } from '@aparte/react';
import '@aparte/core/styles.css';

async function* reply(text: string) {
  yield await askMyServer(text);
}

export function Chat() {
  const chat = useAparteChat();

  const onMessageSent = useCallback(async (event: { content: string }) => {
    const id = uuid();
    chat.ref.current?.appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() });
    await chat.ref.current?.injectTokenStream(id, reply(event.content));
  }, [chat.ref]);

  return (
    <div style={{ height: 500 }}>
      <AparteChat
        ref={chat.ref}
        messages={chat.messages}
        onMessagesChange={chat.setMessages}
        onMessageSent={onMessageSent}
        placeholder="Type message here"
      />
    </div>
  );
}
chatscopeaparté
MainContainer, ChatContainer, MessageList, MessageInput<AparteChat>, in a parent with a height
Message and its modelan AparteMessage in messages
direction: 'outgoing', 'incoming'role: 'user', 'assistant'
messagecontent, drawn as Markdown once a renderer is set up
sentTime, a stringtimestamp, a number such as Date.now()
positionnot needed
type: 'custom' and payloadrenderBubble(message)
TypingIndicatorbuilt in, while a reply has no words yet
MessageInput’s onSendonMessageSent; it receives event.content
MessageInput’s attachButtonthe attachments prop (Attachments)
InputToolboxthe toolbar prop
Avatar on a messagesetAvatarProvider()
ConversationHeaderthe header
Sidebar<aparte-sidebar>
ConversationList, Conversation<aparte-conversation-list>
@chatscope/use-chatuseAparteChat, and conversation persistence
MessageSeparatorno equivalent in the transcript
onYReachStart, loadingMoreno equivalent: you give the messages up front

@chatscope/chat-ui-kit-styles is replaced by @aparte/core/styles.css. Where you set Sass variables, set CSS variables instead (Theme):

css
:root {
  --aparte-primary: #b45309;
  --aparte-bg: #fbf7f0;
  --aparte-text: #241a12;
}

injectTokenStream takes any async iterable, so a server that streams text only changes reply:

ts
async function* reply(text: string) {
  const res = await fetch('/api/chat', { method: 'POST', body: text });
  const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
  for (;;) {
    const { done, value } = await reader.read();
    if (done) return;
    yield value;
  }
}

If you have no loop worth keeping, remove onMessageSent and start a client. Register a provider (Connect a model), or keep the key on your server (Key on your server):

tsx
import { AparteChat, useAparteClient } from '@aparte/react';
import '@aparte/core/styles.css';

export function Chat() {
  useAparteClient();
  return <AparteChat />;
}

aparté draws one person talking to an assistant: a message is user or assistant, and has no sender. For a chat between several people, with a name on each message, chatscope fits better.