Skip to content
Pilotoo
v0.1

Add chat to a React app

You will add the kit’s chat to a React app: a conversation list, the thread and the composer, signed in with your app’s token. The same code can run one panel per agent, with your own labels, tool names and links.

  • React 18 or newer, Vite and Tailwind CSS v4 in your app.
  • @pilotoo/ui installed, and a server reachable from the browser (see Connecting a client).

1. Load the stylesheet and tell Tailwind where the components are

Section titled “1. Load the stylesheet and tell Tailwind where the components are”

Tailwind never scans node_modules, so without @source the components render with the right markup and no styles. Load the stylesheet as an import, not as a <link>.

src/styles.css
@import "tailwindcss";
@import "@pilotoo/ui/tokens.css";
@import "@pilotoo/ui/fonts.css";
@source "../node_modules/@pilotoo/ui/dist";
src/main.tsx
import './styles.css';

fonts.css is optional: leave it out to use the system fonts.

ChatProvider owns the connection. ChatPanel is the thread and the composer for the open conversation. getToken returns the bare token; the kit adds Bearer.

src/assistant/AssistantPage.tsx
import { ChatPanel, ChatProvider } from '@pilotoo/ui/chat';
import { getAccessToken } from '../auth/session';
export function AssistantPage({ agentId }: { agentId: string }) {
return (
<ChatProvider
key={agentId}
baseUrl={import.meta.env.VITE_ASSISTANT_URL}
getToken={() => getAccessToken() ?? undefined}
agentId={agentId}
storagePrefix={`assistant.${agentId}.`}
>
<ChatPanel className="h-full" />
</ChatProvider>
);
}

Give the panel a height: it fills its parent. key={agentId} remounts the provider when the agent changes, which also opens a new socket.

Every string is a label you can replace. toolLabels renames the tool rows the agent shows while it works.

src/assistant/labels.ts
import type { ChatLabels, ToolLabels } from '@pilotoo/ui/chat';
export const labels: Partial<ChatLabels> = {
newConversation: 'New chat',
composerPlaceholder: 'Ask about your invoices',
send: 'Send',
};
export const toolLabels: ToolLabels = {
list_invoices: {
running: 'Looking up invoices',
done: 'Invoices found',
describe: (args) => (typeof args.status === 'string' ? `status: ${args.status}` : undefined),
},
};
<ChatPanel labels={labels} toolLabels={toolLabels} />

linkify rewrites the assistant’s markdown before it is drawn; renderLink draws each link. Use them to send INV-1042 to a route in your app instead of a new tab.

src/assistant/links.tsx
import { Link } from 'react-router-dom';
import type { RenderLink } from '@pilotoo/ui/chat';
export const linkify = (markdown: string) =>
markdown.replace(/\bINV-(\d+)\b/g, (id) => `[${id}](/invoices/${id.slice(4)})`);
export const renderLink: RenderLink = ({ href, className, children }) => {
// Only same-app paths. Anything else, including //host, stays a normal link.
if (href.startsWith('/') && !href.startsWith('//')) {
return <Link className={className} to={href}>{children}</Link>;
}
return undefined;
};

Returning undefined falls back to a plain anchor that opens in a new tab.

empty receives send, prefill, the composer and an error. Place the composer wherever the screen wants it.

src/assistant/EmptyState.tsx
import type { ChatEmptyProps } from '@pilotoo/ui/chat';
export function EmptyState({ send, composer }: ChatEmptyProps) {
return (
<div className="mx-auto flex max-w-xl flex-col gap-4 pt-24">
<h1 className="text-2xl font-semibold">How can I help?</h1>
<button type="button" className="rounded-lg border px-3 py-2 text-left" onClick={() => void send('Which invoices are overdue?')}>
Which invoices are overdue?
</button>
{composer}
</div>
);
}
<ChatPanel empty={(props) => <EmptyState {...props} />} />

In dev, linked workspace packages publish CommonJS, and Vite leaves a linked package alone. The symptom is does not provide an export named .... Pre-bundle the three entries. An installed copy of the packages does not need this.

vite.config.ts
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), react()],
optimizeDeps: {
include: ['@pilotoo/chat-core', '@pilotoo/chat-core/socket-io', '@pilotoo/chat-protocol'],
},
resolve: { dedupe: ['react', 'react-dom'] },
});

If you vendor the packages as tarballs, start Vite with --force after you replace one, or the old pre-bundle is used.

  • ChatProvider builds one socket transport and the REST catalogs for conversations, agents and attachments. Every one of them calls getToken per request.
  • With agentId set, the list shows only that agent’s conversations, and new ones are bound to it. unscopedAgentId also counts conversations that have no agent as belonging to it.
  • storagePrefix keeps the “last open conversation” of two panels apart. The storage is localStorage unless you pass storage.
  • ChatPanel is an assembled example. For another layout, compose ChatThread, ChatComposer and ConversationList yourself from the same package.