Guidance for AI coding agents (Claude Code, Copilot, Cursor, Codex, Aider, etc.) working in this repository. Human readers are welcome, but this file is written for tools.
Single source of truth.
CLAUDE.mdcontains nothing but@AGENTS.md, which Claude Code expands into this file. Edit this file only β never fork guidance intoCLAUDE.md.
Agents should prioritize backwards compatibility, API stability, and high test coverage when changing code.
Stream's React Chat SDK β React components, hooks and contexts for building chat UIs on the Stream Chat API. The published package (stream-chat-react) lives at the repo root; examples/* are private Yarn workspaces consuming it via workspace:^.
- Language: TypeScript + React
- Runtime: Node 24 (
.nvmrcβ usenvm use) - Package manager: Yarn 4 (Berry). The binary is committed under
.yarn/releases/and activated viayarnPathin.yarnrc.yml. Any globally installedyarn(even classic 1.x) acts only as a launcher β no Corepack required. - Workspaces: Yarn workspaces monorepo (
examples/*) - Testing: Vitest + React Testing Library (+
vitest-axefor a11y). There is no Jest and no Playwright/e2e suite in this repo. - Bundler: Vite 8 / Rolldown (library mode);
tscemits declarations only - Styles: Sass compiled to
dist/css/. Consumers override via CSS layers (see README) β never edit compiled CSS. - Lint/format: ESLint (flat config,
--max-warnings 0) + Prettier - CI: GitHub Actions β PR validation on lint + build/bundle-validation + tests
- Release: Conventional Commits + semantic-release (
commitlint.config.mjs,.releaserc.json)
.nvmrc Β· .yarnrc.yml Β· eslint.config.mjs Β· .prettierrc / .prettierignore Β· tsconfig.json (solution) + tsconfig.lib.json (src) + tsconfig.test.json (tests) Β· vite.config.ts Β· vitest.config.ts / vitest.setup.ts Β· i18next.config.ts Β· commitlint.config.mjs Β· .releaserc.json Β· .lintstagedrc.json / .lintstagedrc.fix.json Β· codecov.yml
Respect repo-specific rules. Do not suppress lint rules broadly; justify and scope every exception.
src/β library source:components/,context/,store/,i18n/,styling/,a11y/,plugins/,utils/,mock-builders/scripts/β build/validation scriptsexamples/β private example workspaces:examples/tutorial,examples/vitedevelopers/β dev notes (BRANCHES.md,COMMIT.md,DEPRECATIONS.md,PR.md,RELEASE.md)
Use the closest folder's patterns and conventions when editing.
yarn install # Root + examples/* workspaces
# Build
yarn build # clean + 4 parallel steps (translations, vite, tsc types, sass)
yarn start # tsc -p tsconfig.lib.json --watch (emit .d.ts on change)
yarn start:css # watch + recompile SCSS
# Tests
yarn test # vitest run (single pass)
yarn test MessageList # filter by file path substring
yarn test -t 'marks read' # filter by test name
yarn test:watch # watch mode
yarn coverage # v8 coverage (what CI runs)
# Lint / format
yarn lint # prettier --list-different + eslint --max-warnings 0 + validate-translations
yarn lint-fix # ALWAYS run this before committing
yarn fix-staged # auto-fix only staged files
# Type checking
yarn types # src β the gate that matters (CI's build runs the same config)
yarn types:tests # tests + mock-builders; NOT run in CI, currently red (see below)
# Bundle smoke tests (run in CI after build)
yarn validate-cjs # loads dist/cjs in Node + a browser-like context
yarn validate-esm # imports dist/es in Node
# Examples
yarn start:tutorial # @stream-io/stream-chat-react-tutorial dev server
yarn start:vite # @stream-io/stream-chat-react-vite dev server
yarn examples:build # build all example workspacesyarn types checks src, and only recently started to. It now runs tsc --project tsconfig.lib.json --noEmit. It previously ran bare tsc --noEmit, which resolved the root tsconfig.json β a solution-style config with "files": [] and project references only β so it checked nothing and always passed in under a second. If you remember it as a no-op, that is fixed; if it returns instantly, something is wrong.
src is the enforced type gate. CI never runs types:tests, but yarn build runs the same tsconfig.lib.json with noEmitOnError, so type errors under src/ (excluding __tests__ and mock-builders, which that config excludes) do fail CI. yarn types:tests is currently red repo-wide (~1300 errors, including some sourced from a sibling ../stream-chat-js checkout when one is present) β treat its output as advisory and compare against a baseline rather than expecting zero.
Adding dependencies. .yarnrc.yml sets npmMinimalAgeGate: 1d, so packages published within the last day are refused unless listed under npmPreapprovedPackages. enableScripts: false disables install scripts globally; per-package opt-ins live in dependenciesMeta in package.json.
<Chat> # Root: client, theme, i18n, SearchController, notification filter
ββ <ChannelList> # Channel list + search
ββ <Channel> # State container: messages, threads, WebSocket events
ββ <Window>
β ββ <ChannelHeader>
β ββ <MessageList> # or <VirtualizedMessageList>
β ββ <MessageComposer> # composer with attachments/mentions/polls/voice
ββ <Thread> # threaded replies (renders its own MessageComposer)
<ChatView> + <Threads>/<ThreadList> provide the channels-vs-threads (inbox) view switching.
ChatContext # client, active channel, theme, searchController, navigation
ββ ChannelStateContext # read-only: messages, members, threads, loading states
ββ ChannelActionContext # write: sendMessage, deleteMessage, openThread, markReadβ¦
ββ ComponentContext # ~100 customizable component slots + `icons` slot map
ββ MessageContext # per-message: actions, reactions, status
ββ MessageComposerContext # composer props/bindings
ββ DialogManagerContext / ModalContext # dialog + modal orchestration
ββ TranslationContext, TypingContext, PollContext, MessageListContext,
VirtualizedMessageListContext, ChannelListContext, MessageBounceContext,
AttachmentSelectorContext, MessageTranslationViewContext
Each has a hook: useChatContext(), useChannelStateContext(), useComponentContext(), β¦ Other contexts live next to their components (SearchContext, ChannelDetailContext, ThreadContext, NotificationConfigurationContext).
ChannelProps does not accept component overrides. Slots come from ComponentContext, populated by <WithComponents overrides={{ β¦ }}>, which merges over the parent context (and merges icons slot-by-slot):
<Channel>
<Window>
<WithComponents overrides={{ MessageUI: CustomMessageUI, icons: { IconFlag } }}>
<MessageList />
</WithComponents>
</Window>
</Channel>Icons are read via useComponentContextIcons(), which merges DEFAULT_ICONS (src/components/Icons/icons) under the override so every slot is guaranteed defined and callers destructure without fallbacks. Note the returned map is memoized with [] β icon overrides are read once and must be stable.
Channel props are behavioral escape hatches instead: doSendMessageRequest, doUpdateMessageRequest, doDeleteMessageRequest, doMarkReadRequest, channelQueryOptions, initializeOnMount, markReadOnMount, skipMessageDataMemoization, EmptyPlaceholder.
When adding a customizable component: add the slot to ComponentContext (src/context/ComponentContext.tsx), provide a default implementation, and read it through useComponentContext().
- Local state (
useState) β component UI state - Reducer state (
useReducer) βChannelusesmakeChannelReducer(src/components/Channel/channelState.ts) for message/thread state - Context state β shared across the tree
- External state β
stream-chat'sStateStore, consumed viauseStateStore(src/store/hooks/useStateStore.ts)
useStateStore requires a selector returning a flat object/array (it shallow-compares the selected keys). Define the selector at module scope so it stays referentially stable:
import { useStateStore } from '../../store';
const selector = (nextValue: ThreadManagerState) => ({
isLoading: nextValue.pagination.isLoading,
threads: nextValue.threads,
});
const { isLoading, threads } = useStateStore(client.threads.state, selector);useMessageComposerController() resolves which MessageComposer instance (from stream-chat) backs the current UI, in this order:
edited message β thread instance (thread.messageComposer) β legacy thread parent β channel.messageComposer
Composers for message/legacy_thread contexts are cached in client.messageComposerCache by tag, and registerSubscriptions() is bound to the component lifecycle. Draft/attachment/poll/command state is owned by the SDK class, not React state β read it with useStateStore.
Files: src/components/Channel/Channel.tsx, src/components/Channel/channelState.ts
- Messages enter local state IMMEDIATELY on send (optimistic)
- WebSocket events may arrive before or after the API response
- Timestamp-based conflict resolution: the newest version wins
- Gotcha: thread state is separate from channel state β both must be updated
File: src/components/Channel/Channel.tsx (handleEvent)
// Events are THROTTLED to 500ms to prevent excessive re-renders
const throttledCopyStateFromChannel = throttle(
() => dispatch({ channel, type: 'copyStateFromChannelOnEvent' }),
500,
{ leading: true, trailing: true },
);- Some events are ignored (e.g.
user.watching.start/stop) - Unread UI state updates throttled separately (200ms)
markReadthrottled 500ms with{ leading: true, trailing: false }β fires on the FIRST call onlyloadMore/loadMoreNewercompletion debounced 2000ms- Message visibility in threads is decided by
parent_id+show_in_channel
File: src/components/MessageList/utils.ts (processMessages)
Per message, in order: deleted messages filtered (hideDeletedMessages) β giphy ephemeral preview extracted (setGiphyPreviewMessage, VirtualizedMessageList) β unread separator (skipped for the current user's own messages) β date separator inserted (first message, date change, or when hidden deleted messages shifted the last rendered date) β reviewProcessedMessage hook may rewrite the emitted slice.
Date separators are enabled in MessageList and disabled in VirtualizedMessageList and threads by default. Group styling (getGroupStyles) is applied separately, keyed on user ID + time gaps.
Gotcha: with hideDeletedMessages=true, a date separator is still required when the next rendered message falls on a different date than the last separator.
Files: src/components/MessageList/VirtualizedMessageList.tsx, VirtualizedMessageListComponents.tsx
- Built on react-virtuoso with custom item sizing
- Offset trick:
PREPEND_OFFSET = 10 ** 7lets prepended messages work without Virtuoso knowing (calculateItemIndex/calculateFirstItemIndex) - Only visible items + overscan render
skipMessageDataMemoizationexists for channels with thousands of messages
ThreadList and ChannelDetail lists are virtualized too β see src/a11y/hooks/useVirtualizedListboxKeyboardNavigation.ts for the keyboard-nav contract those lists must honor.
useCreateChannelStateContextserializes message data to a string for comparison (type,deleted_at, reaction types,pinned,reply_count,status,updated_at,user.updated_at). Any field not in that serialization will not trigger updates β a known fragility, flagged with a FIXME in the source.areMessageUIPropsEqual(src/components/Message/utils.tsx) checks cheap props first (highlighted,threadList,endOfGroup,mutes.length,readBy.length,deliveredTo.length,groupStyles) before deep message comparison.
- Mutate
channel.state.messagesdirectly β usechannel.state.addMessageSorted()/removeMessage() - Include
channelin dependency arrays β usechannel.cid(stable), neverchannel.state(changes constantly) - Modify reducer action types without updating all dispatchers β they are tightly coupled
- Change message sort order β the SDK maintains order; local changes conflict
- Forget to update both channel AND thread state β thread messages must exist in main state too
- Main channel:
state.messages(flat list) - Threads:
channel.state.threads[parentId](keyed by parent message ID) - Invariant: messages in threads MUST also exist in main channel state
The SDK supports React 17, 18, 19. Enforced by the react-compat block in eslint.config.mjs β forbidden in src/:
useIdfromreactβ useuseStableIdfromsrc/components/UtilityComponents/useStableIduseSyncExternalStorefromreactβ use the shim fromuse-sync-external-store/shimuseEffectEvent,use()β React 19-only, not allowedrefin a prop type (TSPropertySignature[key.name='ref']) or destructured from props β useforwardRef(React 17/18 only deliverrefto forwardRef'd components)
Compatibility is lint-enforced only; there is no type/runtime matrix across React versions.
useMemo(
() => ({
/* value */
}),
[
channel.cid, // β
Stable - include this
deleteMessage, // β
Stable callback
// β NOT channel.state.messages - causes infinite re-renders
// β NOT channel.initialized - changes constantly
],
);Policy: add or extend tests in the matching module's __tests__/ folder. Cover React components, hooks, and utility functions. Reuse the repo's fakes/mocks instead of hand-rolling new ones.
Runner: Vitest (vitest.config.ts) β globals: true (no imports needed for describe/it/expect/vi), jsdom, pool: 'forks', testTimeout: 15000, css: false, tests matched at src/**/*.test.{js,jsx,ts,tsx}. vitest.setup.ts forces TZ=UTC, registers @testing-library/jest-dom/vitest + vitest-axe matchers, and polyfills crypto, structuredClone, File, FileReader, URL.createObjectURL, matchMedia, and canvas getContext.
Import test helpers from src/mock-builders (also aliased as mock-builders):
// Fastest path: client + watched channels in one call
const {
client,
channels: [channel],
} = await initClientWithChannels();
// Manual setup when you need control over the API responses
const client = await getTestClientWithUser({ id: 'test-user' });
useMockedApis(client, [getOrCreateChannelApi(mockedChannelData)]);
const channel = client.channel('messaging', channelId);
await channel.watch();src/mock-builders/generator/βgenerateChannel,generateMessage,generateUser,generateMember,generatePoll,generateMessageDraft,generateReminder,generateSharedLocation, β¦src/mock-builders/api/β response builders (getOrCreateChannelApi,queryChannelsApi,sendMessageApi,markReadApi,threadRepliesApi, error helpers);useMockedApisspies onclient.axiosInstancesrc/mock-builders/event/βdispatchMessageNewEvent,dispatchNotificationMarkUnread, β¦src/mock-builders/context.tsβmockChatContext,mockChannelStateContext, β¦ built withfromPartialfrom@total-typescript/shoehornsrc/mock-builders/browser/βMediaRecorder,AudioContext,AnalyserNode,ResizeObserver,HTMLMediaElementfakes- Accessibility:
import { axe } from '<relative>/axe-helper'(rootaxe-helper.jswrapsconfigureAxe), thenexpect(await axe(container)).toHaveNoViolations()
Component render shape:
render(
<Chat client={chatClient}>
<Channel channel={channel}>
<MessageList />
</Channel>
</Chat>,
);Mock modules with vi.mock('../../EmptyStateIndicator', () => ({ β¦ })); use importOriginal<typeof import('β¦')>() to partially mock. Mock methods on the channel/client, never replace the whole object.
yarn build = yarn clean + 4 steps in parallel via concurrently, each writing to a separate dist/ subdirectory:
build-translationsβi18next-cli extractpullst()calls from source intosrc/i18n/*.jsonvite buildβ bundles 4 entry points as ESM (dist/es/*.mjs) + CJS (dist/cjs/*.js)tsc -p tsconfig.lib.jsonβ.d.tsonly βdist/types/build-stylingβ Sass βdist/css/index.css,emoji-replacement.css,emoji-picker.css,channel-detail.css, pluscp -r src/styling/assets dist/css/assets
Entry points (package.json exports β vite.config.ts lib.entry):
| Import path | Source |
|---|---|
stream-chat-react |
src/index.ts |
stream-chat-react/channel-detail |
src/plugins/ChannelDetail/ |
stream-chat-react/emojis |
src/plugins/Emojis/ |
stream-chat-react/mp3-encoder |
src/plugins/encoders/mp3.ts |
stream-chat-react/css/* |
dist/css/* |
Vite 8 / Rolldown specifics baked into vite.config.ts (do not "simplify" these):
- Output dirs are hardcoded to
es/cjsβ the[format]placeholder expands toesmunder Rolldown, which would breakpackage.jsonexports - Externals are regexes (
^dep(\/.+)?$) so subpath imports (dayjs/locale/de) stay external; otherwise CJSrequire()glue leaks into the ESM output - No minification, sourcemaps on, target from
tsconfig.lib.json(es2020), all deps/peerDeps externalized - Rolldown's strict CJS interop means default-imported CJS deps may need
.defaultunwrapping at the call site
All styles live in src/styling/ (entry: src/styling/index.scss) and per-component src/components/*/styling/index.scss, @used by the master stylesheet. Nothing is pulled from an external design-system package. Never edit compiled CSS.
Consumers order layers so overrides win without !important. Reference implementation β examples/vite/src/index.scss:
@layer modern-normalize, stream-new, stream-new-plugins, stream-overrides, stream-app-overrides;
@import url('modern-normalize') layer(modern-normalize);
@import url('stream-chat-react/dist/css/index.css') layer(stream-new);
@import url('stream-chat-react/dist/css/emoji-picker.css') layer(stream-new-plugins);
@import url('stream-chat-react/dist/css/channel-detail.css') layer(stream-new-plugins);- Primitives β
src/styling/variables/(fonts, shadows) + Figma-sourced palette tokens - Semantic tokens β
src/styling/variable-tokens.scsswithlight.scss/dark.scssmappings (e.g.--str-chat__primary-color,--str-chat__text-color) - Component tokens β per-component SCSS (e.g.
--str-chat__message-bubble-background-color)
- 12 locales in
src/i18n/*.json: de, en, es, fr, hi, it, ja, ko, nl, pt, ru, tr - Keys are English text:
t('Mute'),t('{{ user }} is typing...') i18next.config.tssetskeySeparator: falseandnsSeparator: false, so keys may contain/and:literally (e.g.timestamp/DateSeparator).timestamp/*keys are listed underpreservePatternsand are not pruned;removeUnusedKeys: false- Extraction:
yarn build-translations(scanssrc/**/*.{ts,tsx}, ignores__tests__andmock-builders) - Validation:
yarn validate-translationsruns insideyarn lintand in CI β zero tolerance for empty translation values Streami18n(src/i18n/Streami18n.ts) wraps i18next + Dayjs with per-locale calendar formats; accesstviauseTranslationContext()(only works inside<Chat>)- Adding a string: use
t()β runyarn build-translationsβ fill in all 12 files
src/a11y/ holds cross-component a11y primitives: useAriaIdentifiers, useListboxKeyboardNavigation, useVirtualizedListboxKeyboardNavigation, useResolvedModalAriaProps, plus accessibleLabel.ts / a11yUtils.ts. Related components: Accessibility/, SkipNavigation/, VisuallyHidden/. New interactive UI should reuse these hooks and ship an axe assertion in its tests.
Tightest coupling:
Message.tsxβMessageContextβ every message needs actionsChannel.tsxβVirtualizedMessageListβ complex prop drillinguseCreateChannelStateContextβ message memoization β string-serialization fragilityMessageComposerβstream-chat'sMessageComposerclass +client.messageComposerCache
Integration risks: reducer action changes ripple across dispatchers; message sorting changes conflict with SDK updates; thread state isolation is error-prone.
ComponentName/
βββ ComponentName.tsx
βββ hooks/ # Component-specific hooks
βββ styling/ # SCSS (index.scss aggregates)
βββ utils/ or utils.ts
βββ __tests__/
βββ index.ts
Component-specific hooks stay in the component's hooks/: Channel/hooks/ (state context, typing, editing), Message/hooks/ (delete, pin, flag, react, retry, reminders), MessageComposer/hooks/ (controller, bindings, submit, attachments, cooldown), MessageList/hooks/ (scroll, mark-read, last-read/delivered).
Lint rules worth knowing (enforced with --max-warnings 0): sort-keys, sort-destructure-keys, react/jsx-sort-props, @typescript-eslint/consistent-type-imports, react-hooks/exhaustive-deps as error, no non-null assertions in src/ (relaxed in tests).
Run yarn lint-fix before every commit. Follow the "zero warnings" policy β fix new warnings, never introduce any.
Conventional Commits, enforced by commitlint via the commit-msg husky hook:
feat(MessageComposer): add audio recording support
Implement MediaRecorder API integration with MP3 encoding.
Closes #123
- Avoid
BREAKING CHANGEfooters and!β ship changes as semver minors. - Never commit directly to
master; always create a feature branch (seedevelopers/BRANCHES.md). - Never commit unless explicitly requested.
The pre-commit hook runs lint-staged: eslint (--max-warnings 0) on staged src/**, prettier --list-different on all supported files, and translation validation on src/i18n/*.json. yarn fix-staged attempts auto-fix.
Follow .github/pull_request_template.md (Goal / Implementation details / UI Changes). Keep PRs small and focused; include tests.
-
yarn lint-fixpassed -
yarn testpassed -
yarn typespassed (and no new errors fromyarn types:tests) - Tests added for changes
- No new warnings (zero tolerance)
- Screenshots (before/after) for UI changes
- Public API changes documented
CI (.github/workflows/ci.yml): lint Β· build + validate-cjs + validate-esm + validate-translations Β· yarn coverage β Codecov Β· deploy examples/vite to Vercel.
Release: automated via semantic-release (.releaserc.json) from commit messages.
Use the @deprecated JSDoc tag with a reason and docs link; commit under the deprecate type. Full process in developers/DEPRECATIONS.md.
When altering public API, update inline docs and any affected guide pages where this repo is the source of truth. Keep sample/snippet code compilable.
Never commit API keys or customer data. Example code must use obvious placeholders (e.g. YOUR_STREAM_KEY). Scripts must fail closed on missing env vars.
Mirror existing patterns in the nearest module. Prefer additive changes; avoid breaking public APIs. Ask maintainers (CODEOWNERS) through PR mentions for modules you touch.
- Development guides:
developers/ - Component docs: https://getstream.io/chat/docs/sdk/react/
- Stream Chat API: https://getstream.io/chat/docs/javascript/
- Stream agent skills (installed via
getstream init): https://getstream.io/agent-skills/docs/installation/
End of machine guidance. Edit this file to refine agent behavior over time; keep human-facing details in README.md and the docs site.