Epicenter
Local-first, open source apps
Chat is the thread of a conversation with the agent. Each turn is a Message inside Message Scroller. The person's message is the scroll anchor, so a new question settles near the top and the reply streams in under it. The field is a Prompt Input.
Installation
Chat is built with the Message Scroller, Message, Bubble, and Input Group components.
Usage
<MessageScroller.Provider autoScroll>
<MessageScroller.Root>
<MessageScroller.Viewport>
<MessageScroller.Content>
<MessageScroller.Item messageId={id} scrollAnchor>
<Message.Root align="end">
<Message.Content>
<Bubble.Root align="end" variant="muted">
<Bubble.Content>{text}</Bubble.Content>
</Bubble.Root>
</Message.Content>
</Message.Root>
</MessageScroller.Item>
</MessageScroller.Content>
</MessageScroller.Viewport>
<MessageScroller.Button />
</MessageScroller.Root>
</MessageScroller.Provider> scrollAnchor goes on the person's turn. autoScroll follows the reply while the person stays at the live edge.
See Message Scroller.
AI SDK Helper
To build without a model, script the conversation with createChat from @shadcn/helpers/ai-sdk. Each agent turn streams reasoning, a tool call and a reply. script.next() fills the field with the next scripted message, and a string fallback replies once the script runs out.
const script = createChat()
.user("Add a dark mode toggle to the settings page.")
.assistant(({ writer }) => {
writer.reasoning("I need to know how themes are set up first.");
writer
.tool("runCommand", { input: { command: "rg -l ThemeProvider app" } })
.output({ stdout: "app/layout.tsx" });
writer.text("next-themes is already set up, so I added a ModeToggle.");
});
const transport = script.transport({
fallback:
"This demo only knows the scripted turns. Press reset to start over.",
});
const chat = new Chat({ transport });
const nextMessage = $derived(script.next(chat.messages));