Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/rsc-namespace-exports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@intentface/chat": minor
---

**Breaking:** primitives are now namespace exports, and the root part is explicit. `<Composer>` becomes `<Composer.Root>`, `<Message>` becomes `<Message.Root>`, and likewise for `Thread`, `Steps`, `Reasoning`, `Chip`, `Attachments` and `AskUser`. Sub-component names (`Composer.Container`, `Message.Text`, …), hooks, and type names are unchanged, so the migration is mechanical:

```diff
-<Message role="assistant">
+<Message.Root role="assistant">
<Message.Text>{text}</Message.Text>
-</Message>
+</Message.Root>
```

This makes the primitives usable from React Server Components. Previously every sub-component resolved to `undefined` in a server component, failing the render with `Element type is invalid… but got: undefined` — a server component importing a `"use client"` module receives a proxy of its *named exports* and cannot read properties off an exported value, which is where `Object.assign` put them. Rendering a static transcript from a server component now works; the parts remain client components, so interactive props and hooks behave exactly as before.

The package also now ships unbundled, one module per source file, because a bundle can carry only one top-level `"use client"` directive and that collapses the per-part boundaries the above depends on. Two incidental wins from building with tsc: declarations are inferred properly (the previous isolated-declarations emit erased every compound component to `unknown`) and the production `react/jsx-runtime` is always used (the previous build emitted the development runtime, which throws `jsxDEV is not a function` in a consumer's production build).
32 changes: 27 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ This file provides guidance to coding agents when working with code in this repo

### Component Architecture

**Every component in `components/ai/` and `components/ui/` must follow the compound component pattern.** Components expose sub-components as static properties via `Object.assign`, giving consumers full control over composition and layout.
Two layers, two different mechanisms for the same compound-component shape. Which one applies depends on where the component lives.

#### App layer — `components/ai/`, `components/ui/`

**Every component here must follow the compound component pattern**, exposing sub-components as static properties via `Object.assign`. These are single-file, copy-pasteable shadcn-style components, and one file to copy is worth more than server-component reach. They are the interactive layer; consumers render them from client components.

```tsx
// Usage — consumer composes the pieces
Expand Down Expand Up @@ -55,7 +59,25 @@ Rules for compound components:
- **Accept `className`** on every sub-component for style overrides
- **Convenience wrappers are fine** — a higher-level component can compose the primitives with default behavior (e.g. `Composer.Attachments` composes `Attachments`, `Attachments.Item`, `Attachments.Remove`)

This pattern is used throughout: `Message`, `Composer`, `Attachments`, `Tooltip`, `Conversation`, `Thread`, etc.
#### Package layer — `packages/chat/`

Published primitives use **namespace exports**, not `Object.assign`, and the root is explicit: `<Composer.Root>`, never `<Composer>`. Three modules per primitive:

```
src/message/message.tsx "use client" — MessageRoot, MessageText, …
src/message/index.parts.ts no directive — export { MessageRoot as Root, … } from "./message"
src/message/index.ts no directive — export * as Message from "./index.parts"
+ flat type / hook re-exports
```

`Object.assign` puts sub-components on an exported *value*. A server component importing a `"use client"` module receives a proxy of its **named exports** and cannot read properties off a value, so `Message.Text` resolves to `undefined` and React throws `Element type is invalid… but got: undefined`. Named exports cross the boundary; property access does not. Namespace re-export through a directive-free layer keeps `Message.Text` statically resolvable.

Two constraints follow, and both are load-bearing:

- **`index.ts` and `index.parts.ts` must never carry `"use client"`.** The directive belongs on the component module one level down. Adding it to either barrel silently reintroduces the bug.
- **The build must not bundle.** One file gets one top-level directive, so bundling collapses the boundary. `tsconfig.build.json` emits per-module via tsc for exactly this reason — see the comment there before changing it.

This pattern is used throughout: `Message`, `Composer`, `Attachments`, `Chip`, `Thread`, `Steps`, `Reasoning`, `AskUser`.

### AI Integration

Expand All @@ -74,15 +96,15 @@ The chat API follows Vercel AI SDK conventions
2. Avoid `useEffect` for syncing/deriving state. Use it only for true side effects (subscriptions, DOM integrations).
3. Use standard size naming: `xs`, `sm`, `md`, `lg`, `xl`.
4. Organize CVA base classes with arrays/comments when classes are long.
5. Every component in `components/ai/` and `components/ui/` must use the compound component pattern (see Component Architecture above).
5. Every component must use the compound component pattern — via `Object.assign` in `components/ai/` and `components/ui/`, via namespace exports in `packages/chat/` (see Component Architecture above).
6. Use `cn()` from `lib/utils.ts` for className merging.
7. Follow Biome rules and formatting.
8. Use data attributes for styling and state selectors: app components (`components/ai`, `components/ui`) stamp `data-slot` / `data-role`; package primitives (`packages/chat`) emit bespoke part attributes instead (`data-composer-editor`, `data-command-badge`) — `data-slot` belongs to the consumer layer.
9. Leverage Motion for entrance/exit animations.
10. Rich text editing goes through `Composer` from `@intentface/chat/composer` — no editor framework; don't add one.
11. Follow AI SDK patterns (`useChat()`, `streamText()`, `toUIMessageStreamResponse()`).
12. No monolithic components — always decompose into composable sub-components with `Object.assign`. Consumers compose the pieces; components never hardcode their own layout.
13. Do not use index/barrel files (`index.ts` that re-exports from other files). Import directly from the specific module instead.
12. No monolithic components — always decompose into composable sub-components. Consumers compose the pieces; components never hardcode their own layout.
13. Do not use index/barrel files (`index.ts` that re-exports from other files). Import directly from the specific module instead. **Exception:** each `packages/chat/src/<primitive>/` has exactly two barrels — `index.parts.ts` and `index.ts` — which are required for server-component reach and must stay directive-free.

## Environment Variables

Expand Down
48 changes: 24 additions & 24 deletions CHAT_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ useChat() aggregates parts → messages: AppUIMessage[]
Chat.onFinish → useChatStore.setMessages(chatId, …) [localStorage]
<Thread> renders grouped <Message.Turn>s; the panel reflects derived state
<Thread.Root> renders grouped <Message.Turn>s; the panel reflects derived state
```

## UIMessage parts
Expand Down Expand Up @@ -74,7 +74,7 @@ export const Composer = Object.assign(ComposerRoot, {
The nesting hierarchy at a glance:

```tsx
<Composer>
<Composer.Root>
<Composer.Panel>
<Composer.PanelItem>
<Composer.CommandList>
Expand Down Expand Up @@ -106,12 +106,12 @@ The nesting hierarchy at a glance:
<Composer.Submit />
</Composer.Actions>
</Composer.Container>
</Composer>
</Composer.Root>
```

### Root — `<Composer>`
### Root — `<Composer.Root>`

Owns the editor, attachment state, command/mention popovers, and the ask-user (questionnaire) state machine. `<Composer>` is itself the provider: each mount creates and owns a store, and parts resolve it from context via `useComposer()` — there is **no `ref`**. To drive a composer from outside its tree (toolbars, shortcut handlers), create the store yourself with `Composer.createStore()`, pass it as the `store` prop, and read it with `useComposerStore(store, selector)` or imperatively through `store.controller`.
Owns the editor, attachment state, command/mention popovers, and the ask-user (questionnaire) state machine. `<Composer.Root>` is itself the provider: each mount creates and owns a store, and parts resolve it from context via `useComposer()` — there is **no `ref`**. To drive a composer from outside its tree (toolbars, shortcut handlers), create the store yourself with `Composer.createStore()`, pass it as the `store` prop, and read it with `useComposerStore(store, selector)` or imperatively through `store.controller`.

```tsx
export type ComposerRootProps = Omit<ComponentProps<"form">, "onSubmit" | "ref"> & {
Expand Down Expand Up @@ -208,7 +208,7 @@ When in ask-user mode, swap the `Actions` row from the standard layout to `<AskU
Pass a `commands` map to the root:

```tsx
<Composer
<Composer.Root
commands={{
"@": { kind: "insert", trigger: "word-boundary", items: MENTION_ITEMS },
"/": { kind: "execute", trigger: "doc-start", items: COMMAND_ITEMS },
Expand Down Expand Up @@ -254,7 +254,7 @@ The composer runs on a purpose-built contenteditable engine (`packages/chat/src/
- **DOM reconciliation** (`editor-dom.ts`) — renders the canonical child list, reusing chip spans by id so a moved chip keeps its React portal instead of remounting.
- **Command triggers** (`prefix-detection.ts` + `trigger-tracker.ts`) — a pure scan derives the active token from the text around the caret; the tracker layers sticky range tracking and dismissal memory on top, mapping positions forward through each edit. Fuzzy scoring favours prefix matches over scattered matches, and consecutive-character runs over single matches.

Chips are atomic inline `contenteditable=false` spans carrying `prefix` / `label` / `value` / `icon`; a React portal renders a `<Chip>` into each.
Chips are atomic inline `contenteditable=false` spans carrying `prefix` / `label` / `value` / `icon`; a React portal renders a `<Chip.Root>` into each.

#### Chip wire format

Expand Down Expand Up @@ -286,18 +286,18 @@ export const Thread = Object.assign(ThreadRoot, {
### Skeleton

```tsx
<Thread>
<Thread.Root>
<Thread.Overlay direction="top" />
<Thread.Viewport>
<Thread.Placeholder />
{/* messages render here */}
</Thread.Viewport>
<Thread.Composer>
<Thread.ScrollButton />
{/* <Composer> goes here */}
{/* <Composer.Root> goes here */}
</Thread.Composer>
<Thread.Overlay direction="bottom" />
</Thread>
</Thread.Root>
```

| Primitive | Role |
Expand Down Expand Up @@ -330,16 +330,16 @@ export const Message = Object.assign(MessageRoot, {

```tsx
<Message.Turn>
<Message role="user" isLast isError={false}>
<Message.Root role="user" isLast isError={false}>
<Message.Attachments>
<Message.Attachment />
</Message.Attachments>
<Message.Content>
<Message.Text />
</Message.Content>
</Message>
</Message.Root>

<Message role="assistant" isLast isError={false}>
<Message.Root role="assistant" isLast isError={false}>
<Message.Content>
<Message.Markdown>
<Message.Chip />
Expand All @@ -355,7 +355,7 @@ export const Message = Object.assign(MessageRoot, {
<Message.Copy />
</Message.Actions>
<Message.SelectionToolbar onAdd={…} />
</Message>
</Message.Root>
</Message.Turn>
```

Expand Down Expand Up @@ -387,10 +387,10 @@ The renderer walks a message's parts: user `text` → `Message.Text`, assistant
Collapsible block for `reasoning` parts.

```tsx
<Reasoning isStreaming={…} duration={…}>
<Reasoning.Root isStreaming={…} duration={…}>
<Reasoning.Trigger label={headers} />
<Reasoning.Content>{texts}</Reasoning.Content>
</Reasoning>
</Reasoning.Root>
```

| Piece | Notes |
Expand All @@ -414,7 +414,7 @@ export const Steps = Object.assign(StepsRoot, {
### Skeleton

```tsx
<Steps>
<Steps.Root>
<Steps.Header />
<Steps.Content>
<Steps.Step>
Expand All @@ -427,7 +427,7 @@ export const Steps = Object.assign(StepsRoot, {
<Steps.ToolCall />
<Steps.AskUser />
</Steps.Content>
</Steps>
</Steps.Root>
```

`Steps` is a `Collapsible` that renders a chronological list of in-flight or completed work items. `Steps.Step` takes a `label` + `status` (`"complete" | "active" | "pending"`) and is a static row when it has no children, a nested collapsible when it does.
Expand Down Expand Up @@ -560,22 +560,22 @@ const ChatSurface = ({ chatId }: { chatId: string }) => {
const [tools, setTools] = useState({ webSearch: false, thinking: false });

return (
<Thread autoScroll="follow">
<Thread.Root autoScroll="follow">
<Thread.Overlay direction="top" />
<Thread.Viewport>
{groupTurns(messages).map((turn) => (
<Message.Turn key={turn.key}>
{turn.messages.map((m, i) => (
<Message key={m.id} role={m.role} isLast={i === turn.messages.length - 1} isError={false}>
<Message.Root key={m.id} role={m.role} isLast={i === turn.messages.length - 1} isError={false}>
{/* render m.parts → Message.Text / Markdown / Steps / Sources / … */}
</Message>
</Message.Root>
))}
</Message.Turn>
))}
</Thread.Viewport>
<Thread.Composer>
<Thread.ScrollButton />
<Composer
<Composer.Root
onSubmit={(data) => {
if (data.kind === "answers") {
if (panelState.type !== "ask-user") return;
Expand Down Expand Up @@ -617,10 +617,10 @@ const ChatSurface = ({ chatId }: { chatId: string }) => {
)}
</Composer.Actions>
</Composer.Container>
</Composer>
</Composer.Root>
</Thread.Composer>
<Thread.Overlay direction="bottom" />
</Thread>
</Thread.Root>
);
};
```
Expand Down
Loading
Loading