28 KiB
Chatwoot React Components - Architecture Documentation
This document provides comprehensive context for the embeddable chat UI components built with React by wrapping Vue components as Web Components.
Table of Contents
- Overview
- Architecture Diagram
- Build System
- Component Hierarchy
- Key Patterns
- Runtime Theme Customization
- Real-time Communication
- File Structure
- Extension Guide
- Known Limitations
Overview
The system enables embedding Chatwoot's conversation UI into any React application. It achieves this through a three-layer architecture:
- React Layer - Provider pattern for configuration and React-friendly API
- Web Component Layer - Vue components converted to Custom Elements via
defineCustomElement - Vue Layer - Existing Chatwoot Vue components reused without modification
Why This Approach?
- Code Reuse: Leverage existing battle-tested Vue components (
components-next/message/*) - Framework Agnostic: Web Components work in any framework (React, Angular, vanilla JS)
- Isolated Styles: Shadow DOM encapsulation prevents CSS conflicts
- Shared State: Single Vuex store instance across all components
Architecture Diagram
┌─────────────────────────────────────────────────────────────────┐
│ React Application │
├─────────────────────────────────────────────────────────────────┤
│ <ChatwootProvider> │
│ ├── Sets up globals (window.__WOOT_*) │
│ ├── Initializes Vuex store │
│ ├── Registers Web Components │
│ └── Initializes ActionCable (WebSocket) │
│ │
│ <ChatwootConversation> │
│ └── <ChatwootMessageListWrapper> │
│ └── <chatwoot-message-list> (Web Component) │
│ └── [Shadow DOM] │
│ └── MessageList.vue │
│ ├── Message.vue (components-next) │
│ ├── TypingIndicator.vue │
│ └── LiteReplyBox.vue │
└─────────────────────────────────────────────────────────────────┘
Build System
Build Modes (vite.config.ts)
The Vite configuration supports multiple build modes via BUILD_MODE environment variable:
| Mode | Command | Output | Purpose |
|---|---|---|---|
library |
pnpm build:sdk |
public/packs/js/sdk.js |
Widget SDK (IIFE) |
ui |
pnpm build:ui |
public/packs/js/ui.js |
Standalone UI (IIFE) |
react-components |
pnpm build:react |
public/packs/react-components.{es,cjs}.js |
NPM package (ES + CJS) |
| (default) | bin/vite build |
Standard Vite output | Main app build |
React Components Build Pipeline
# Full build and package
pnpm package:react
# Build only (no packaging)
pnpm build:react
# Copy mode (skip build, just package)
node scripts/package-react-components.js --copyMode
Build Output (dist/react-components/):
index.js- ES module entryindex.cjs- CommonJS entrystyle.css- Bundled stylespackage.json- Generated with timestamp versioning
Plugin Configuration
// react-components mode plugins
plugins = [
vue({ ...vueOptions, customElement: true }), // Enable CE mode
react() // React JSX transform
];
// Rollup externals - React is peer dependency
rollupOptions = {
external: ['react', 'react-dom'],
};
Component Hierarchy
React Components (src/components/)
ChatwootProvider.jsx
Purpose: Root provider that initializes the entire Chatwoot environment.
Key Responsibilities:
- Validates required props (
baseURL,userToken) - Registers Vue Web Components via
registerVueWebComponents() - Sets up global window variables for Vue components
- Initializes axios with authentication
- Dispatches
setUserto hydrate auth state - Initializes ActionCable for real-time updates
Props:
interface ChatwootProviderProps {
baseURL: string; // Required: Chatwoot API URL
userToken: string; // Required: Agent access token
accountId: number; // Account ID
conversationId: number; // Conversation to display
websocketURL?: string; // WebSocket server URL
pubsubToken?: string; // PubSub authentication token
disableUpload?: boolean; // Disable file attachments
disableEditor?: boolean; // Disable reply editor
disableSignature?: boolean; // Disable signature controls and insertion
signature?: string; // Custom message signature (overrides user profile signature)
}
Global Variables Set:
window.__WOOT_API_HOST__ // API base URL
window.__WOOT_ACCOUNT_ID__ // Account ID
window.__WOOT_ACCESS_TOKEN__ // Auth token (used by axios)
window.__WEBSOCKET_URL__ // WebSocket URL
window.__PUBSUB_TOKEN__ // Real-time auth token
window.__WOOT_CONVERSATION_ID__ // Active conversation
window.__EDITOR_DISABLE_UPLOAD__ // Upload flag
window.__DISABLE_EDITOR__ // Editor flag
window.__WOOT_DISABLE_SIGNATURE__ // Disable signature controls and insertion
window.__WOOT_CUSTOM_SIGNATURE__ // Custom signature (overrides profile)
window.__WOOT_ISOLATED_SHELL__ // Disables audio notifications
window.__CHATWOOT_STORE__ // Vuex store reference
window.WootConstants // Global constants
window.axios // Configured axios instance
ChatwootConversation.jsx
Purpose: High-level wrapper providing styled container.
Pattern: Uses useChatwoot() hook to enforce Provider context.
ChatwootMessageListWrapper.jsx
Purpose: Bridge between React and Web Component.
Key Pattern - Imperative Handle Updates:
// React props → Web Component properties
useEffect(() => {
element.conversationId = conversationId;
}, [conversationId]);
Event Forwarding:
element.addEventListener('chatwoot:loaded', handleLoad);
element.addEventListener('chatwoot:error', handleError);
Web Component Layer (src/vue-components/)
registerWebComponents.js
Purpose: Converts Vue SFCs to Web Components and registers them.
Key Pattern - Style Injection:
const ceOptions = {
configureApp(app) {
app.use(store);
app.use(VueDOMPurifyHTML, domPurifyConfig);
app.use(i18n);
app.provide(I18nInjectionKey, i18n);
vueActionCable.init(store, window.__PUBSUB_TOKEN__);
},
// Styles bundled into Shadow DOM
styles: [chatwootStyles, multiselectStyles, floatingVueStyles, uploadStyles],
};
const ChatwootMessageListElement = defineCustomElement(
ChatwootMessageListWebComponent,
ceOptions
);
Important: Styles are imported with ?inline query to get CSS as string:
import chatwootStyles from '../../../dashboard/assets/scss/app.scss?inline';
ChatwootMessageListWebComponent.vue
Purpose: Thin wrapper that renders the actual MessageList.
Pattern - Additional Styles for Third-Party Components:
<style>
/* vue-upload-component requires these styles */
.file-uploads { /* ... */ }
</style>
Vue Layer (/app/javascript/ui/)
MessageList.vue
Purpose: Core conversation view with infinite scroll.
Key Features:
- Infinite Scroll: Uses
@vueuse/core'suseInfiniteScroll - Typing Indicators: Real-time via
conversationTypingStatusstore module - Message Rendering: Uses
components-next/message/Message.vue - Reply Box: Integrated
LiteReplyBox.vue
Data Flow:
// Gets conversation ID from global
const conversationId = computed(() => window.__WOOT_CONVERSATION_ID__);
// Fetches conversation data on mount
onMounted(async () => {
await store.dispatch('inboxes/get');
await Promise.all([
store.dispatch('getConversation', conversationId.value),
store.dispatch('fetchAllAttachments', conversationId.value),
]);
});
Key Pattern - Store Access Without Vue Instance:
// From composables/store.js
export const useStore = () => {
if (window.__CHATWOOT_STORE__) {
return window.__CHATWOOT_STORE__;
}
// Fallback to Vue instance
const vm = getCurrentInstance();
return vm.proxy.$store;
};
LiteReplyBox.vue
Purpose: Message composition editor.
Key Features:
- Rich text editing via ProseMirror (
WootMessageEditor) - File attachments with preview
- Typing indicators (on/off status)
- Keyboard shortcuts (Cmd/Ctrl+Enter to send)
- Private notes support
Conditional Features via Globals:
isEditorDisabled() {
return window.__DISABLE_EDITOR__;
},
allowFileUpload() {
return window.__EDITOR_DISABLE_UPLOAD__ !== true;
},
axios.js (UI-specific axios factory)
Purpose: Creates axios instance configured for external use.
Pattern - Token from Global:
export default axios => {
const apiHost = window.__WOOT_API_HOST__;
const accessToken = window.__WOOT_ACCESS_TOKEN__;
const wootApi = axios.create({ baseURL: `${apiHost}/` });
Object.assign(wootApi.defaults.headers.common, {
api_access_token: accessToken,
});
return wootApi;
};
Key Patterns
1. Global Variable Bridge
Vue components expect certain globals. The React provider sets these before rendering:
// Set by ChatwootProvider
window.__WOOT_API_HOST__ = config.baseURL;
window.__WOOT_ACCOUNT_ID__ = config.accountId;
// ...etc
// Consumed by Vue components
const accountIdFromRoute = window.__WOOT_ACCOUNT_ID__;
2. Vuex Store as Global Singleton
The store is created once and shared via window.__CHATWOOT_STORE__:
// In ChatwootProvider
import store from '../../../dashboard/store';
window.__CHATWOOT_STORE__ = store;
// In Vue composables
export const useStore = () => {
if (window.__CHATWOOT_STORE__) {
return window.__CHATWOOT_STORE__;
}
// ...
};
3. I18n in Web Components
Vue I18n requires special injection for Web Components:
// Must provide I18nInjectionKey for useI18n() to work in CE mode
app.use(i18n);
app.provide(I18nInjectionKey, i18n);
Reference: https://vue-i18n.intlify.dev/guide/advanced/wc
4. Isolated Shell Mode
The __WOOT_ISOLATED_SHELL__ flag disables features not needed in embedded mode:
// In actionCable.js
if (!window.__WOOT_ISOLATED_SHELL__) {
DashboardAudioNotificationHelper.onNewMessage(data);
}
5. CamelCase Transformation
API returns snake_case, Vue components expect camelCase:
import { useCamelCase } from '../dashboard/composables/useTransformKeys';
const allMessages = computed(() => {
return useCamelCase(conversation.value.messages, { deep: true }).reverse();
});
6. Web Component Event Communication
Web Components emit custom events that React can listen to:
// In Web Component
this.$emit('chatwoot:loaded');
this.$emit('chatwoot:error', { message: 'Error details' });
// In React wrapper
element.addEventListener('chatwoot:loaded', handleLoad);
element.addEventListener('chatwoot:error', handleError);
7. CSS Variable Theming for Bubbles
Message bubble styles use CSS custom properties that can be overridden from outside the Shadow DOM:
// In Base.vue - uses CSS variables for colors
const varaintBaseMap = {
[MESSAGE_VARIANTS.AGENT]: 'bg-[rgb(var(--bubble-agent-bg))] text-[rgb(var(--bubble-agent-text))]',
[MESSAGE_VARIANTS.USER]: 'bg-[rgb(var(--bubble-user-bg))] text-[rgb(var(--bubble-user-text))]',
// ...
};
// Border radius also uses variables
const orientationMap = {
[ORIENTATION.LEFT]: 'rounded-[var(--bubble-radius)] ltr:rounded-bl-[var(--bubble-radius-sm)]...',
// ...
};
Variables are defined in _next-colors.scss and can be overridden via bubble-overrides.css.
Runtime Theme Customization
The Web Component supports runtime theming via CSS custom properties. These variables inherit through the Shadow DOM boundary, allowing consumers to customize bubble appearance without modifying the source.
Available CSS Variables
Colors (RGB values without commas)
| Variable | Description | Default |
|---|---|---|
--chatwoot-bubble-agent-bg |
Agent message background | --solid-blue |
--chatwoot-bubble-agent-text |
Agent message text | --slate-12 |
--chatwoot-bubble-user-bg |
User/customer message background | --slate-4 |
--chatwoot-bubble-user-text |
User message text | --slate-12 |
--chatwoot-bubble-private-bg |
Private note background | --solid-amber |
--chatwoot-bubble-private-text |
Private note text | --amber-12 |
--chatwoot-bubble-bot-bg |
Bot/template message background | --solid-iris |
--chatwoot-bubble-bot-text |
Bot message text | --slate-12 |
--chatwoot-bubble-error-bg |
Error message background | --ruby-4 |
--chatwoot-bubble-error-text |
Error message text | --ruby-12 |
--chatwoot-bubble-unsupported-bg |
Unsupported message background | --amber-4 |
--chatwoot-bubble-unsupported-text |
Unsupported message text | --amber-12 |
Meta (timestamp) - Per Variant
| Variable | Description | Default |
|---|---|---|
--chatwoot-bubble-agent-meta |
Meta text for agent messages | --slate-11 |
--chatwoot-bubble-user-meta |
Meta text for user messages | --slate-11 |
--chatwoot-bubble-private-meta |
Meta text for private notes (50% opacity applied) | --amber-12 |
--chatwoot-bubble-bot-meta |
Meta text for bot messages | --slate-11 |
--chatwoot-bubble-error-meta |
Meta text for error messages | --ruby-11 |
Message Status Icons - Per Variant
| Variable | Description | Default |
|---|---|---|
--chatwoot-bubble-agent-status |
Agent: sent/delivered/progress color | --slate-10 |
--chatwoot-bubble-agent-status-read |
Agent: read receipt color | 126 182 255 |
--chatwoot-bubble-user-status |
User: sent/delivered/progress color | --slate-10 |
--chatwoot-bubble-user-status-read |
User: read receipt color | 126 182 255 |
--chatwoot-bubble-private-status |
Private: sent/delivered/progress color | --amber-11 |
--chatwoot-bubble-private-status-read |
Private: read receipt color | 126 182 255 |
--chatwoot-bubble-bot-status |
Bot: sent/delivered/progress color | --slate-10 |
--chatwoot-bubble-bot-status-read |
Bot: read receipt color | 126 182 255 |
Spacing
| Variable | Description | Default |
|---|---|---|
--chatwoot-bubble-spacing-ratio |
Scale factor for border radius | 1 |
Border
| Variable | Description | Default |
|---|---|---|
--chatwoot-bubble-border-width |
Border width (0 = invisible) | 0 |
--chatwoot-bubble-agent-border |
Agent message border color | --blue-6 |
--chatwoot-bubble-user-border |
User message border color | --slate-6 |
--chatwoot-bubble-private-border |
Private note border color | --amber-6 |
--chatwoot-bubble-bot-border |
Bot message border color | --iris-6 |
--chatwoot-bubble-error-border |
Error message border color | --ruby-6 |
--chatwoot-bubble-unsupported-border |
Unsupported message border color | --amber-12 |
Usage Examples
Option 1: Global CSS
/* In your app's CSS file */
:root {
--chatwoot-bubble-agent-bg: 59 130 246; /* Blue */
--chatwoot-bubble-user-bg: 243 244 246; /* Light gray */
--chatwoot-bubble-spacing-ratio: 1.5; /* 50% more rounded */
--chatwoot-bubble-border-width: 1px; /* Add borders */
--chatwoot-bubble-agent-border: 59 130 246; /* Blue border */
}
Option 2: Wrapper with Inline Styles
function App() {
return (
<div
style={{
'--chatwoot-bubble-agent-bg': '59 130 246',
'--chatwoot-bubble-user-bg': '243 244 246',
'--chatwoot-bubble-spacing-ratio': '1.5',
'--chatwoot-bubble-border-width': '1px',
'--chatwoot-bubble-agent-border': '59 130 246',
}}
>
<ChatwootProvider {...props}>
<ChatwootConversation />
</ChatwootProvider>
</div>
);
}
Option 3: Dynamic Theming
function App() {
const [theme, setTheme] = useState({
'--chatwoot-bubble-agent-bg': '59 130 246',
'--chatwoot-bubble-border-width': '1px',
'--chatwoot-bubble-agent-border': '59 130 246',
});
return (
<div style={theme}>
<button onClick={() => setTheme({
'--chatwoot-bubble-agent-bg': '34 197 94', // Switch to green
'--chatwoot-bubble-border-width': '2px',
'--chatwoot-bubble-agent-border': '34 197 94', // Green border
})}>
Change Theme
</button>
<ChatwootProvider {...props}>
<ChatwootConversation />
</ChatwootProvider>
</div>
);
}
Option 4: Borders Only
function App() {
return (
<div
style={{
'--chatwoot-bubble-border-width': '2px',
'--chatwoot-bubble-agent-border': '59 130 246', // Blue border
'--chatwoot-bubble-user-border': '156 163 175', // Gray border
'--chatwoot-bubble-private-border': '251 191 36', // Amber border
'--chatwoot-bubble-bot-border': '129 140 248', // Iris border
}}
>
<ChatwootProvider {...props}>
<ChatwootConversation />
</ChatwootProvider>
</div>
);
}
How It Works
┌─────────────────────────────────────────────────────────────────┐
│ Consumer's CSS │
│ :root { │
│ --chatwoot-bubble-agent-bg: 59 130 246; │
│ --chatwoot-bubble-border-width: 1px; │
│ } │
└─────────────────────────────────────────────────────────────────┘
│
▼ (CSS inheritance)
┌─────────────────────────────────────────────────────────────────┐
│ Shadow DOM (:host) │
│ bubble-overrides.css: │
│ --bubble-agent-bg: var(--chatwoot-bubble-agent-bg, │
│ var(--solid-blue)); │
│ --bubble-border-width: var(--chatwoot-bubble-border-width, │
│ 0); │
└─────────────────────────────────────────────────────────────────┘
│
▼ (used by components)
┌─────────────────────────────────────────────────────────────────┐
│ Base.vue │
│ class="bg-[rgb(var(--bubble-agent-bg))] │
│ border-[length:var(--bubble-border-width)] │
│ border-[rgb(var(--bubble-agent-border))]" │
└─────────────────────────────────────────────────────────────────┘
Key Files
| File | Purpose |
|---|---|
src/styles/bubble-overrides.css |
Defines :host overrides that bridge external variables |
dashboard/assets/scss/_next-colors.scss |
Default theme values |
dashboard/components-next/message/bubbles/Base.vue |
Consumes CSS variables |
Real-time Communication
ActionCable Setup
// BaseActionCableConnector.js
const websocketURL = websocketHost ? `${websocketHost}/cable` : undefined;
this.consumer = createConsumer(websocketURL);
this.subscription = this.consumer.subscriptions.create({
channel: 'RoomChannel',
pubsub_token: pubsubToken,
account_id: app.$store.getters.getCurrentAccountId,
user_id: app.$store.getters.getCurrentUserID,
});
Events Handled
| Event | Action |
|---|---|
message.created |
Add message to conversation |
message.updated |
Update existing message |
conversation.typing_on |
Show typing indicator |
conversation.typing_off |
Hide typing indicator |
presence.update |
Update online status |
conversation.updated |
Refresh conversation data |
Presence Updates
Heartbeat every 20 seconds:
const PRESENCE_INTERVAL = 20000;
this.triggerPresenceInterval = () => {
setTimeout(() => {
this.subscription.updatePresence();
this.triggerPresenceInterval();
}, PRESENCE_INTERVAL);
};
File Structure
app/javascript/
├── react-components/
│ ├── src/
│ │ ├── index.jsx # Package exports
│ │ ├── components/
│ │ │ ├── ChatwootProvider.jsx # Root provider
│ │ │ ├── ChatwootConversation.jsx # Container component
│ │ │ └── ChatwootMessageListWrapper.jsx # WC bridge
│ │ ├── vue-components/
│ │ │ ├── registerWebComponents.js # WC registration
│ │ │ └── ChatwootMessageListWebComponent.vue
│ │ └── styles/
│ │ ├── upload.css # File upload styles
│ │ └── bubble-overrides.css # Runtime theme customization
│ └── doc.md # This file
│
├── ui/
│ ├── MessageList.vue # Core message list
│ ├── LiteReplyBox.vue # Reply editor
│ ├── axios.js # Axios factory
│ └── usage.md # Usage docs
│
├── dashboard/
│ ├── store/ # Vuex store
│ │ ├── index.js
│ │ └── modules/
│ │ ├── conversations/ # Conversation state
│ │ ├── auth.js # Auth state
│ │ └── conversationTypingStatus.js
│ ├── components-next/
│ │ └── message/ # Message components
│ │ ├── Message.vue
│ │ ├── TypingIndicator.vue
│ │ └── bubbles/
│ ├── composables/
│ │ ├── store.js # Store helpers
│ │ └── useTransformKeys.js # Case conversion
│ ├── helper/
│ │ ├── actionCable.js # WebSocket connector
│ │ └── commons.js # Utility functions
│ └── api/
│ ├── ApiClient.js # Base API client
│ └── auth.js # Auth API
│
└── shared/
└── helpers/
└── BaseActionCableConnector.js # WebSocket base class
scripts/
└── package-react-components.js # NPM packaging script
vite.config.ts # Multi-mode build config
Extension Guide
Adding a New Web Component
- Create Vue Component (
vue-components/NewComponent.vue):
<script setup>
import { useStore, useMapGetter } from 'dashboard/composables/store';
// Use composables that check window.__CHATWOOT_STORE__
</script>
- Register Web Component (
registerWebComponents.js):
import NewComponent from './NewComponent.vue';
const NewElement = defineCustomElement(NewComponent, ceOptions);
export const registerVueWebComponents = () => {
// ...existing registrations
if (!customElements.get('chatwoot-new-component')) {
customElements.define('chatwoot-new-component', NewElement);
}
};
- Create React Wrapper (
components/NewComponentWrapper.jsx):
export const NewComponentWrapper = (props) => {
const elementRef = useRef();
useEffect(() => {
// Sync React props to WC properties
}, [props]);
return <chatwoot-new-component ref={elementRef} />;
};
- Export from index.jsx:
export { NewComponentWrapper } from './components/NewComponentWrapper';
Adding New Global Configuration
- Add prop to
ChatwootProvider.jsx - Set corresponding
window.__WOOT_*variable - Access in Vue components via
window.__WOOT_*
Adding Store Modules
If the new component needs additional Vuex state:
- Ensure module is already in
dashboard/store/index.js - Access via
useStore().dispatch('module/action')oruseMapGetter('module/getter')
Known Limitations
1. Single Conversation Per Page
Currently, only one conversation can be displayed at a time due to global state (__WOOT_CONVERSATION_ID__).
Workaround: For multiple conversations, instantiate separate iframes.
2. Style Isolation
Shadow DOM prevents parent CSS from affecting components, but also means:
- Global CSS resets don't apply
- Most parent styles don't cascade
Solution: Message bubble colors and border radius can be customized via CSS custom properties (see Runtime Theme Customization). CSS custom properties DO inherit through Shadow DOM boundaries.
3. React DevTools
Web Components don't appear in React DevTools component tree. Use browser's native Custom Elements inspector.
4. Hot Module Replacement
Vue components inside Web Components don't support HMR well. Full page reload often needed during development.
5. Bundle Size
The react-components bundle includes:
- Full Vuex store
- All SCSS styles
- I18n messages
Future optimization: Tree-shake unused store modules.
6. Single Language (English Only)
Currently hardcoded to English locale:
const i18n = createI18n({
locale: 'en',
messages: { en },
});
Debugging Tips
Inspect Vuex Store
// In browser console
window.__CHATWOOT_STORE__.state
window.__CHATWOOT_STORE__.getters.getConversationById(123)
Check WebSocket Connection
// ActionCable consumer status
window.__CHATWOOT_STORE__._modules.root._rawModule // Check if actions dispatch
Verify Web Component Registration
customElements.get('chatwoot-message-list') // Should return constructor
Force Re-render
// Dispatch any action to trigger reactivity
window.__CHATWOOT_STORE__.dispatch('setActiveChat', { data: { id: conversationId } });
Related Files
CLAUDE.md- General Chatwoot development guidelinesapp/javascript/ui/usage.md- UI components usage guidevite.config.ts- Build configuration documentation (inline comments)