diff --git a/.gitignore b/.gitignore
index f2ac474..2a5f086 100644
--- a/.gitignore
+++ b/.gitignore
@@ -93,7 +93,6 @@ dist
# vuepress v2.x temp and cache directory
.temp
-.cache
# Sveltekit cache directory
.svelte-kit/
@@ -150,3 +149,5 @@ vite.config.ts.timestamp-*
local.properties
android.iml
*.hprof
+
+.idea/
diff --git a/.idea/prettier.xml b/.idea/prettier.xml
index 0c83ac4..23216a5 100644
--- a/.idea/prettier.xml
+++ b/.idea/prettier.xml
@@ -3,5 +3,6 @@
+
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..8d2261d
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,26 @@
+# Repository Guidelines
+
+## Project Structure & Module Organization
+Code lives in `packages/*-sdk`, each with `src/`, `dist/`, and `tsconfig.*` files so React, React Native, and Node artifacts release independently. `packages/react-sdk` contains web UI and SCSS, `packages/react-native-sdk` mirrors it with native markdown utilities, and `packages/node-sdk` exposes Stream Chat storage helpers for the Vercel AI SDK. Example apps (`examples/nextjs-ai-chatbot`, `examples/react-example`) provide validation and should follow API changes.
+
+## Architecture & Component Highlights
+Key React components include `AIMarkdown`, `StreamingMessage`, and `AIMessageComposer`; `AIMarkdown` maps code blocks to `toolComponents` like `chartjs` for charts. Web and mobile share `@stream-io/state-store`, while RN swaps markdown + charts for `@khanacademy/simple-markdown` and `victory-native`. Node SDK’s `StreamStorage` converts Vercel AI SDK transcripts into Stream Chat channels via helpers like `ai-sdk-helpers.ts`.
+
+## Build, Test, and Development Commands
+- `pnpm install` — install workspace deps from `pnpm-lock.yaml`.
+- `pnpm packages:build:all` / `pnpm examples:build:all` — rebuild SDKs (Vite, `tsc`, Sass, bob) or demos.
+- `pnpm --filter @stream-io/ai-chat-react dev` — watch-build the React SDK; `pnpm --filter ./packages/react-native-sdk start` and `pnpm --filter ./packages/node-sdk dev` serve RN/Node watch modes.
+- `pnpm packages:test:all` or `pnpm --filter ./packages/react-sdk exec vitest run` — run Vitest across the repo or inside one package.
+- `pnpm lint:all` / `pnpm prettier:fix-all` — enforce lint and format rules before pushing.
+
+## Coding Style & Naming Conventions
+TypeScript is mandatory with strict root settings (`strict`, `noUncheckedIndexedAccess`, `verbatimModuleSyntax`). Prettier (two-space indent, single quotes) and ESLint guardrails (`eqeqeq`, sorted imports, `@typescript-eslint/consistent-type-imports`, Hooks rules) must pass before committing. Name components in PascalCase (`StreamingMessage.tsx`), prefix hooks with `use`, keep barrel exports in each `index.ts`, and model shared contracts in `types.ts`.
+
+## Testing Guidelines
+Vitest powers all suites. Place specs under `src/__tests__` with the `.test.ts` suffix and mirror the production folder layout. Exercise new branches (charting, composer state, storage conversions). Use `pnpm --filter ./packages/react-native-sdk exec vitest run` or package-specific commands before opening a PR.
+
+## Commit & Pull Request Guidelines
+Follow `feat(scope): summary (#PR)` like the existing history, keep commits scoped, and add a Changeset when a package surface changes. Pull requests need a problem statement, solution summary, proof of testing (logs or screenshots), and links to relevant issues/Linear tickets. UI-focused PRs should attach before/after visuals.
+
+## Security & Publishing Tips
+Keep `"private": true` in each `package.json` until release day, store credentials in ignored `.env.local` files, and scrub secrets from `examples/*` before pushing. Run `pnpm ci:publish` only after confirming demos leak no tokens and sensitive assets load from secure URLs.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..6edfc50
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,188 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+This is a monorepo for Stream.io's AI component SDKs across multiple platforms (React, React Native, and Node.js). The project provides UI components and utilities for building AI chat interfaces with streaming messages, markdown rendering with syntax highlighting, charts/graphs, and message composition with file attachments.
+
+## Monorepo Structure
+
+This is a pnpm workspace with three main packages and example applications:
+
+- `packages/react-sdk` - React web components for AI chat interfaces
+- `packages/react-native-sdk` - React Native components for mobile AI chat
+- `packages/node-sdk` - Node.js storage connector using Stream Chat infrastructure (integrates with Vercel AI SDK)
+- `examples/react-example` - Web example application
+- `examples/nextjs-ai-chatbot` - Next.js chatbot example
+
+## Common Commands
+
+### Development
+
+```bash
+# Install dependencies
+pnpm install
+
+# Build all packages
+pnpm packages:build:all
+
+# Build all examples
+pnpm examples:build:all
+
+# Test all packages
+pnpm packages:test:all
+
+# Test a specific package
+pnpm --filter @stream-io/ai-chat-react test
+pnpm --filter ./packages/react-native-sdk test
+
+# Lint everything
+pnpm lint:all
+
+# Format code
+pnpm prettier:fix-all
+```
+
+### Running Examples
+
+```bash
+# React example (Vite dev server)
+cd examples/react-example
+pnpm dev
+
+# Next.js chatbot example
+cd examples/nextjs-ai-chatbot
+pnpm dev
+```
+
+### Package-Specific Development
+
+Navigate to the package directory for targeted work:
+
+```bash
+# React SDK
+cd packages/react-sdk
+pnpm build # Build JS + types + styles
+pnpm dev # Watch mode for development
+pnpm build:styles # Compile SCSS to CSS
+
+# React Native SDK
+cd packages/react-native-sdk
+pnpm build # Build with react-native-builder-bob
+pnpm start # TypeScript watch mode
+pnpm test # Run tests with vitest
+
+# Node SDK
+cd packages/node-sdk
+pnpm build # Compile TypeScript
+pnpm dev # TypeScript watch mode
+```
+
+### Publishing
+
+This repo uses Changesets for version management:
+
+```bash
+# Create a changeset (do this when making user-facing changes)
+pnpm changeset
+
+# Publish packages (automated via CI)
+pnpm ci:publish
+```
+
+## Architecture Notes
+
+### React SDK (`packages/react-sdk`)
+
+**Main Components:**
+- `AIMarkdown` - Markdown renderer with GFM support, syntax highlighting (Prism), and extensible tool components
+- `StreamingMessage` - Typewriter-style text streaming with configurable speed
+- `AIMessageComposer` - Message input with file attachments, speech-to-text, and state management via `@stream-io/state-store`
+
+**Tool Component System:**
+The `AIMarkdown` component supports custom "tool" renderers via the `toolComponents` prop. Tools are registered by language identifier (e.g., `chartjs`) and can render custom visualizations within markdown code blocks. Built-in tools include Chart.js integration via `SuspendedChart`.
+
+**Build System:**
+- Uses Vite for bundling (dual format: ESM + CJS)
+- TypeScript for type definitions
+- SCSS compiled to CSS (distributed as separate files in `dist/styles/`)
+- Exports: main entry + `/stream` subpath + `/styles/*` for CSS
+
+### React Native SDK (`packages/react-native-sdk`)
+
+**Key Differences from React SDK:**
+- Uses `@khanacademy/simple-markdown` instead of `react-markdown` for markdown parsing
+- Charts powered by `victory-native` (React Native/Skia-based)
+- Built with `react-native-builder-bob` (CommonJS + ESM + TypeScript)
+- Custom markdown components in `src/markdown/`
+
+**Native Components:**
+- `MarkdownReactiveScrollView` - Performance-optimized markdown scrolling
+- `PerfText` - Text rendering optimization
+- Syntax highlighting via `react-syntax-highlighter` (shared with web)
+
+### Node SDK (`packages/node-sdk`)
+
+**Purpose:**
+Storage adapter that bridges Vercel AI SDK with Stream Chat backend. Enables persisting AI conversations using Stream's infrastructure.
+
+**Core Classes:**
+- `StreamStorage` - Main class for channel/message management
+- `ai-sdk-helpers.ts` - Utilities for converting between AI SDK message format and Stream Chat format
+- Supports streaming responses from AI SDK back to Stream Chat
+
+### Shared Patterns
+
+**State Management:**
+Both React and React Native SDKs use `@stream-io/state-store` for component state (e.g., message composer state).
+
+**Markdown Rendering:**
+- Web: `react-markdown` + `remark-gfm`
+- Mobile: `@khanacademy/simple-markdown` (custom port)
+- Both: `react-syntax-highlighter` for code blocks
+
+**Styling:**
+- React SDK: SCSS files compiled to CSS, consumers import from `@stream-io/ai-chat-react/styles/*`
+- React Native: StyleSheet-based, no external styles
+
+## TypeScript Configuration
+
+The monorepo uses a shared `tsconfig.root.json` with strict settings:
+- `strict: true`
+- `noUncheckedIndexedAccess: true`
+- `noImplicitOverride: true`
+- `verbatimModuleSyntax: true`
+
+Individual packages extend this config.
+
+## Dependency Management
+
+This monorepo uses pnpm workspaces with a catalog system (defined in `pnpm-workspace.yaml`). Common dependencies like `typescript`, `vite`, and `vitest` are pinned in the catalog to ensure version consistency across packages. Reference catalog versions with `"dependency": "catalog:"` in package.json files.
+
+## Testing
+
+Tests use Vitest (configured via `pnpm-workspace.yaml` catalog). Run tests from package directories or use `pnpm packages:test:all`.
+
+## CI/CD
+
+GitHub Actions workflow (`.github/workflows/ci.yml`) runs on PRs:
+1. Install dependencies with pnpm
+2. Run `pnpm packages:test:all`
+
+Changesets workflow handles automated publishing.
+
+## Publishing and Privacy
+
+**IMPORTANT:** All packages are currently marked as `"private": true` in their package.json files to prevent accidental publishing. Before release, ensure:
+- Package privacy settings are updated appropriately
+- No sensitive credentials or API keys exist in example apps
+- Example apps remain private if they contain demo tokens
+
+## Additional Guidelines
+
+See `AGENTS.md` for additional repository conventions including:
+- Coding style and naming conventions (PascalCase components, `use` prefix for hooks)
+- Commit message format (`feat(scope): summary (#PR)`)
+- Pull request requirements (problem statement, testing proof, visuals for UI changes)
+- Security best practices for credentials and environment variables
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 8d99876..074a2c9 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -34,7 +34,6 @@ export default tseslint.config(
'array-callback-return': 'error',
'arrow-body-style': 'off',
'comma-dangle': 'off',
- 'jsx-quotes': ['error', 'prefer-single'],
'linebreak-style': ['error', 'unix'],
'no-console': 'off',
'no-mixed-spaces-and-tabs': 'warn',
@@ -48,16 +47,6 @@ export default tseslint.config(
'object-shorthand': 'warn',
'prefer-const': 'warn',
'require-await': 'off',
- 'sort-imports': [
- 'error',
- {
- allowSeparatedGroups: true,
- ignoreCase: true,
- ignoreDeclarationSort: true,
- ignoreMemberSort: false,
- memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'],
- },
- ],
'sort-keys': 'off',
'valid-typeof': 'error',
'max-classes-per-file': 'off',
diff --git a/examples/react-example/implementation.md b/examples/react-example/implementation.md
new file mode 100644
index 0000000..c2a7382
--- /dev/null
+++ b/examples/react-example/implementation.md
@@ -0,0 +1,322 @@
+# Implementation: ChatGPT 4/Plus UI Redesign
+
+## ✅ Completed Features
+
+### Design Goals
+
+- ✅ Match ChatGPT 4/Plus UI
+- ✅ Component-based architecture with isolated SCSS modules
+- ✅ Light/dark theme toggle with localStorage persistence
+- ✅ Mobile-responsive navigation with top bar
+- ✅ Bookmarkable conversations with URL parameters
+- ✅ Browser back/forward button support
+- ✅ Override Stream Chat React styles via CSS variables
+
+---
+
+## Component Structure
+
+```
+src/
+├── components/
+│ ├── AIChatApp/
+│ │ ├── AIChatApp.tsx # ✅ Main app wrapper with URL state management
+│ │ └── AIChatApp.scss # ✅ App layout styles with responsive grid
+│ ├── Sidebar/
+│ │ ├── Sidebar.tsx # ✅ Channel list sidebar with collapse
+│ │ ├── Sidebar.scss # ✅ Dark/light sidebar styles
+│ │ ├── SidebarHeader.tsx # ✅ New chat button + logo
+│ │ ├── SidebarHeader.scss
+│ │ ├── SidebarFooter.tsx # ✅ Theme toggle (desktop)
+│ │ ├── SidebarFooter.scss
+│ │ ├── ChannelPreviewItem.tsx # ✅ Custom channel preview
+│ │ └── ChannelPreviewItem.scss
+│ ├── TopNavBar/
+│ │ ├── TopNavBar.tsx # ✅ Mobile navigation with hamburger + theme toggle
+│ │ └── TopNavBar.scss
+│ ├── ChatContainer/
+│ │ ├── ChatContainer.tsx # ✅ Main chat area wrapper
+│ │ └── ChatContainer.scss # ✅ Flexbox layout with overflow management
+│ ├── MessageBubble/
+│ │ ├── MessageBubble.tsx # ✅ Custom message component
+│ │ └── MessageBubble.scss # ✅ ChatGPT-style bubbles with dynamic sizing
+│ ├── MessageInputBar/
+│ │ ├── MessageInputBar.tsx # ✅ AIMessageComposer wrapper
+│ │ └── MessageInputBar.scss # ✅ Theme-aware input & model selector
+│ ├── AIStateIndicator/
+│ │ ├── AIStateIndicator.tsx # ✅ Custom thinking indicator with random messages
+│ │ └── AIStateIndicator.scss
+│ └── EmptyState/
+│ ├── EmptyState.tsx # ✅ Empty placeholder
+│ └── EmptyState.scss # ✅ Theme-aware styling
+├── contexts/
+│ └── ThemeContext.tsx # ✅ Theme state with localStorage persistence
+├── Root.tsx # ✅ Thin wrapper, imports AIChatApp
+├── index.scss # ✅ Global styles + CSS variables for both themes
+└── ai-demo.scss # ✅ Stream Chat overrides
+```
+
+---
+
+## Color Scheme
+
+**CSS Variables in `index.scss`:**
+
+```scss
+:root,
+:root[data-theme='dark'] {
+ /* Dark theme colors matching ChatGPT 4 */
+ --ai-demo-bg-primary: #212121; // Main background
+ --ai-demo-bg-secondary: #171717; // Sidebar background
+ --ai-demo-bg-tertiary: #2f2f2f; // Hover states & inputs
+ --ai-demo-border: #4a4a4a; // Borders
+
+ /* Text colors */
+ --ai-demo-text-primary: #ececec; // Main text
+ --ai-demo-text-secondary: #b4b4b4; // Secondary text
+ --ai-demo-text-tertiary: #8e8e8e; // Muted text
+
+ /* Message colors */
+ --ai-demo-user-bg: #2f2f2f; // User message background
+ --ai-demo-ai-bg: #2f2f2f; // AI message background
+
+ /* Accent colors */
+ --ai-demo-accent: #10a37f; // ChatGPT green
+ --ai-demo-accent-hover: #0d8968;
+
+ /* Font */
+ --ai-demo-font-family:
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica',
+ 'Arial', sans-serif;
+}
+
+:root[data-theme='light'] {
+ /* Light theme colors */
+ --ai-demo-bg-primary: #ffffff;
+ --ai-demo-bg-secondary: #f7f7f8;
+ --ai-demo-bg-tertiary: #ececf1;
+ --ai-demo-border: #d9d9e3;
+
+ --ai-demo-text-primary: #353740;
+ --ai-demo-text-secondary: #565869;
+ --ai-demo-text-tertiary: #8e8ea0;
+
+ --ai-demo-user-bg: #ececf1;
+ --ai-demo-ai-bg: #ececf1;
+
+ --ai-demo-accent: #10a37f;
+ --ai-demo-accent-hover: #0d8968;
+}
+```
+
+---
+
+## Component Details
+
+### 1. **AIChatApp.tsx**
+
+✅ Implemented features:
+
+- Sets up `Chat` provider with `isMessageAIGenerated`
+- Manages sidebar collapse state (mobile + desktop)
+- Wraps app in `ThemeProvider` for theme context
+- **URL State Management**: Updates URL with `?conversation_id=` when switching channels
+- **Browser Navigation**: Handles popstate events for back/forward button support
+- Layout: CSS Grid with collapsible sidebar
+- Renders `TopNavBar` (mobile) + `Sidebar` + `ChatContainer`
+
+### 2. **Sidebar**
+
+✅ Implemented features:
+
+- Theme-aware sidebar with `--ai-demo-bg-secondary`
+- Contains `SidebarHeader`, `ChannelList`, and `SidebarFooter`
+- Mobile: Overlay with backdrop, toggle via TopNavBar hamburger
+- Desktop: Fixed sidebar with theme toggle in footer
+- Width: 260px (desktop), full-width overlay (mobile)
+- Smooth transitions for collapse/expand
+
+### 3. **SidebarHeader**
+
+✅ Implemented features:
+
+- "New Chat" button with Material Symbols icon
+- Creates new channel with `ai-${nanoId()}` pattern
+- Styled as button with hover effect
+- ChatGPT logo/branding area
+
+### 4. **SidebarFooter**
+
+✅ Implemented features:
+
+- Theme toggle button (desktop only)
+- Material Symbols icons (light_mode/dark_mode)
+- Smooth transitions
+
+### 5. **TopNavBar**
+
+✅ Implemented features:
+
+- Mobile navigation bar with hamburger menu
+- Shows current conversation title (with ellipsis for long titles)
+- Theme toggle button (mobile)
+- Material Symbols rounded icons
+- Fixed positioning at top
+
+### 6. **ChannelPreviewItem**
+
+✅ Implemented features:
+
+- Shows `channel.data?.summary ?? channel.id`
+- Hover background: `--ai-demo-bg-tertiary`
+- Active state: background color change
+- Truncate long text with ellipsis
+- Theme-aware colors
+
+### 7. **ChatContainer**
+
+✅ Implemented features:
+
+- Wraps `Channel` component
+- Contains `Window` with `MessageList`, `AIStateIndicator`, `MessageInputBar`
+- Max-width: 900px, centered
+- **Flexbox layout with overflow management** to ensure message list scrolls while keeping indicator and input visible
+- Mobile-responsive with top padding for TopNavBar
+
+### 8. **MessageBubble**
+
+✅ Implemented features:
+
+- User messages: right-aligned with `--ai-demo-user-bg`, rounded bubbles (18px)
+- AI messages: left-aligned with `--ai-demo-ai-bg`, rounded bubbles (18px)
+- **Dynamic sizing**: bubbles grow with content
+- Compact padding (0.5rem 0.75rem) matching ChatGPT
+- Min-width: 50% for AI messages (better streaming UX)
+- Max-width: 70% (user), 80% (AI)
+- Avatar hidden
+
+### 9. **AIStateIndicator**
+
+✅ Implemented features:
+
+- Custom thinking indicator with animated dots
+- **Random cheesy messages**: "Consulting the AI gods", "Brewing up an answer", etc.
+- Messages change every 2 seconds during thinking state
+- Theme-aware styling
+
+### 10. **MessageInputBar**
+
+✅ Implemented features:
+
+- Wraps `AIMessageComposer`
+- Rounded input box (1.5rem border-radius)
+- **Theme-aware model selector**: overrides `.aicr__ai-message-composer__select`
+- Theme-aware input field and submit button
+- Focus states with accent color
+- Responsive padding
+
+### 11. **EmptyState**
+
+✅ Implemented features:
+
+- Centered content with welcome message
+- Auto-creates channel (existing behavior preserved)
+- Theme-aware styling with CSS variables
+- Material Symbols icon
+
+### 12. **ThemeContext**
+
+✅ Implemented features:
+
+- React Context for theme state ('light' | 'dark')
+- localStorage persistence with key 'ai-demo-theme'
+- Respects system preference (prefers-color-scheme) on first load
+- Sets `data-theme` attribute on document root
+- `useTheme` hook for accessing theme state and toggle function
+
+---
+
+## Stream Chat React CSS Variable Overrides
+
+Implemented in `ai-demo.scss`:
+
+```scss
+:root {
+ /* Override Stream Chat React variables */
+ --str-chat__primary-color: var(--ai-demo-accent);
+ --str-chat__background-color: var(--ai-demo-bg-primary);
+ --str-chat__channel-background-color: var(--ai-demo-bg-primary);
+ --str-chat__secondary-background-color: var(--ai-demo-bg-secondary);
+ --str-chat__border-color: var(--ai-demo-border);
+ --str-chat__text-color: var(--ai-demo-text-primary);
+ --str-chat__text-low-emphasis-color: var(--ai-demo-text-secondary);
+
+ /* Channel list overrides */
+ --str-chat__channel-preview-hover-background: var(--ai-demo-bg-tertiary);
+ --str-chat__channel-preview-active-background: var(--ai-demo-bg-tertiary);
+
+ /* Message overrides */
+ --str-chat__message-bubble-color: var(--ai-demo-user-bg);
+ --str-chat__font-family: var(--ai-demo-font-family);
+}
+```
+
+---
+
+## Mobile Responsiveness
+
+✅ **Breakpoint:** 768px
+
+- ✅ Sidebar overlay with backdrop on mobile
+- ✅ TopNavBar with hamburger menu, conversation title, and theme toggle
+- ✅ ChatContainer takes full width with top padding for fixed TopNavBar
+- ✅ Responsive padding and spacing throughout
+- ✅ Smooth transitions for sidebar collapse/expand
+- ✅ Touch-friendly button sizes
+
+---
+
+## Technical Highlights
+
+### URL State Management
+
+The app uses `window.history.pushState()` to update the URL when switching conversations, and listens to `popstate` events to handle browser back/forward navigation. This makes conversations shareable and bookmarkable.
+
+### Theme System
+
+Theme state is managed via React Context and persisted to localStorage. The theme is applied by setting a `data-theme` attribute on the document root, which allows CSS variables to be scoped appropriately.
+
+### Flexbox Overflow Management
+
+The ChatContainer uses a careful flexbox layout to ensure the message list scrolls while the AIStateIndicator and MessageInputBar remain visible:
+
+### Dynamic Message Bubble Sizing
+
+Message bubbles grow dynamically based on content, with different constraints for user vs AI messages:
+
+### SDK Style Overrides
+
+To properly override the AI Message Composer styles, we target the correct class (`.aicr__ai-message-composer__select`) which has `all: unset` and `background-color: transparent` set by default:
+
+---
+
+## SCSS Architecture
+
+Each component has its own isolated SCSS file:
+
+```tsx
+import './ComponentName.scss';
+```
+
+**Global styles** in `index.scss`:
+
+- CSS variables for both themes
+- CSS layers
+- Reset styles
+- Font imports (Material Symbols Rounded)
+
+**Stream Chat overrides** in `ai-demo.scss` (imported in `index.scss`)
+
+**Component styles** follow BEM-like naming: `ai-demo-component__element--modifier`
+
+---
diff --git a/examples/react-example/index.html b/examples/react-example/index.html
index 9290ef5..671a930 100644
--- a/examples/react-example/index.html
+++ b/examples/react-example/index.html
@@ -4,7 +4,7 @@
-
react-example
+ Stream AI Chat Bot
diff --git a/examples/react-example/src/Root.tsx b/examples/react-example/src/Root.tsx
index eeb6d7b..6fd095a 100644
--- a/examples/react-example/src/Root.tsx
+++ b/examples/react-example/src/Root.tsx
@@ -1,36 +1,10 @@
-import { AIMessageComposer, StreamingMessage } from '@stream-io/ai-chat-react';
import type {
ChannelFilters,
ChannelOptions,
ChannelSort,
- LocalMessage,
} from 'stream-chat';
-import {
- AIStateIndicator,
- Channel,
- ChannelList,
- Chat,
- useCreateChatClient,
- MessageList,
- Window,
- type ChannelPreviewProps,
- ChannelPreview,
- useChannelStateContext,
- useChatContext,
- MessageInput,
- useChannelActionContext,
- useMessageComposer,
- useMessageContext,
- Attachment,
- messageHasAttachments,
- MessageErrorIcon,
-} from 'stream-chat-react';
-
-import { customAlphabet } from 'nanoid';
-import { useEffect, useMemo } from 'react';
-import clsx from 'clsx';
-
-const nanoId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 10);
+import { AIChatApp } from './components/AIChatApp';
+import { ThemeProvider } from './contexts/ThemeContext';
const params = new Proxy(new URLSearchParams(window.location.search), {
get: (searchParams, property) => searchParams.get(property as string),
@@ -47,6 +21,7 @@ const parseUserIdFromToken = (token: string) => {
const apiKey = params.key ?? (import.meta.env.VITE_STREAM_KEY as string);
const userToken = params.ut ?? (import.meta.env.VITE_USER_TOKEN as string);
const userId = parseUserIdFromToken(userToken);
+const conversationId = params.conversation_id ?? undefined;
const filters: ChannelFilters = {
members: { $in: [userId] },
@@ -56,188 +31,19 @@ const filters: ChannelFilters = {
const options: ChannelOptions = { limit: 5, presence: true, state: true };
const sort: ChannelSort = { pinned_at: 1, last_message_at: -1, updated_at: -1 };
-// @ts-ignore
-const isMessageAIGenerated = (message: LocalMessage) => !!message?.ai_generated;
-
-const InputComponent = () => {
- const { updateMessage, sendMessage } = useChannelActionContext();
- const { channel } = useChannelStateContext();
- const composer = useMessageComposer();
-
- return (
- {
- const event = e;
- const target = (event.currentTarget ??
- event.target) as HTMLFormElement | null;
- event.preventDefault();
-
- const formData = new FormData(event.currentTarget);
-
- const t = formData.get('message');
- const model = formData.get('model');
-
- composer.textComposer.setText(t as string);
-
- const d = await composer.compose();
-
- if (!d) return;
-
- target?.reset();
- composer.clear();
-
- if (channel.initialized) {
- await sendMessage(d);
- } else {
- updateMessage(d?.localMessage);
-
- await channel.watch();
-
- // TODO: wrap in retry (in case channel creation takes longer)
- await fetch('http://localhost:3000/start-ai-agent', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- channel_id: channel.id,
- channel_type: channel.type,
- platform: 'openai',
- model: model,
- }),
- });
-
- await sendMessage(d);
- }
- }}
- />
- );
-};
-
-const CustomPreview = (p: ChannelPreviewProps) => {
- const { setActiveChannel } = useChatContext();
- return (
- setActiveChannel(p.channel)}>
- {/* @ts-expect-error */}
- {p.channel.data.summary ?? p.channel.id}
-
- );
-};
-
-const EmptyPlaceholder = () => {
- const { channel, setActiveChannel, client } = useChatContext();
-
- useEffect(() => {
- if (!channel) {
- setActiveChannel(
- client.channel('messaging', `ai-${nanoId()}`, {
- members: [client.userID as string],
- }),
- );
- }
- }, [channel, client, setActiveChannel]);
-
- return (
-
-
- Start a conversation!
-
-
- );
-};
-
-const CustomMessage = () => {
- const { message, isMyMessage, highlighted, handleAction } =
- useMessageContext();
-
- const hasAttachment = messageHasAttachments(message);
- const finalAttachments = useMemo(
- () =>
- !message.shared_location && !message.attachments
- ? []
- : !message.shared_location
- ? message.attachments
- : [message.shared_location, ...(message.attachments ?? [])],
- [message],
- );
-
- const rootClassName = clsx(
- 'str-chat__message str-chat__message-simple',
- `str-chat__message--${message.type}`,
- `str-chat__message--${message.status}`,
- {
- 'str-chat__message--me str-chat__message-simple--me': isMyMessage(),
- 'str-chat__message--other': !isMyMessage(),
- 'str-chat__message--has-text': !!message.text,
- 'has-no-text': !message.text,
- 'str-chat__message--has-attachment': hasAttachment,
- 'str-chat__message--highlighted': highlighted,
- 'str-chat__message-send-can-be-retried':
- message?.status === 'failed' && message?.error?.status !== 403,
- },
- );
-
- return (
-
-
-
- {finalAttachments?.length ? (
-
- ) : null}
-
-
-
-
-
-
-
- );
-};
-
const App = () => {
- const chatClient = useCreateChatClient({
- apiKey,
- tokenOrProvider: userToken,
- userData: { id: userId },
- });
-
- if (!chatClient) return <>Loading...>;
-
return (
-
- (
-
- )}
+
+
- }
- Message={CustomMessage}
- >
-
-
-
-
-
-
-
-
- {/* */}
-
+
);
};
diff --git a/examples/react-example/src/ai-demo.scss b/examples/react-example/src/ai-demo.scss
new file mode 100644
index 0000000..40b523d
--- /dev/null
+++ b/examples/react-example/src/ai-demo.scss
@@ -0,0 +1,46 @@
+/* Stream Chat React CSS Variable Overrides for AI Demo */
+
+:root {
+ /* Override Stream Chat React variables */
+ --str-chat__primary-color: var(--ai-demo-accent);
+ --str-chat__background-color: var(--ai-demo-bg-primary);
+ --str-chat__secondary-background-color: var(--ai-demo-bg-secondary);
+ --str-chat__border-color: var(--ai-demo-border);
+ --str-chat__text-color: var(--ai-demo-text-primary);
+ --str-chat__text-low-emphasis-color: var(--ai-demo-text-secondary);
+
+ /* Channel overrides */
+ --str-chat__channel-background-color: var(--ai-demo-bg-primary);
+ --str-chat__channel-preview-hover-background: var(--ai-demo-bg-tertiary);
+ --str-chat__channel-preview-active-background: var(--ai-demo-bg-tertiary);
+
+ /* Message overrides */
+ --str-chat__message-bubble-color: var(--ai-demo-user-bg);
+ --str-chat__font-family: var(--ai-demo-font-family);
+}
+
+/* Additional Stream Chat component overrides */
+.str-chat {
+ background-color: var(--ai-demo-bg-primary);
+
+ &__channel-list {
+ background-color: var(--ai-demo-bg-secondary);
+ border-right: 1px solid var(--ai-demo-border);
+ }
+
+ &__empty-channel {
+ background-color: var(--ai-demo-bg-primary);
+ }
+
+ &__channel {
+ background-color: var(--ai-demo-bg-primary);
+ }
+
+ &__message-list {
+ background-color: var(--ai-demo-bg-primary);
+ }
+
+ &__input {
+ background-color: var(--ai-demo-bg-primary);
+ }
+}
diff --git a/examples/react-example/src/components/AIChatApp/AIChatApp.scss b/examples/react-example/src/components/AIChatApp/AIChatApp.scss
new file mode 100644
index 0000000..61f8400
--- /dev/null
+++ b/examples/react-example/src/components/AIChatApp/AIChatApp.scss
@@ -0,0 +1,7 @@
+.ai-demo-app {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ background-color: var(--ai-demo-bg-primary);
+ overflow: hidden;
+}
diff --git a/examples/react-example/src/components/AIChatApp/AIChatApp.tsx b/examples/react-example/src/components/AIChatApp/AIChatApp.tsx
new file mode 100644
index 0000000..141022c
--- /dev/null
+++ b/examples/react-example/src/components/AIChatApp/AIChatApp.tsx
@@ -0,0 +1,130 @@
+import { useState, useEffect } from 'react';
+import type {
+ ChannelFilters,
+ ChannelOptions,
+ ChannelSort,
+ LocalMessage,
+} from 'stream-chat';
+import { Chat, useCreateChatClient, useChatContext } from 'stream-chat-react';
+import { Sidebar } from '../Sidebar';
+import { ChatContainer } from '../ChatContainer';
+import './AIChatApp.scss';
+
+interface AIChatAppProps {
+ apiKey: string;
+ userToken: string;
+ userId: string;
+ filters: ChannelFilters;
+ options: ChannelOptions;
+ sort: ChannelSort;
+ initialChannelId?: string;
+}
+
+const isMessageAIGenerated = (message: LocalMessage) => !!message?.ai_generated;
+
+const ChatContent = ({
+ filters,
+ options,
+ sort,
+ initialChannelId,
+}: {
+ filters: ChannelFilters;
+ options: ChannelOptions;
+ sort: ChannelSort;
+ initialChannelId?: string;
+}) => {
+ const [isSidebarOpen, setIsSidebarOpen] = useState(false);
+ const { client, channel, setActiveChannel } = useChatContext();
+
+ // Update URL when channel changes
+ useEffect(() => {
+ if (channel?.id) {
+ const url = new URL(window.location.href);
+ const currentConversationId = url.searchParams.get('conversation_id');
+
+ // Only push if the conversation_id actually changed
+ if (currentConversationId !== channel.id) {
+ url.searchParams.set('conversation_id', channel.id);
+ window.history.pushState({}, '', url.toString());
+ }
+ }
+ }, [channel?.id]);
+
+ // Load initial channel from URL on mount
+ useEffect(() => {
+ if (initialChannelId && client && !channel) {
+ const loadChannel = async () => {
+ const targetChannel = client.channel('messaging', initialChannelId);
+ await targetChannel.watch();
+ setActiveChannel(targetChannel);
+ };
+ loadChannel().catch((err) => {
+ console.error('Failed to load channel', err);
+ });
+ }
+ }, [initialChannelId, client, channel, setActiveChannel]);
+
+ // Handle browser back/forward navigation
+ useEffect(() => {
+ const handlePopState = async () => {
+ const url = new URL(window.location.href);
+ const conversationId = url.searchParams.get('conversation_id');
+
+ if (conversationId && client && conversationId !== channel?.id) {
+ const targetChannel = client.channel('messaging', conversationId);
+ await targetChannel.watch();
+ setActiveChannel(targetChannel);
+ }
+ };
+
+ window.addEventListener('popstate', handlePopState);
+ return () => window.removeEventListener('popstate', handlePopState);
+ }, [client, channel?.id, setActiveChannel]);
+
+ const toggleSidebar = () => setIsSidebarOpen((prev) => !prev);
+ const closeSidebar = () => setIsSidebarOpen(false);
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export const AIChatApp = ({
+ apiKey,
+ userToken,
+ userId,
+ filters,
+ options,
+ sort,
+ initialChannelId,
+}: AIChatAppProps) => {
+ const chatClient = useCreateChatClient({
+ apiKey,
+ tokenOrProvider: userToken,
+ userData: { id: userId },
+ });
+
+ if (!chatClient) return <>Loading...>;
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/examples/react-example/src/components/AIChatApp/index.ts b/examples/react-example/src/components/AIChatApp/index.ts
new file mode 100644
index 0000000..45ec40b
--- /dev/null
+++ b/examples/react-example/src/components/AIChatApp/index.ts
@@ -0,0 +1 @@
+export { AIChatApp } from './AIChatApp';
diff --git a/examples/react-example/src/components/AIStateIndicator/AIStateIndicator.scss b/examples/react-example/src/components/AIStateIndicator/AIStateIndicator.scss
new file mode 100644
index 0000000..a8a65bd
--- /dev/null
+++ b/examples/react-example/src/components/AIStateIndicator/AIStateIndicator.scss
@@ -0,0 +1,86 @@
+.ai-demo-state-indicator {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem 0;
+ background-color: var(--ai-demo-bg-primary);
+
+ &__content {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.5rem 0.75rem;
+ background-color: var(--ai-demo-bg-tertiary);
+ border-radius: 18px;
+ max-width: 80%;
+ }
+
+ &__dots {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ }
+
+ &__dot {
+ width: 8px;
+ height: 8px;
+ background-color: var(--ai-demo-text-secondary);
+ border-radius: 50%;
+ animation: ai-demo-thinking 1.4s ease-in-out infinite;
+
+ &:nth-child(1) {
+ animation-delay: 0s;
+ }
+
+ &:nth-child(2) {
+ animation-delay: 0.2s;
+ }
+
+ &:nth-child(3) {
+ animation-delay: 0.4s;
+ }
+ }
+
+ &__text {
+ color: var(--ai-demo-text-secondary);
+ font-size: 0.875rem;
+ font-weight: 500;
+ animation: ai-demo-text-fade 2s ease-in-out infinite;
+ }
+}
+
+@keyframes ai-demo-thinking {
+ 0%, 60%, 100% {
+ transform: scale(1);
+ opacity: 0.4;
+ }
+ 30% {
+ transform: scale(1.4);
+ opacity: 1;
+ }
+}
+
+@keyframes ai-demo-text-fade {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 45%, 55% {
+ opacity: 0.5;
+ }
+}
+
+/* Mobile responsiveness */
+@media (max-width: 768px) {
+ .ai-demo-state-indicator {
+ padding: 0.75rem 0;
+
+ &__content {
+ gap: 0.5rem;
+ padding: 0.375rem 0.625rem;
+ }
+
+ &__text {
+ font-size: 0.8125rem;
+ }
+ }
+}
diff --git a/examples/react-example/src/components/AIStateIndicator/AIStateIndicator.tsx b/examples/react-example/src/components/AIStateIndicator/AIStateIndicator.tsx
new file mode 100644
index 0000000..bbf7312
--- /dev/null
+++ b/examples/react-example/src/components/AIStateIndicator/AIStateIndicator.tsx
@@ -0,0 +1,50 @@
+import { useMemo } from 'react';
+import {
+ AIStates,
+ useAIState,
+ useChannelStateContext,
+} from 'stream-chat-react';
+import './AIStateIndicator.scss';
+
+const MESSAGES = [
+ 'Thinking really hard',
+ 'Putting on my thinking cap',
+ 'Consulting the AI gods',
+ 'Brewing up an answer',
+ 'Crunching the numbers',
+ 'Reading the digital tea leaves',
+ 'Firing up the neurons',
+ 'Summoning my inner genius',
+ 'Connecting the dots',
+ 'Working my magic',
+ 'Channeling my inner Einstein',
+ 'Cooking up something good',
+];
+
+export const AIStateIndicator = () => {
+ const { channel } = useChannelStateContext();
+ const { aiState } = useAIState(channel);
+ const messageIndex = useMemo(
+ () => Math.floor(Math.random() * MESSAGES.length),
+ // reset the thinking message everytime a new chat message arrives
+ // eslint-disable-next-line
+ [channel.state.last_message_at],
+ );
+
+ if (![AIStates.Generating, AIStates.Thinking].includes(aiState)) return null;
+
+ return (
+
+
+
+
+
+
+
+
+ {MESSAGES[messageIndex]}
+
+
+
+ );
+};
diff --git a/examples/react-example/src/components/AIStateIndicator/index.ts b/examples/react-example/src/components/AIStateIndicator/index.ts
new file mode 100644
index 0000000..d5864c1
--- /dev/null
+++ b/examples/react-example/src/components/AIStateIndicator/index.ts
@@ -0,0 +1 @@
+export { AIStateIndicator } from './AIStateIndicator';
diff --git a/examples/react-example/src/components/ChatContainer/ChatContainer.scss b/examples/react-example/src/components/ChatContainer/ChatContainer.scss
new file mode 100644
index 0000000..a1bca91
--- /dev/null
+++ b/examples/react-example/src/components/ChatContainer/ChatContainer.scss
@@ -0,0 +1,100 @@
+.ai-demo-chat-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 100%;
+ position: relative;
+
+ /* Override Stream Chat Channel styles */
+ .str-chat__channel {
+ flex: 1;
+ width: 100%;
+ max-width: 100%;
+ background-color: var(--ai-demo-bg-primary);
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+ }
+
+ .str-chat__main-panel {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ flex: 1;
+ }
+
+ .str-chat__container {
+ background-color: var(--ai-demo-bg-primary);
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ min-height: 0;
+ }
+
+ .str-chat__li {
+ margin: 8px 0;
+ }
+
+ /* Window component that contains MessageList, AIStateIndicator, and MessageInput */
+ .str-chat__thread,
+ .str-chat__main-panel-inner {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ overflow-y: scroll;
+ min-height: 0;
+ flex: 1;
+ }
+
+ .str-chat__list {
+ background-color: var(--ai-demo-bg-primary);
+ padding: 1rem 0;
+ flex: 1;
+ overflow-y: scroll;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ }
+
+ /* Override message list styles */
+ .str-chat__message-list {
+ background-color: var(--ai-demo-bg-primary);
+ width: 100%;
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ }
+
+ .str-chat__message-list-scroll {
+ background-color: var(--ai-demo-bg-primary);
+ max-width: 900px;
+ width: 100%;
+ margin: 0 auto;
+ padding: 0 1rem;
+ }
+
+ /* Override AI state indicator */
+ .str-chat__ai-state-indicator {
+ padding: 0.75rem 1.5rem;
+ color: var(--ai-demo-text-secondary);
+ font-size: 0.875rem;
+ }
+
+ /* Override message input */
+ .str-chat__input-flat {
+ background-color: transparent;
+ border: none;
+ }
+
+ /* Mobile responsiveness */
+ @media (max-width: 768px) {
+ .str-chat__message-list-scroll {
+ max-width: 100%;
+ padding: 0 0.5rem;
+ }
+ }
+}
diff --git a/examples/react-example/src/components/ChatContainer/ChatContainer.tsx b/examples/react-example/src/components/ChatContainer/ChatContainer.tsx
new file mode 100644
index 0000000..1fa8cc2
--- /dev/null
+++ b/examples/react-example/src/components/ChatContainer/ChatContainer.tsx
@@ -0,0 +1,34 @@
+import { Channel, MessageList, Window, MessageInput } from 'stream-chat-react';
+import { EmptyState } from '../EmptyState';
+import { MessageBubble } from '../MessageBubble';
+import { MessageInputBar } from '../MessageInputBar';
+import { AIStateIndicator } from '../AIStateIndicator';
+import { TopNavBar } from '../TopNavBar';
+import './ChatContainer.scss';
+
+interface ChatContainerProps {
+ onToggleSidebar: () => void;
+}
+
+export const ChatContainer = ({ onToggleSidebar }: ChatContainerProps) => {
+ return (
+
+
}
+ Message={MessageBubble}
+ /* @ts-expect-error: `null` isn't in the types yet */
+ UnreadMessagesNotification={null}
+ /* @ts-expect-error: `null` isn't in the types yet */
+ UnreadMessagesSeparator={null}
+ >
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/examples/react-example/src/components/ChatContainer/index.ts b/examples/react-example/src/components/ChatContainer/index.ts
new file mode 100644
index 0000000..16361bf
--- /dev/null
+++ b/examples/react-example/src/components/ChatContainer/index.ts
@@ -0,0 +1 @@
+export { ChatContainer } from './ChatContainer';
diff --git a/examples/react-example/src/components/EmptyState/EmptyState.scss b/examples/react-example/src/components/EmptyState/EmptyState.scss
new file mode 100644
index 0000000..d0ed51b
--- /dev/null
+++ b/examples/react-example/src/components/EmptyState/EmptyState.scss
@@ -0,0 +1,31 @@
+.ai-demo-empty-state {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ width: 100%;
+ background-color: var(--ai-demo-bg-primary);
+
+ &__content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ padding: 2rem;
+ max-width: 600px;
+ }
+
+ &__title {
+ font-size: 2rem;
+ font-weight: 600;
+ color: var(--ai-demo-text-primary);
+ margin: 0 0 0.75rem 0;
+ }
+
+ &__subtitle {
+ font-size: 1rem;
+ color: var(--ai-demo-text-secondary);
+ margin: 0;
+ line-height: 1.5;
+ }
+}
diff --git a/examples/react-example/src/components/EmptyState/EmptyState.tsx b/examples/react-example/src/components/EmptyState/EmptyState.tsx
new file mode 100644
index 0000000..57fb3d3
--- /dev/null
+++ b/examples/react-example/src/components/EmptyState/EmptyState.tsx
@@ -0,0 +1,31 @@
+import { useEffect } from 'react';
+import { useChatContext } from 'stream-chat-react';
+import { customAlphabet } from 'nanoid';
+import './EmptyState.scss';
+
+const nanoId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 10);
+
+export const EmptyState = () => {
+ const { channel, setActiveChannel, client } = useChatContext();
+
+ useEffect(() => {
+ if (!channel) {
+ setActiveChannel(
+ client.channel('messaging', `ai-${nanoId()}`, {
+ members: [client.userID as string],
+ }),
+ );
+ }
+ }, [channel, client, setActiveChannel]);
+
+ return (
+
+
+
Start a conversation
+
+ Ask me anything, and I'll do my best to help.
+
+
+
+ );
+};
diff --git a/examples/react-example/src/components/EmptyState/index.ts b/examples/react-example/src/components/EmptyState/index.ts
new file mode 100644
index 0000000..89798db
--- /dev/null
+++ b/examples/react-example/src/components/EmptyState/index.ts
@@ -0,0 +1 @@
+export { EmptyState } from './EmptyState';
diff --git a/examples/react-example/src/components/MessageBubble/MessageBubble.scss b/examples/react-example/src/components/MessageBubble/MessageBubble.scss
new file mode 100644
index 0000000..0fb9dd7
--- /dev/null
+++ b/examples/react-example/src/components/MessageBubble/MessageBubble.scss
@@ -0,0 +1,96 @@
+.ai-demo-message {
+ display: flex;
+ width: 100%;
+
+ &--user {
+ justify-content: flex-end;
+
+ .ai-demo-message__bubble {
+ background-color: var(--ai-demo-user-bg);
+ border-radius: 18px;
+ max-width: 100%;
+ }
+ }
+
+ &--ai {
+ justify-content: flex-start;
+
+ .ai-demo-message__bubble {
+ background-color: var(--ai-demo-bg-tertiary);
+ border-radius: 18px;
+ padding: 0.5rem 0.75rem;
+ min-width: 50%;
+ width: auto;
+ }
+ }
+
+ &__inner {
+ display: flex;
+ flex-direction: column;
+ max-width: 100%;
+ }
+
+ &__bubble {
+ color: var(--ai-demo-text-primary);
+ font-size: 0.9375rem;
+ line-height: 1.6;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+
+ /* Override Stream Chat attachment styles */
+ .str-chat__attachment-list {
+ margin: 0;
+ }
+
+ .str-chat__message-attachment {
+ border-radius: 8px;
+ overflow: hidden;
+ }
+
+ /* Code blocks in AI messages */
+ pre {
+ background-color: var(--ai-demo-bg-tertiary);
+ border-radius: 8px;
+ padding: 1rem;
+ margin: 0.5rem 0;
+ overflow-x: auto;
+ }
+
+ code {
+ font-family:
+ 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
+ 'Courier New', monospace;
+ font-size: 0.875rem;
+ }
+
+ /* Inline code */
+ p code,
+ li code {
+ background-color: var(--ai-demo-bg-tertiary);
+ padding: 0.2em 0.4em;
+ border-radius: 4px;
+ }
+ }
+
+ &--highlighted {
+ background-color: rgba(16, 163, 127, 0.1);
+ }
+
+ &--failed {
+ opacity: 0.7;
+ }
+
+ /* Mobile responsiveness */
+ @media (max-width: 768px) {
+ padding: 0.25rem 0;
+
+ &--user .ai-demo-message__bubble {
+ padding: 0.5rem 0.75rem;
+ }
+
+ &--ai .ai-demo-message__bubble {
+ min-width: 60%;
+ padding: 0.5rem 0.75rem;
+ }
+ }
+}
diff --git a/examples/react-example/src/components/MessageBubble/MessageBubble.tsx b/examples/react-example/src/components/MessageBubble/MessageBubble.tsx
new file mode 100644
index 0000000..bea6e6d
--- /dev/null
+++ b/examples/react-example/src/components/MessageBubble/MessageBubble.tsx
@@ -0,0 +1,57 @@
+import { useMemo } from 'react';
+import clsx from 'clsx';
+import { StreamingMessage } from '@stream-io/ai-chat-react';
+import {
+ Attachment,
+ messageHasAttachments,
+ useMessageContext,
+} from 'stream-chat-react';
+import './MessageBubble.scss';
+
+export const MessageBubble = () => {
+ const { message, isMyMessage, highlighted, handleAction } =
+ useMessageContext();
+
+ const hasAttachment = messageHasAttachments(message);
+ const finalAttachments = useMemo(
+ () =>
+ !message.shared_location && !message.attachments
+ ? []
+ : !message.shared_location
+ ? message.attachments
+ : [message.shared_location, ...(message.attachments ?? [])],
+ [message],
+ );
+
+ const rootClassName = clsx(
+ 'ai-demo-message',
+ `ai-demo-message--${message.type}`,
+ `ai-demo-message--${message.status}`,
+ {
+ 'ai-demo-message--user': isMyMessage(),
+ 'ai-demo-message--ai': !isMyMessage(),
+ 'ai-demo-message--has-text': !!message.text,
+ 'ai-demo-message--has-attachment': hasAttachment,
+ 'ai-demo-message--highlighted': highlighted,
+ 'ai-demo-message--can-retry':
+ message?.status === 'failed' && message?.error?.status !== 403,
+ },
+ );
+
+ return (
+
+
+
+ {finalAttachments?.length ? (
+
+ ) : null}
+
+
+
+
+
+ );
+};
diff --git a/examples/react-example/src/components/MessageBubble/index.ts b/examples/react-example/src/components/MessageBubble/index.ts
new file mode 100644
index 0000000..fd86462
--- /dev/null
+++ b/examples/react-example/src/components/MessageBubble/index.ts
@@ -0,0 +1 @@
+export { MessageBubble } from './MessageBubble';
diff --git a/examples/react-example/src/components/MessageInputBar/MessageInputBar.scss b/examples/react-example/src/components/MessageInputBar/MessageInputBar.scss
new file mode 100644
index 0000000..6ab14bf
--- /dev/null
+++ b/examples/react-example/src/components/MessageInputBar/MessageInputBar.scss
@@ -0,0 +1,118 @@
+.ai-demo-message-input-bar {
+ display: flex;
+ justify-content: center;
+ padding: 1.5rem;
+ background-color: var(--ai-demo-bg-primary);
+ color: var(--ai-demo-text-primary);
+ border-top: 1px solid var(--ai-demo-border);
+
+ /* Override AIMessageComposer styles */
+ .aicr__ai-message-composer__form {
+ width: 100%;
+ max-width: 900px;
+ }
+
+ .aicr__ai-message-composer__form {
+ background-color: var(--ai-demo-bg-tertiary);
+ border: 1px solid var(--ai-demo-border);
+ border-radius: 24px;
+ padding: 0.75rem 1rem;
+ transition: all 0.2s ease;
+ box-shadow: 0 0 0 0 transparent;
+
+ &:focus-within {
+ border-color: var(--ai-demo-accent);
+ background-color: var(--ai-demo-bg-tertiary);
+ box-shadow: 0 0 0 1px rgba(16, 163, 127, 0.2);
+ }
+ }
+
+ .aicr__ai-message-composer__form__textarea {
+ background-color: transparent !important;
+ color: var(--ai-demo-text-primary);
+ font-family: var(--ai-demo-font-family);
+ font-size: 0.9375rem;
+ border: none;
+ outline: none;
+ resize: none;
+ max-height: 200px;
+ line-height: 1.5;
+ width: 100%;
+
+ &::placeholder {
+ color: var(--ai-demo-text-secondary);
+ opacity: 0.8;
+ }
+ }
+
+ /* Additional specificity for textarea */
+ textarea.aicr__ai-message-composer__form__textarea,
+ input.aicr__ai-message-composer__form__textarea {
+ color: var(--ai-demo-text-primary) !important;
+ }
+
+ .aicr__ai-message-composer__select {
+ background-color: var(--ai-demo-bg-tertiary);
+ color: var(--ai-demo-text-primary);
+ border-color: var(--ai-demo-border);
+ border-radius: 8px;
+ padding: 0.5rem 0.75rem;
+ font-family: var(--ai-demo-font-family);
+ font-size: 0.875rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background-color: var(--ai-demo-bg-tertiary);
+ border-color: var(--ai-demo-accent);
+ }
+
+ &:focus {
+ outline: none;
+ border-color: var(--ai-demo-accent);
+ box-shadow: 0 0 0 1px rgba(16, 163, 127, 0.2);
+ background-color: var(--ai-demo-bg-tertiary);
+ }
+
+ option {
+ background-color: var(--ai-demo-bg-primary);
+ color: var(--ai-demo-text-primary);
+ }
+ }
+
+ .aicr__ai-message-composer__form__submit-btn {
+ background-color: var(--ai-demo-accent);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ padding: 0.5rem;
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &:hover:not(:disabled) {
+ background-color: var(--ai-demo-accent-hover);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+
+ /* Mobile responsiveness */
+ @media (max-width: 768px) {
+ padding: 1rem;
+
+ .ai-message-composer {
+ max-width: 100%;
+ }
+
+ .ai-message-composer__form {
+ border-radius: 16px;
+ padding: 0.5rem 0.75rem;
+ }
+ }
+}
diff --git a/examples/react-example/src/components/MessageInputBar/MessageInputBar.tsx b/examples/react-example/src/components/MessageInputBar/MessageInputBar.tsx
new file mode 100644
index 0000000..76a8290
--- /dev/null
+++ b/examples/react-example/src/components/MessageInputBar/MessageInputBar.tsx
@@ -0,0 +1,79 @@
+import { type Channel } from 'stream-chat';
+import { AIMessageComposer } from '@stream-io/ai-chat-react';
+import {
+ useChannelActionContext,
+ useChannelStateContext,
+ useMessageComposer,
+} from 'stream-chat-react';
+import './MessageInputBar.scss';
+
+const startAiAgent = async (channel: Channel, model: string | File | null) => {
+ await fetch(
+ 'https://stream-nodejs-ai-e5d85ed5ce6f.herokuapp.com/start-ai-agent',
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ channel_id: channel.id,
+ channel_type: channel.type,
+ platform: 'openai',
+ model,
+ }),
+ },
+ );
+};
+
+export const MessageInputBar = () => {
+ const { updateMessage, sendMessage } = useChannelActionContext();
+ const { channel } = useChannelStateContext();
+ const composer = useMessageComposer();
+
+ return (
+
+
{
+ const event = e;
+ const target = (event.currentTarget ??
+ event.target) as HTMLFormElement | null;
+ event.preventDefault();
+
+ const formData = new FormData(event.currentTarget);
+
+ const t = formData.get('message');
+ const model = formData.get('model');
+
+ composer.textComposer.setText(t as string);
+
+ const d = await composer.compose();
+
+ if (!d) return;
+
+ target?.reset();
+ composer.clear();
+
+ if (channel.initialized) {
+ const isAiAgentActive = Object.keys(channel.state.watchers).some(
+ (userId) => userId.startsWith('ai-bot'),
+ );
+ if (!isAiAgentActive) {
+ await startAiAgent(channel, model);
+ }
+
+ await sendMessage(d);
+ } else {
+ updateMessage(d?.localMessage);
+
+ await channel.watch();
+
+ // TODO: wrap in retry (in case channel creation takes longer)
+ await startAiAgent(channel, model);
+
+ await sendMessage(d);
+ }
+ }}
+ />
+
+ );
+};
diff --git a/examples/react-example/src/components/MessageInputBar/index.ts b/examples/react-example/src/components/MessageInputBar/index.ts
new file mode 100644
index 0000000..534e0c1
--- /dev/null
+++ b/examples/react-example/src/components/MessageInputBar/index.ts
@@ -0,0 +1 @@
+export { MessageInputBar } from './MessageInputBar';
diff --git a/examples/react-example/src/components/Sidebar/ChannelPreviewItem.scss b/examples/react-example/src/components/Sidebar/ChannelPreviewItem.scss
new file mode 100644
index 0000000..39791a7
--- /dev/null
+++ b/examples/react-example/src/components/Sidebar/ChannelPreviewItem.scss
@@ -0,0 +1,36 @@
+.ai-demo-channel-preview {
+ padding: 0.75rem 1rem;
+ cursor: pointer;
+ border-radius: 8px;
+ margin: 0.25rem 0.5rem;
+ transition: background-color 0.2s ease;
+ position: relative;
+
+ &:hover {
+ background-color: var(--ai-demo-bg-tertiary);
+ }
+
+ &--active {
+ background-color: var(--ai-demo-bg-tertiary);
+
+ &::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 8px;
+ bottom: 8px;
+ width: 3px;
+ background-color: var(--ai-demo-accent);
+ border-radius: 0 2px 2px 0;
+ }
+ }
+
+ &__text {
+ color: var(--ai-demo-text-primary);
+ font-size: 0.875rem;
+ line-height: 1.4;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
diff --git a/examples/react-example/src/components/Sidebar/ChannelPreviewItem.tsx b/examples/react-example/src/components/Sidebar/ChannelPreviewItem.tsx
new file mode 100644
index 0000000..496a400
--- /dev/null
+++ b/examples/react-example/src/components/Sidebar/ChannelPreviewItem.tsx
@@ -0,0 +1,19 @@
+import type { ChannelPreviewProps } from 'stream-chat-react';
+import { useChatContext } from 'stream-chat-react';
+import './ChannelPreviewItem.scss';
+
+export const ChannelPreviewItem = (props: ChannelPreviewProps) => {
+ const { setActiveChannel, channel: activeChannel } = useChatContext();
+ const isActive = activeChannel?.id === props.channel.id;
+
+ return (
+ setActiveChannel(props.channel)}
+ >
+
+ {props.channel.data?.summary ?? props.channel.id}
+
+
+ );
+};
diff --git a/examples/react-example/src/components/Sidebar/Sidebar.scss b/examples/react-example/src/components/Sidebar/Sidebar.scss
new file mode 100644
index 0000000..c2a8f80
--- /dev/null
+++ b/examples/react-example/src/components/Sidebar/Sidebar.scss
@@ -0,0 +1,61 @@
+.ai-demo-sidebar {
+ width: 260px;
+ height: 100%;
+ background-color: var(--ai-demo-bg-secondary);
+ border-right: 1px solid var(--ai-demo-border);
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+ transition: transform 0.3s ease;
+
+ &__list {
+ flex: 1;
+ overflow-y: auto;
+
+ /* Override Stream Chat channel list styles */
+ .str-chat__channel-list {
+ background-color: transparent;
+ border: none;
+ width: 100%;
+ max-width: 100%;
+ padding: 0.5rem 0;
+ }
+
+ .str-chat__channel-list-messenger {
+ background-color: transparent;
+ padding: 0;
+ }
+
+ .str-chat__channel-list-messenger__main {
+ padding: 0;
+ }
+ }
+
+ /* Mobile styles */
+ @media (max-width: 768px) {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 1000;
+ transform: translateX(-100%);
+
+ &--open {
+ transform: translateX(0);
+ }
+ }
+}
+
+.ai-demo-sidebar-backdrop {
+ display: none;
+
+ @media (max-width: 768px) {
+ display: block;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ z-index: 999;
+ }
+}
diff --git a/examples/react-example/src/components/Sidebar/Sidebar.tsx b/examples/react-example/src/components/Sidebar/Sidebar.tsx
new file mode 100644
index 0000000..2edac29
--- /dev/null
+++ b/examples/react-example/src/components/Sidebar/Sidebar.tsx
@@ -0,0 +1,47 @@
+import type { ChannelFilters, ChannelOptions, ChannelSort } from 'stream-chat';
+import { ChannelList, ChannelPreview } from 'stream-chat-react';
+import { SidebarHeader } from './SidebarHeader';
+import { SidebarFooter } from './SidebarFooter';
+import { ChannelPreviewItem } from './ChannelPreviewItem';
+import './Sidebar.scss';
+
+interface SidebarProps {
+ filters: ChannelFilters;
+ options: ChannelOptions;
+ sort: ChannelSort;
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export const Sidebar = ({
+ filters,
+ options,
+ sort,
+ isOpen,
+ onClose,
+}: SidebarProps) => {
+ return (
+ <>
+ {/* Backdrop for mobile */}
+ {isOpen && (
+
+ )}
+
+
+
+
+ (
+
+ )}
+ filters={filters}
+ options={options}
+ sort={sort}
+ />
+
+
+
+ >
+ );
+};
diff --git a/examples/react-example/src/components/Sidebar/SidebarFooter.scss b/examples/react-example/src/components/Sidebar/SidebarFooter.scss
new file mode 100644
index 0000000..3c8f556
--- /dev/null
+++ b/examples/react-example/src/components/Sidebar/SidebarFooter.scss
@@ -0,0 +1,44 @@
+.ai-demo-sidebar-footer {
+ padding: 1rem;
+ border-top: 1px solid var(--ai-demo-border);
+ background-color: var(--ai-demo-bg-secondary);
+
+ &__theme-btn {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ border: 1px solid var(--ai-demo-border);
+ border-radius: 8px;
+ background-color: transparent;
+ color: var(--ai-demo-text-primary);
+ font-family: var(--ai-demo-font-family);
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ transition: background-color 0.2s ease;
+
+ &:hover {
+ background-color: var(--ai-demo-bg-tertiary);
+ }
+
+ &:active {
+ background-color: var(--ai-demo-border);
+ }
+
+ .material-symbols-rounded {
+ font-size: 1.25rem;
+ font-variation-settings:
+ 'FILL' 0,
+ 'wght' 400,
+ 'GRAD' 0,
+ 'opsz' 24;
+ }
+ }
+
+ &__theme-text {
+ flex: 1;
+ text-align: left;
+ }
+}
diff --git a/examples/react-example/src/components/Sidebar/SidebarFooter.tsx b/examples/react-example/src/components/Sidebar/SidebarFooter.tsx
new file mode 100644
index 0000000..4bc3180
--- /dev/null
+++ b/examples/react-example/src/components/Sidebar/SidebarFooter.tsx
@@ -0,0 +1,24 @@
+import { useTheme } from '../../contexts/ThemeContext';
+import './SidebarFooter.scss';
+
+export const SidebarFooter = () => {
+ const { theme, toggleTheme } = useTheme();
+
+ return (
+
+
+
+ );
+};
diff --git a/examples/react-example/src/components/Sidebar/SidebarHeader.scss b/examples/react-example/src/components/Sidebar/SidebarHeader.scss
new file mode 100644
index 0000000..f056d04
--- /dev/null
+++ b/examples/react-example/src/components/Sidebar/SidebarHeader.scss
@@ -0,0 +1,38 @@
+.ai-demo-sidebar-header {
+ padding: 1rem;
+ border-bottom: 1px solid var(--ai-demo-border);
+
+ &__new-chat-btn {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ border: 1px solid var(--ai-demo-border);
+ border-radius: 8px;
+ background-color: transparent;
+ color: var(--ai-demo-text-primary);
+ font-family: var(--ai-demo-font-family);
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ transition: background-color 0.2s ease;
+
+ &:hover {
+ background-color: var(--ai-demo-bg-tertiary);
+ }
+
+ &:active {
+ background-color: var(--ai-demo-border);
+ }
+
+ .material-symbols-rounded {
+ font-size: 1.25rem;
+ font-variation-settings:
+ 'FILL' 0,
+ 'wght' 400,
+ 'GRAD' 0,
+ 'opsz' 24;
+ }
+ }
+}
diff --git a/examples/react-example/src/components/Sidebar/SidebarHeader.tsx b/examples/react-example/src/components/Sidebar/SidebarHeader.tsx
new file mode 100644
index 0000000..a461d3c
--- /dev/null
+++ b/examples/react-example/src/components/Sidebar/SidebarHeader.tsx
@@ -0,0 +1,45 @@
+import { useChatContext } from 'stream-chat-react';
+import { customAlphabet } from 'nanoid';
+import './SidebarHeader.scss';
+
+const nanoId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 10);
+
+export const SidebarHeader = () => {
+ const { setActiveChannel, client } = useChatContext();
+
+ const handleNewChat = () => {
+ // Check if there's unsent text in the composer
+ // We'll check the textarea element directly
+ const textarea = document.querySelector(
+ '.ai-message-composer__textarea',
+ ) as HTMLTextAreaElement;
+ const hasUnsentText = textarea?.value?.trim();
+
+ if (hasUnsentText) {
+ const confirmed = window.confirm(
+ 'You have unsent text. Are you sure you want to start a new chat?',
+ );
+ if (!confirmed) return;
+ }
+
+ // Create a new channel
+ const newChannel = client.channel('messaging', `ai-${nanoId()}`, {
+ members: [client.userID as string],
+ });
+
+ setActiveChannel(newChannel);
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/examples/react-example/src/components/Sidebar/index.ts b/examples/react-example/src/components/Sidebar/index.ts
new file mode 100644
index 0000000..a983e07
--- /dev/null
+++ b/examples/react-example/src/components/Sidebar/index.ts
@@ -0,0 +1,4 @@
+export { Sidebar } from './Sidebar';
+export { SidebarHeader } from './SidebarHeader';
+export { SidebarFooter } from './SidebarFooter';
+export { ChannelPreviewItem } from './ChannelPreviewItem';
diff --git a/examples/react-example/src/components/TopNavBar/TopNavBar.scss b/examples/react-example/src/components/TopNavBar/TopNavBar.scss
new file mode 100644
index 0000000..0e8bbe5
--- /dev/null
+++ b/examples/react-example/src/components/TopNavBar/TopNavBar.scss
@@ -0,0 +1,87 @@
+.ai-demo-top-nav {
+ display: none;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ background-color: var(--ai-demo-bg-secondary);
+ border-bottom: 1px solid var(--ai-demo-border);
+ gap: 1rem;
+ position: sticky;
+ top: 0;
+ z-index: 10;
+
+ &__menu-btn {
+ background-color: transparent;
+ border: none;
+ color: var(--ai-demo-text-primary);
+ cursor: pointer;
+ padding: 0.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 8px;
+ transition: background-color 0.2s ease;
+ flex-shrink: 0;
+ min-width: 40px;
+ height: 40px;
+
+ &:hover {
+ background-color: var(--ai-demo-bg-tertiary);
+ }
+
+ &:active {
+ background-color: var(--ai-demo-border);
+ }
+
+ .material-symbols-rounded {
+ font-size: 1.5rem;
+ line-height: 1;
+ }
+ }
+
+ &__title {
+ flex: 1;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--ai-demo-text-primary);
+ margin: 0;
+ text-align: center;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100%;
+ }
+
+ &__theme-btn {
+ background-color: transparent;
+ border: none;
+ color: var(--ai-demo-text-primary);
+ cursor: pointer;
+ padding: 0.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 8px;
+ transition: background-color 0.2s ease;
+ flex-shrink: 0;
+ min-width: 40px;
+ height: 40px;
+
+ &:hover {
+ background-color: var(--ai-demo-bg-tertiary);
+ }
+
+ &:active {
+ background-color: var(--ai-demo-border);
+ }
+
+ .material-symbols-rounded {
+ font-size: 1.5rem;
+ line-height: 1;
+ }
+ }
+
+ @media (max-width: 768px) {
+ display: flex;
+ }
+}
diff --git a/examples/react-example/src/components/TopNavBar/TopNavBar.tsx b/examples/react-example/src/components/TopNavBar/TopNavBar.tsx
new file mode 100644
index 0000000..4392e20
--- /dev/null
+++ b/examples/react-example/src/components/TopNavBar/TopNavBar.tsx
@@ -0,0 +1,40 @@
+import { useChannelStateContext } from 'stream-chat-react';
+import { useTheme } from '../../contexts/ThemeContext';
+import './TopNavBar.scss';
+
+interface TopNavBarProps {
+ onToggleSidebar: () => void;
+}
+
+export const TopNavBar = ({ onToggleSidebar }: TopNavBarProps) => {
+ const { channel } = useChannelStateContext();
+ const { theme, toggleTheme } = useTheme();
+
+ const conversationTitle = channel?.data?.summary ?? channel?.id ?? 'New Chat';
+
+ return (
+
+
+
+
{conversationTitle}
+
+
+
+ );
+};
diff --git a/examples/react-example/src/components/TopNavBar/index.ts b/examples/react-example/src/components/TopNavBar/index.ts
new file mode 100644
index 0000000..f855f5c
--- /dev/null
+++ b/examples/react-example/src/components/TopNavBar/index.ts
@@ -0,0 +1 @@
+export { TopNavBar } from './TopNavBar';
diff --git a/examples/react-example/src/components/index.ts b/examples/react-example/src/components/index.ts
new file mode 100644
index 0000000..6c20f76
--- /dev/null
+++ b/examples/react-example/src/components/index.ts
@@ -0,0 +1,8 @@
+export { AIChatApp } from './AIChatApp';
+export { AIStateIndicator } from './AIStateIndicator';
+export { ChatContainer } from './ChatContainer';
+export { EmptyState } from './EmptyState';
+export { MessageBubble } from './MessageBubble';
+export { MessageInputBar } from './MessageInputBar';
+export { Sidebar, SidebarHeader, ChannelPreviewItem } from './Sidebar';
+export { TopNavBar } from './TopNavBar';
diff --git a/examples/react-example/src/contexts/ThemeContext.tsx b/examples/react-example/src/contexts/ThemeContext.tsx
new file mode 100644
index 0000000..ff08e9f
--- /dev/null
+++ b/examples/react-example/src/contexts/ThemeContext.tsx
@@ -0,0 +1,55 @@
+import type { ReactNode } from 'react';
+import { createContext, useContext, useState, useEffect } from 'react';
+
+type Theme = 'light' | 'dark';
+
+interface ThemeContextType {
+ theme: Theme;
+ toggleTheme: () => void;
+}
+
+const ThemeContext = createContext(undefined);
+
+export const useTheme = () => {
+ const context = useContext(ThemeContext);
+ if (!context) {
+ throw new Error('useTheme must be used within ThemeProvider');
+ }
+ return context;
+};
+
+interface ThemeProviderProps {
+ children: ReactNode;
+}
+
+export const ThemeProvider = ({ children }: ThemeProviderProps) => {
+ const [theme, setTheme] = useState(() => {
+ // Check localStorage first
+ const saved = localStorage.getItem('ai-demo-theme') as Theme;
+ if (saved) return saved;
+
+ // Check system preference
+ if (window.matchMedia('(prefers-color-scheme: light)').matches) {
+ return 'light';
+ }
+
+ return 'dark';
+ });
+
+ useEffect(() => {
+ // Update the data-theme attribute on the root element
+ document.documentElement.setAttribute('data-theme', theme);
+ // Save to localStorage
+ localStorage.setItem('ai-demo-theme', theme);
+ }, [theme]);
+
+ const toggleTheme = () => {
+ setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/examples/react-example/src/index.scss b/examples/react-example/src/index.scss
index 8ba1153..b0adf81 100644
--- a/examples/react-example/src/index.scss
+++ b/examples/react-example/src/index.scss
@@ -1,8 +1,9 @@
-@layer material-rounded, stream, stream-overrides, ai-chat;
+@layer material-rounded, stream, stream-overrides, ai-chat, ai-demo;
@import 'stream-chat-react/dist/css/v2/index.css' layer(stream);
@import 'material-symbols/rounded.css' layer(material-rounded);
@import '@stream-io/ai-chat-react/styles/index.css' layer(ai-chat);
+@import './ai-demo.scss' layer(ai-demo);
@layer stream-overrides {
.str-chat__channel {
@@ -44,20 +45,73 @@
:root {
margin: 0;
padding: 0;
- font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
+
+ /* Font */
+ --ai-demo-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
+
+ font-family: var(--ai-demo-font-family);
line-height: 1.5;
font-weight: 400;
- color-scheme: light dark;
- color: rgba(255, 255, 255, 0.87);
- background-color: #242424;
-
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+/* Dark Theme (default) */
+:root,
+:root[data-theme='dark'] {
+ /* ChatGPT 4/Plus Dark Theme Colors */
+ --ai-demo-bg-primary: #212121;
+ --ai-demo-bg-secondary: #171717;
+ --ai-demo-bg-tertiary: #2f2f2f;
+ --ai-demo-border: #4a4a4a;
+
+ /* Text colors */
+ --ai-demo-text-primary: #ececec;
+ --ai-demo-text-secondary: #b4b4b4;
+ --ai-demo-text-tertiary: #8e8e8e;
+
+ /* Message colors */
+ --ai-demo-user-bg: #343541;
+ --ai-demo-ai-bg: transparent;
+
+ /* Accent colors */
+ --ai-demo-accent: #10a37f;
+ --ai-demo-accent-hover: #0d8968;
+
+ color-scheme: dark;
+ color: var(--ai-demo-text-primary);
+ background-color: var(--ai-demo-bg-primary);
+}
+
+/* Light Theme */
+:root[data-theme='light'] {
+ /* ChatGPT Light Theme Colors */
+ --ai-demo-bg-primary: #ffffff;
+ --ai-demo-bg-secondary: #f7f7f8;
+ --ai-demo-bg-tertiary: #ececf1;
+ --ai-demo-border: #d9d9e3;
+
+ /* Text colors */
+ --ai-demo-text-primary: #353740;
+ --ai-demo-text-secondary: #565869;
+ --ai-demo-text-tertiary: #8e8ea0;
+
+ /* Message colors */
+ --ai-demo-user-bg: #f4f4f4;
+ --ai-demo-ai-bg: transparent;
+
+ /* Accent colors */
+ --ai-demo-accent: #10a37f;
+ --ai-demo-accent-hover: #0d8968;
+
+ color-scheme: light;
+ color: var(--ai-demo-text-primary);
+ background-color: var(--ai-demo-bg-primary);
+}
+
body {
margin: 0;
padding: 0;
@@ -70,3 +124,29 @@ body {
height: 100%;
display: flex;
}
+
+/* Custom ChatGPT-style scrollbar */
+* {
+ scrollbar-width: thin;
+ scrollbar-color: var(--ai-demo-border) transparent;
+}
+
+*::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+*::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+*::-webkit-scrollbar-thumb {
+ background-color: var(--ai-demo-border);
+ border-radius: 4px;
+ border: 2px solid transparent;
+ background-clip: padding-box;
+}
+
+*::-webkit-scrollbar-thumb:hover {
+ background-color: var(--ai-demo-bg-tertiary);
+}
diff --git a/examples/react-example/src/stream-chat.d.ts b/examples/react-example/src/stream-chat.d.ts
new file mode 100644
index 0000000..22f94d0
--- /dev/null
+++ b/examples/react-example/src/stream-chat.d.ts
@@ -0,0 +1,43 @@
+import type {
+ DefaultAttachmentData,
+ DefaultChannelData,
+ DefaultCommandData,
+ DefaultEventData,
+ DefaultMemberData,
+ DefaultMessageData,
+ DefaultPollData,
+ DefaultPollOptionData,
+ DefaultReactionData,
+ DefaultThreadData,
+ DefaultUserData,
+} from 'stream-chat-react';
+
+declare module 'stream-chat' {
+ interface CustomAttachmentData extends DefaultAttachmentData {
+ id?: string;
+ }
+
+ interface CustomChannelData extends DefaultChannelData {
+ summary?: string;
+ }
+
+ interface CustomCommandData extends DefaultCommandData {}
+
+ interface CustomEventData extends DefaultEventData {}
+
+ interface CustomMemberData extends DefaultMemberData {}
+
+ interface CustomUserData extends DefaultUserData {}
+
+ interface CustomMessageData extends DefaultMessageData {
+ ai_generated?: boolean;
+ }
+
+ interface CustomPollOptionData extends DefaultPollOptionData {}
+
+ interface CustomPollData extends DefaultPollData {}
+
+ interface CustomReactionData extends DefaultReactionData {}
+
+ interface CustomThreadData extends DefaultThreadData {}
+}
diff --git a/package.json b/package.json
index 4ff517f..978ecb6 100644
--- a/package.json
+++ b/package.json
@@ -27,5 +27,5 @@
"eslint-plugin-react-hooks": "^5.2.0",
"vitest": "^4.0.6"
},
- "packageManager": "pnpm@10.13.1"
+ "packageManager": "pnpm@10.24.0"
}
diff --git a/packages/react-sdk/src/components/composer/ai-message-composer.tsx b/packages/react-sdk/src/components/composer/ai-message-composer.tsx
index 6cbbdfc..2c452d2 100644
--- a/packages/react-sdk/src/components/composer/ai-message-composer.tsx
+++ b/packages/react-sdk/src/components/composer/ai-message-composer.tsx
@@ -1,14 +1,12 @@
import {
+ type ComponentProps,
+ type ComponentPropsWithoutRef,
createContext,
+ type ReactNode,
useCallback,
useContext,
- useEffect,
useMemo,
- useRef,
useState,
- type ComponentProps,
- type ComponentPropsWithoutRef,
- type ReactNode,
} from 'react';
import { customAlphabet } from 'nanoid';
import { StateStore } from '@stream-io/state-store';
diff --git a/packages/react-sdk/src/styles/ai-message-composer.scss b/packages/react-sdk/src/styles/ai-message-composer.scss
index b5a207f..28fa6e2 100644
--- a/packages/react-sdk/src/styles/ai-message-composer.scss
+++ b/packages/react-sdk/src/styles/ai-message-composer.scss
@@ -5,7 +5,6 @@
gap: 1rem;
border: 1px solid #ccc;
border-radius: 1.5rem;
- max-width: 600px;
flex-grow: 1;
.aicr__ai-message-composer__round-button {