---
name: building-glirastes-chat-ui
description: Customizing the Glirastes chat UI — VercelAiChat / LangGraphAiChat, AiChatProvider + AiChatPanel composition, theming, mentions, voice input, approval cards, ChatWindow portal, useAiClientAction. Use when going beyond the zero-config chat widget.
version: 1.0.0
tags:
- glirastes
- react
- chat-ui
- components
triggers:
- chat panel
- chat window
- VercelAiChat
- LangGraphAiChat
- AiChatProvider
- AiChatPanel
- useAiChat
- useAiClientAction
- chat theming
- chat mentions
- voice input
- approval card
- suggestion chips
- pipeline timeline
---
# Building the Glirastes Chat UI
> **Scope:** customizing the chat UI beyond ``. For the initial integration (install, wire the route, drop the widget), see [integrating-glirastes-nextjs](../integrating-glirastes-nextjs/SKILL.md) or [integrating-glirastes-nestjs](../integrating-glirastes-nestjs/SKILL.md).
## Component map
```
AiChatProvider (state + transport + config)
└─ AiChatPanel (the panel layout — header, body, input)
├─ MessageList → MessageBubble (text, mentions, tool results, approvals, pipeline timeline)
├─ SuggestionBar (Pro: contextual followup chips)
└─ ChatInput / RichMentionInput (input box)
└─ VoiceInputAddon (optional: live waveform + STT)
VercelAiChat = AiChatProvider + ChatWindow (floating, draggable, resizable) + AiTriggerButton (the floating launcher)
LangGraphAiChat = same shape but talks LangGraph instead of Vercel AI SDK
```
`` is the zero-config wrapper. For full control, drop down to `AiChatProvider` + `AiChatPanel`.
## Three integration levels
### Level 1 — VercelAiChat (zero config)
```tsx
'use client';
import { VercelAiChat } from 'glirastes/react/vercel';
import 'glirastes/react/styles.css';
export function ChatWidget() {
return ;
}
```
This gives you the floating trigger button (bottom-right), draggable+resizable chat window, message rendering, mentions, voice input, approval cards. Everything wired up. ~80% of apps stop here.
### Level 2 — VercelAiChat with props
`VercelAiChat` (and `LangGraphAiChat`) accept layout + behaviour props:
```tsx
({ Authorization: `Bearer ${getToken()}` })}
bodyExtras={() => ({ tenantId: getTenantId() })}
title="Assistant"
defaultOpen={false}
draggable
resizable
size={{ width: 400, height: 640 }}
hideTriggerWhenOpen
shortcut={{ key: 'k', meta: true }}
showMic={true}
showClearButton
confirmClear
showSessionSwitcher
welcomeMessage="Hi! Ask me about your tasks."
triggerIcon={}
headerActions={}
sessions={{ storage: 'localStorage', maxHistory: 30 }}
autoResumeOnApproval
/>
```
Highlights:
| Prop | What it does |
|---|---|
| `headers` | Function returning request headers — refreshed on every request, ideal for JWT |
| `bodyExtras` | Extra fields merged into the chat request body — tenant ID, locale, etc. |
| `shortcut` | Keyboard shortcut to toggle the chat (e.g. `⌘K`) |
| `showMic` | Voice input toggle. Set `false` to drop `wavesurfer.js` from the bundle entirely |
| `sessions` | Multi-session history — switches between conversations, persists across reloads |
| `autoResumeOnApproval` | After an approval card is approved, the LLM continues without manual nudge |
### Level 3 — AiChatProvider + AiChatPanel (full control)
When you need a non-floating layout (sidebar, full-page, embedded), compose manually:
```tsx
'use client';
import { AiChatProvider, AiChatPanel, useAiChat } from 'glirastes/react';
import { useVercelAiChatTransport } from 'glirastes/react/vercel';
export function FullPageChat() {
const transport = useVercelAiChatTransport({
endpoint: '/api/chat',
headers: () => ({ Authorization: `Bearer ${getToken()}` }),
});
return (
);
}
```
Inside any descendant of `AiChatProvider`, `useAiChat()` exposes the full state — messages, sending, current pipeline state, etc. Useful when you want to render messages elsewhere or add custom controls.
## UI Action Bus — frontend reactions to tool results
Tool results that carry a `uiAction` payload (from `uiPattern` or `uiActionOnSuccess`) are dispatched through `UiActionBus`. Register handlers anywhere in the component tree:
```tsx
'use client';
import { useAiClientAction } from 'glirastes/react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
export function TaskList() {
const router = useRouter();
// Open the detail view when the LLM picks a task
useAiClientAction('task-details.open', (payload) => {
router.push(`/tasks/${payload?.taskId}`);
});
// Refresh the list after a mutation
useAiClientAction('tasks.refresh', () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
});
// Open a custom dialog
useAiClientAction('task-create-dialog.open', () => {
setCreateDialogOpen(true);
});
return /* ... */;
}
```
Action IDs follow conventions derived from `uiPattern`:
| `uiPattern.type` | Derived action ID |
|---|---|
| `open-detail`, `entity: 'task'` | `task-details.open` |
| `open-dialog`, `dialog: 'create-task'` | `task-create-dialog.open` |
| `refresh`, `target: 'tasks'` | `tasks.refresh` |
| `toast` | dispatched directly to your toast lib via the SDK's toast handler config |
For raw `uiActionOnSuccess: { type: 'run-client-action', actionId: 'X' }` you choose any string for `actionId`.
`glirastes coverage` reports any action ID declared in tools but not handled (or vice versa).
## Approval cards
Mutations with `needsApproval: true` (default for POST/PATCH/PUT/DELETE) render an approval card before executing. The default card shows:
- Tool name and description
- Input parameters
- Approve / Cancel buttons
Customize per-tool with optional `approvalCard` config:
```ts
defineEndpointTool({
// ...
approvalCard: {
title: 'Create task',
description: (input) => `Create task "${input.title}"?`,
severity: 'info', // or 'warning' | 'danger' for destructive ops
},
})
```
For full UI control, pass a custom component to `AiChatProvider`:
```tsx
...
```
## Mentions (`@user`, `#tag`)
Mentions let users reference entities the LLM can resolve to IDs. Configure on the provider or on `VercelAiChat`:
```tsx
{
const users = await fetch(`/api/users/search?q=${query}`).then(r => r.json());
return users.map(u => ({ id: u.id, label: u.name, hint: u.email }));
},
},
'#': {
label: 'Tag',
search: async (query) => fetch(`/api/tags?q=${query}`).then(r => r.json()),
},
},
}}
/>
```
Mentions in the input get serialized into the user message — your tools see structured references the LLM can pass back as IDs:
```
Input: "assign @Alice the task #urgent"
Wire: "assign user_123 the task with tag tag_456"
```
## Theming
The chat UI is styled with a small token system. Override at the provider level:
```tsx
```
For deeper customization, swap the default `glirastes/react/styles.css` for your own:
1. Don't import `glirastes/react/styles.css`
2. Inspect the DOM (every component has stable `data-*` attributes) — e.g. `[data-glirastes='chat-panel']`, `[data-part='message-bubble']`
3. Write your own CSS targeting those selectors
## Voice input (Deepgram speech-to-text)
The trigger button has a built-in mic mode. When the user holds the mic:
1. Browser captures audio
2. `wavesurfer.js` renders a live waveform
3. Audio frames stream to your backend's `/api/ai/speech-stream` WebSocket
4. Backend proxies to Deepgram, streams transcriptions back
5. On release, the final transcript becomes the chat input
**Frontend setup:**
```bash
npm install wavesurfer.js
```
```tsx
```
**Backend setup:** see [integrating-glirastes-nestjs](../integrating-glirastes-nestjs/SKILL.md) → "Speech-to-text WebSocket". For Next.js standalone, you need a separate WebSocket server — Next.js Route Handlers don't support WS natively.
If you don't want voice input at all, set `showMic={false}` and skip installing `wavesurfer.js`. The components are tree-shaken.
### Content Security Policy and `wavesurfer.js`
The optional-peer loader for `wavesurfer.js` uses `new Function('s', 'return import(s)')` to hide the specifier from bundlers (so apps that don't install the peer don't fail to build). This requires `script-src 'unsafe-eval'` in your CSP.
If your deployment forbids `unsafe-eval` **and** you want voice input, install `wavesurfer.js` unconditionally:
```bash
npm install wavesurfer.js
```
With the peer present at install time, the bundler resolves the static path successfully and the runtime trick is bypassed entirely. Strict-CSP environments without voice can stay on `showMic={false}` and never need `wavesurfer.js`.
## ChatWindow — portal, draggable, resizable
`VercelAiChat` already wraps the panel in a `ChatWindow`. If you compose manually, you can use the window separately:
```tsx
import { ChatWindow, AiChatPanel } from 'glirastes/react';
```
The window portals to `document.body` by default — no z-index fights with your app shell.
## Sessions (multi-conversation)
Enable conversation history with the `sessions` prop:
```tsx
trackEvent('chat:new-session'),
}}
showSessionSwitcher
/>
```
A session switcher renders in the header (with `showSessionSwitcher`); users can name, switch, or delete sessions. Custom adapters let you persist server-side instead of localStorage.
## Pipeline timeline (Pro)
When `GLIRASTES_API_KEY` is set, the chat shows per-request pipeline steps inline (intent classification, guardrails, tool calls, model selection):
```
↳ Intent: task_query (0.94)
↳ Tools available: 3
↳ Model: gpt-4o-mini
↳ Tool call: list_tasks (124ms)
```
No setup beyond having the key — the timeline auto-renders inside the message bubble of the corresponding assistant turn.
## Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Widget unstyled | `styles.css` not imported | Add `import 'glirastes/react/styles.css'` near the root |
| Floating button overlaps a sticky footer | Default position bottom-right | Pass `defaultPosition={{ x: 'right', y: 'bottom', offset: 80 }}` |
| `useAiClientAction` doesn't fire | Component not mounted when the action arrives | Mount the handler in a layout that's always rendered |
| Mentions search runs on every keystroke | No debounce | Wrap your `search` fn with `lodash.debounce` (300ms typical) |
| Voice input fails silently | WebSocket URL wrong / `DEEPGRAM_API_KEY` missing | Check browser DevTools → WS frame; check backend logs |
| Approval card not shown for a mutation | Tool sets `needsApproval: false` explicitly | Either remove the override or set `autoApproveTools` per-user |
| Approval card shown for a benign GET | Tool overrides `needsApproval: true` | GETs default to `false` — drop the override |
| Multiple chat instances on the page | Mounted `` more than once | Render exactly one in your root layout |
| Bundle includes `wavesurfer.js` despite `showMic={false}` | Direct import of voice components elsewhere | Remove any `import` from `glirastes/react/components/recording-bar` etc. |
## What this skill does NOT cover
- **Initial integration** (install, route handler, widget mount) — see [integrating-glirastes-nextjs](../integrating-glirastes-nextjs/SKILL.md) or [integrating-glirastes-nestjs](../integrating-glirastes-nestjs/SKILL.md)
- **Tool authoring** (`uiPattern` / `uiActionOnSuccess` semantics, `actionId` derivation, approval flag) — see [maintaining-glirastes-tools](../maintaining-glirastes-tools/SKILL.md)
- **LangGraph transport differences** — replace `useVercelAiChatTransport` / `VercelAiChat` with `useLangGraphAiChatTransport` / `LangGraphAiChat`; everything else in this skill applies unchanged