Skip to content
Pilotoo
v0.1

The chat UI

For a DOM app, @pilotoo/ui/chat gives you a working chat in two components. ChatProvider wires the connection (transport, catalogs, session) from a server URL and a token. ChatPanel renders the thread and the composer. Everything else on this page is customisation. For React Native, see the components of @pilotoo/chat-ui at the end.

This is the mount of a SaaS app that already has its own login, anonymized:

src/pages/AssistantPage.tsx
import { ChatPanel, ChatProvider } from '@pilotoo/ui/chat';
export function AssistantPage({ agentId }: { agentId: string }) {
return (
<ChatProvider
key={agentId}
baseUrl={ASSISTANT_URL}
getToken={() => getAccessToken() ?? undefined}
agentId={agentId}
>
<ChatPanel />
</ChatProvider>
);
}

getToken returns the host’s own session token. It is read on every request and every socket reconnect, so a refreshed token is used. See Connecting a client.

ChatProvider reads baseUrl and transport once, because a transport owns one connection for its whole life. To point the chat elsewhere, change key and the provider remounts. The example above does it per agent.

agentId binds new conversations to one agent and filters the conversation list to it. Set unscopedAgentId when older conversations bound to no agent should count as belonging to that agent, usually the server’s default one.

ConversationList reads the same session, so place it inside the provider:

import { ChatPanel, ChatProvider, ConversationList } from '@pilotoo/ui/chat';
<ChatProvider baseUrl={ASSISTANT_URL} getToken={getToken}>
<aside><ConversationList /></aside>
<main><ChatPanel /></main>
</ChatProvider>;

Check ConversationList props in packages/ui/src/chat/conversation-list.tsx before relying on more than the defaults. To build your own list, use the useChat() hook.

Every string in the panel can be overridden with labels; the ones you leave out keep their English default.

<ChatPanel labels={{ newConversation: 'Nouvelle conversation', conversations: 'Discussions' }} />

ChatLabels lists the keys (resolveLabels merges them). Voice has its own voiceLabels prop.

By default a tool call shows a humanised tool name. Give your tools readable text with toolLabels, keyed by tool name:

<ChatPanel
toolLabels={{
get_order: {
running: 'Looking up the order',
done: 'Looked up the order',
describe: (args) => (typeof args.id === 'string' ? `Order ${args.id}` : undefined),
},
}}
/>

running shows while the call is pending, done afterwards. describe receives the parsed call arguments and returns the detail line; when it is missing or returns undefined, the generic argument summary is used.

The assistant writes Markdown. linkify rewrites the text before it is rendered, for example to turn an order number into a link. renderLink draws links itself, so relative ones can go through your router:

<ChatPanel
linkify={(markdown) => markdown.replace(/#(\d+)/g, '[#$1](/orders/$1)')}
renderLink={({ href, className, children }) =>
href.startsWith('/') && !href.startsWith('//') ? (
<Link to={href} className={className}>{children}</Link>
) : undefined
}
/>

Return undefined to fall back to a plain anchor that opens in a new tab.

empty replaces the new-conversation screen. It receives send, prefill, the composer to place and an error:

<ChatPanel
empty={({ composer, send }) => (
<div>
<h2>How can I help?</h2>
<button onClick={() => void send('Summarise my open orders')}>Summarise my open orders</button>
{composer}
</div>
)}
/>

prepareFirstMessage on ChatProvider rewrites the first message of a new conversation before it is sent, so you can lead it with what the user is looking at. Later messages go as typed.

<ChatProvider
baseUrl={ASSISTANT_URL}
getToken={getToken}
prepareFirstMessage={(text) => `[Open order: ${currentOrder.id}]\n${text}`}
>

ChatPanel is assembled from ChatThread and ChatComposer. Use those, plus UserMessage, AssistantMessage, Markdown and the rest, when you want your own layout. The demo web app does this: it builds its own shell on @pilotoo/chat-core and @pilotoo/ui.

Prop Meaning
baseUrl Origin of the chat server (REST and Socket.IO share it). Read once.
getToken Bearer token provider; read on every request and reconnect.
transport Replaces the default Socket.IO transport. Read once.
agentId, unscopedAgentId The agent new conversations are bound to, and the one older unbound conversations count as.
storage, storagePrefix Where the last open conversation is remembered. Defaults to localStorage.
prepareFirstMessage Rewrites the first message of a new conversation.
clientTools Tools only this host can run. See Client tools.

empty, labels, voiceLabels, toolLabels, linkify, renderLink, clientToolViews and onComposerReady (the composer’s handle, to prefill drafts). The authority is packages/ui/src/chat/chat-panel.tsx.

Entry Contents
@pilotoo/ui Primitives and overlays, plus cn
@pilotoo/ui/chat ChatProvider, ChatPanel, ChatThread, ChatComposer, ConversationList, messages, Markdown, CodeBlock, ToolCall, AgentPicker, ModelPicker, attachment and client-tool pieces
@pilotoo/ui/settings SettingsLayout, AgentsList, AgentEditor, ProfilesList, UsageSection, SchedulesSection, ThemePicker
@pilotoo/ui/voice VoiceProvider, ReadAloudButton, DictationButton, hooks
@pilotoo/ui/desktop Sidebar, Toolbar, applyPlatformClasses, useVibrancy, syncNativeTheme
@pilotoo/ui/tokens.css, /fonts.css Design tokens; opt-in fonts

@pilotoo/chat-ui ships pieces, not a finished screen: a screen that owned keyboard avoidance and safe areas would be one product’s chat. You arrange MessageList, Message, Composer, MessageActions and the rest on top of @pilotoo/chat-core; the demo mobile app (apps/mobile) is the worked example.

<MessageList
messages={messages}
renderContent={(m) => (m.role === 'assistant' ? <Markdown>{m.content}</Markdown> : null)}
renderActions={(m) => <MessageActions content={m.content} />}
/>
  • A slot is a ReactNode prop (header, actions, footer, empty), never a show* boolean.
  • An action renders only when its handler is given: retry needs onRetry, stop needs onStop.
  • Composer is controlled (value) or uncontrolled (defaultValue), decided on first render. In controlled mode you clear it once you accepted the message.
  • className is layout only; visual tokens belong to variant, size and tone.
  • Types such as ChatMessage come from @pilotoo/chat-core.
  • Native ports (ClipboardProvider, AttachmentPickerProvider, AttachmentImageProvider) are supplied by the app. See Clipboard and Attachments. packages/chat-ui/README.md has the full conventions.