|
| 1 | +--- |
| 2 | +sidebar_position: 6 |
| 3 | +--- |
| 4 | + |
| 5 | +# Chat primitives |
| 6 | + |
| 7 | +Headless React building blocks for talking to the AppKit [`agents`](../../plugins/agents.md) plugin from the browser. The primitives are an opinionated wrapper over the [Vercel AI SDK](https://sdk.vercel.ai/) (`@ai-sdk/react`, `ai`) that translates the agents plugin's Responses-API SSE wire format into the SDK's `UIMessageChunk` protocol — so you get streaming text, reasoning, tool calls, approval gates, and thread continuity without writing a server-side adapter. |
| 8 | + |
| 9 | +Everything ships from a single subpath import: |
| 10 | + |
| 11 | +```ts |
| 12 | +import { |
| 13 | + Conversation, |
| 14 | + ChatInput, |
| 15 | + ResponsesApiTransport, |
| 16 | + useChat, |
| 17 | + useScrollToBottom, |
| 18 | +} from "@databricks/appkit-ui/react/chat"; |
| 19 | +``` |
| 20 | + |
| 21 | +`@ai-sdk/react` and `ai` are **optional peer dependencies**. Install them in your app if you use these primitives: |
| 22 | + |
| 23 | +```bash |
| 24 | +pnpm add @ai-sdk/react ai |
| 25 | +``` |
| 26 | + |
| 27 | +## Quick start |
| 28 | + |
| 29 | +The smallest end-to-end chat against an `agents()`-backed AppKit server: |
| 30 | + |
| 31 | +```tsx |
| 32 | +import { |
| 33 | + ChatInput, |
| 34 | + Conversation, |
| 35 | +} from "@databricks/appkit-ui/react/chat"; |
| 36 | + |
| 37 | +export function Chat() { |
| 38 | + return ( |
| 39 | + <Conversation api="/api/agents/chat"> |
| 40 | + {({ messages, status, sendMessage, stop, containerRef }) => ( |
| 41 | + <div className="flex flex-col h-[600px] border rounded-lg"> |
| 42 | + <div ref={containerRef} className="flex-1 overflow-y-auto p-4 space-y-2"> |
| 43 | + {messages.map((m) => ( |
| 44 | + <div key={m.id} className={m.role === "user" ? "text-right" : "text-left"}> |
| 45 | + {m.parts.map((p, i) => |
| 46 | + p.type === "text" ? <p key={i}>{p.text}</p> : null, |
| 47 | + )} |
| 48 | + </div> |
| 49 | + ))} |
| 50 | + </div> |
| 51 | + <ChatInput onSubmit={sendMessage} status={status} stop={stop}> |
| 52 | + {({ value, onChange, submit, isStreaming, canSubmit, handleKeyDown }) => ( |
| 53 | + <form onSubmit={submit} className="border-t p-3 flex gap-2"> |
| 54 | + <input |
| 55 | + value={value} |
| 56 | + onChange={(e) => onChange(e.target.value)} |
| 57 | + onKeyDown={handleKeyDown} |
| 58 | + disabled={isStreaming} |
| 59 | + placeholder="Ask a question…" |
| 60 | + className="flex-1" |
| 61 | + /> |
| 62 | + <button type="submit" disabled={!canSubmit}>Send</button> |
| 63 | + </form> |
| 64 | + )} |
| 65 | + </ChatInput> |
| 66 | + </div> |
| 67 | + )} |
| 68 | + </Conversation> |
| 69 | + ); |
| 70 | +} |
| 71 | +``` |
| 72 | + |
| 73 | +That single component: |
| 74 | + |
| 75 | +- Posts to `POST /api/agents/chat` (the agents plugin's default route). |
| 76 | +- Streams Responses-API SSE back, translates each event to a `UIMessageChunk` on the fly, and feeds the AI SDK reducer. |
| 77 | +- Captures the server-allocated `threadId` from `appkit.metadata` events and replays it on every subsequent request — multi-turn conversations Just Work, no wiring required. |
| 78 | +- Auto-sticks the scroll container to the bottom as long as the user hasn't scrolled up. |
| 79 | + |
| 80 | +## How it fits together |
| 81 | + |
| 82 | +``` |
| 83 | +┌──────────────────────────────────────────────────────────┐ |
| 84 | +│ <Conversation> │ |
| 85 | +│ ├─ useChat() ── stable id + threadId ref │ |
| 86 | +│ │ └─ ResponsesApiTransport │ |
| 87 | +│ │ ├─ prepareSendMessagesRequest │ |
| 88 | +│ │ │ → { message, threadId?, ...extras } │ |
| 89 | +│ │ └─ processResponseStream │ |
| 90 | +│ │ SSE bytes → text → events → UIChunks │ |
| 91 | +│ └─ useScrollToBottom() ── containerRef + auto-stick │ |
| 92 | +│ │ |
| 93 | +│ <ChatInput> │ |
| 94 | +│ value / submit / canSubmit / handleKeyDown / stop │ |
| 95 | +└──────────────────────────────────────────────────────────┘ |
| 96 | +``` |
| 97 | + |
| 98 | +The `Conversation` component is a thin render-prop convenience over `useChat` + `useScrollToBottom`. Reach for the underlying hooks when you need a non-standard layout (split panes, virtualized lists, etc). |
| 99 | + |
| 100 | +## `useChat` |
| 101 | + |
| 102 | +A minimal wrapper around the AI SDK's `useChat` that wires up `ResponsesApiTransport` and stabilizes the chat `id` and `threadId` across re-renders. |
| 103 | + |
| 104 | +```ts |
| 105 | +const chat = useChat<AgentUIMessage>({ |
| 106 | + api: "/api/agents/chat", |
| 107 | + onData: (part) => { /* react to data-* parts */ }, |
| 108 | + onError: (err) => console.error(err), |
| 109 | +}); |
| 110 | +``` |
| 111 | + |
| 112 | +### Options |
| 113 | + |
| 114 | +| Option | Type | Required | Description | |
| 115 | +|----------------|-----------------------------------------|----------|-------------| |
| 116 | +| `api` | `string` | ✓ | Chat endpoint URL (typically `/api/agents/chat`). | |
| 117 | +| `id` | `string` | | Stable chat id. Defaults to a fresh UUID per mount. | |
| 118 | +| `messages` | `TMessage[]` | | Initial messages (e.g. when hydrating from history). | |
| 119 | +| `headers` | fetch headers / accessor | | Extra request headers forwarded to the transport. | |
| 120 | +| `onData` | `(part) => void` | | Fires for every `data-*` chunk. Use this to react to AppKit-specific data parts like `data-approval-pending`. | |
| 121 | +| `onStreamPart` | `(chunk: UIMessageChunk) => void` | | Fires synchronously for every translated chunk before the reducer sees it. Ideal for debug panels and stream logs — unaffected by render throttling. | |
| 122 | +| `onError` | `(error: Error) => void` | | Network, transport, or server-side stream errors. | |
| 123 | + |
| 124 | +### Returns |
| 125 | + |
| 126 | +The full [`UseChatHelpers`](https://sdk.vercel.ai/docs/reference/ai-sdk-ui/use-chat) surface (`messages`, `status`, `error`, `sendMessage`, `stop`, …) plus a stable `id`. |
| 127 | + |
| 128 | +### Thread continuity |
| 129 | + |
| 130 | +The first SSE event of a new conversation carries an `appkit.metadata` payload with the server-allocated `threadId`. The hook stashes it in a ref, then `ResponsesApiTransport` echoes it on subsequent requests — even if the transport is re-created mid-session (e.g. because `headers` is passed as an inline object literal). You don't need to manage thread ids yourself. |
| 131 | + |
| 132 | +## `useScrollToBottom` |
| 133 | + |
| 134 | +Tracks whether a scroll container is at the bottom and exposes an imperative `scrollToBottom`. When `trigger` changes and the user was already at the bottom, the container auto-sticks — preserving the typical chat "follow latest" behavior without overriding manual scroll-up. |
| 135 | + |
| 136 | +```ts |
| 137 | +const { containerRef, isAtBottom, scrollToBottom } = |
| 138 | + useScrollToBottom<HTMLDivElement>({ trigger: messages }); |
| 139 | +``` |
| 140 | + |
| 141 | +| Option | Type | Default | Description | |
| 142 | +|-------------|-----------|---------|-------------| |
| 143 | +| `threshold` | `number` | `50` | Pixels from the bottom that still count as "at bottom". | |
| 144 | +| `trigger` | `unknown` | — | Reactive value (usually `messages`) that triggers an auto-stick when the user is at the bottom. | |
| 145 | + |
| 146 | +`scrollToBottom(behavior)` defaults to `"smooth"`. The auto-stick path uses `"instant"` to avoid piling up smooth-scroll animations under throttled streaming re-renders. |
| 147 | + |
| 148 | +## `<Conversation>` |
| 149 | + |
| 150 | +Render-prop component that combines `useChat` and `useScrollToBottom` for the common case. Accepts every `useChat` option as a top-level prop and exposes both hooks' return values to its `children` callback. |
| 151 | + |
| 152 | +```tsx |
| 153 | +<Conversation<AgentUIMessage> |
| 154 | + api="/api/agents/chat" |
| 155 | + onData={(part) => {/* … */}} |
| 156 | +> |
| 157 | + {({ messages, status, sendMessage, stop, containerRef, isAtBottom, scrollToBottom }) => ( |
| 158 | + /* … */ |
| 159 | + )} |
| 160 | +</Conversation> |
| 161 | +``` |
| 162 | + |
| 163 | +If you need to drive the chat from outside the render tree (e.g. an external "Send" button), drop down to `useChat` directly. |
| 164 | + |
| 165 | +## `<ChatInput>` |
| 166 | + |
| 167 | +Render-prop component that owns the input string, debounces submit, handles `Enter`/`Shift+Enter`/IME composition, and forwards an `isStreaming` flag for stop-button toggles. |
| 168 | + |
| 169 | +```tsx |
| 170 | +<ChatInput<AgentUIMessage> onSubmit={sendMessage} status={status} stop={stop}> |
| 171 | + {({ value, onChange, submit, isStreaming, stop, canSubmit, handleKeyDown }) => ( |
| 172 | + <form onSubmit={submit}> |
| 173 | + <textarea |
| 174 | + value={value} |
| 175 | + onChange={(e) => onChange(e.target.value)} |
| 176 | + onKeyDown={handleKeyDown} |
| 177 | + disabled={isStreaming} |
| 178 | + /> |
| 179 | + {isStreaming |
| 180 | + ? <button type="button" onClick={stop}>Stop</button> |
| 181 | + : <button type="submit" disabled={!canSubmit}>Send</button>} |
| 182 | + </form> |
| 183 | + )} |
| 184 | +</ChatInput> |
| 185 | +``` |
| 186 | + |
| 187 | +| Prop | Type | Description | |
| 188 | +|------------|-------------------------------------------|-------------| |
| 189 | +| `onSubmit` | `UseChatHelpers<TMessage>["sendMessage"]` | Pass `sendMessage` straight through from `useChat` / `Conversation`. | |
| 190 | +| `status` | `ChatStatus` | Pass `status` from the chat helpers — drives `isStreaming`. | |
| 191 | +| `stop` | `() => void` | Pass `stop` from the chat helpers — exposed back to the render prop for stop buttons. | |
| 192 | + |
| 193 | +The render prop returns a `submit` callback you can wire to either a form `onSubmit` or a button `onClick`. `Enter` submits (unless `Shift` is held or an IME is composing); the input clears on successful submit. |
| 194 | + |
| 195 | +## Typed messages |
| 196 | + |
| 197 | +Pass a `UIMessage` subtype to type `messages[].parts`, `onData`, and `sendMessage` end-to-end: |
| 198 | + |
| 199 | +```ts |
| 200 | +import type { UIMessage } from "ai"; |
| 201 | + |
| 202 | +type MyMessage = UIMessage<unknown, { |
| 203 | + "approval-pending": { approvalId: string; toolName: string }; |
| 204 | +}>; |
| 205 | + |
| 206 | +const chat = useChat<MyMessage>({ api: "/api/agents/chat" }); |
| 207 | +``` |
| 208 | + |
| 209 | +The data-parts map must be a `type` (not `interface`) to satisfy the SDK's `Record<string, unknown>` constraint. |
| 210 | + |
| 211 | +## Approval gates |
| 212 | + |
| 213 | +When the agents plugin emits an `appkit.approval_pending` SSE event (a destructive tool is paused on the server-side approval gate), the transport surfaces it as a `data-approval-pending` chunk. Subscribe via `onData`: |
| 214 | + |
| 215 | +```tsx |
| 216 | +<Conversation |
| 217 | + api="/api/agents/chat" |
| 218 | + onData={(part) => { |
| 219 | + if (part.type === "data-approval-pending") { |
| 220 | + const { approvalId, streamId, toolName, args } = part.data; |
| 221 | + // Render an approval card; POST the decision back to the plugin: |
| 222 | + // fetch("/api/agents/approve", { |
| 223 | + // method: "POST", |
| 224 | + // body: JSON.stringify({ streamId, approvalId, decision: "approve" }), |
| 225 | + // }); |
| 226 | + } |
| 227 | + }} |
| 228 | +> |
| 229 | + {/* … */} |
| 230 | +</Conversation> |
| 231 | +``` |
| 232 | + |
| 233 | +The chunk `id` is the `approvalId`, so duplicate events (re-renders, reconnects) collapse cleanly. |
| 234 | + |
| 235 | +## `ResponsesApiTransport` |
| 236 | + |
| 237 | +The `Conversation` / `useChat` flow is enough for almost every consumer. Drop down to the transport directly if you're composing it with a different React hook surface (e.g. plain `@ai-sdk/react`'s `useChat`) or you need to extend `prepareSendMessagesRequest` to carry attachments, agent ids, or other custom body fields. |
| 238 | + |
| 239 | +```ts |
| 240 | +import { ResponsesApiTransport } from "@databricks/appkit-ui/react/chat"; |
| 241 | +import { useChat } from "@ai-sdk/react"; |
| 242 | + |
| 243 | +const threadIdRef = useRef<string | undefined>(undefined); |
| 244 | + |
| 245 | +const transport = useMemo( |
| 246 | + () => |
| 247 | + new ResponsesApiTransport({ |
| 248 | + api: "/api/agents/chat", |
| 249 | + getThreadId: () => threadIdRef.current, |
| 250 | + onThreadId: (tid) => { threadIdRef.current = tid; }, |
| 251 | + }), |
| 252 | + [], |
| 253 | +); |
| 254 | + |
| 255 | +const chat = useChat({ transport }); |
| 256 | +``` |
| 257 | + |
| 258 | +### Constructor options |
| 259 | + |
| 260 | +Extends `HttpChatTransportInitOptions` (everything except `prepareSendMessagesRequest`, which is owned by the transport) with: |
| 261 | + |
| 262 | +| Option | Type | Description | |
| 263 | +|----------------|-------------------------------------|-------------| |
| 264 | +| `getThreadId` | `() => string \| undefined` | Read the persisted server thread id. Held outside the transport so re-creation doesn't fork the conversation. | |
| 265 | +| `onThreadId` | `(id: string) => void` | Persist the server thread id snooped from the first `appkit.metadata` event. | |
| 266 | +| `onStreamPart` | `(chunk: UIMessageChunk) => void` | Synchronous tap fired before each chunk reaches the SDK reducer. | |
| 267 | + |
| 268 | +### Wire format |
| 269 | + |
| 270 | +`prepareSendMessagesRequest` builds: |
| 271 | + |
| 272 | +```json |
| 273 | +{ |
| 274 | + "message": "<latest user-message text>", |
| 275 | + "threadId": "<server-allocated id, omitted on first request>", |
| 276 | + "...extras": "any non-reserved fields you pass via `body`" |
| 277 | +} |
| 278 | +``` |
| 279 | + |
| 280 | +`message` and `threadId` are reserved — values you pass via the `body` option for those keys are ignored. Any other fields you set on `body` are passed through (useful for routing to a specific agent: `body: { agent: "support" }`). |
| 281 | + |
| 282 | +### Event translation |
| 283 | + |
| 284 | +| Server (`ResponseStreamEvent`) | Client (`UIMessageChunk`) | |
| 285 | +|-------------------------------------------------------|------------------------------------------------------| |
| 286 | +| `response.output_item.added` (`type: message`) | `text-start` | |
| 287 | +| `response.output_text.delta` | `text-delta` | |
| 288 | +| `response.output_item.done` (`type: message`) | `text-end` | |
| 289 | +| `response.output_item.added` (`function_call`) | `tool-input-available` | |
| 290 | +| `response.output_item.added` (`function_call_output`) | `tool-output-available` | |
| 291 | +| `appkit.thinking` | `reasoning-start` (lazy) + `reasoning-delta` | |
| 292 | +| `appkit.metadata` | `message-metadata` (and captures `threadId`) | |
| 293 | +| `appkit.approval_pending` | `data-approval-pending` | |
| 294 | +| `error` / `response.failed` | `error` + `finish` | |
| 295 | +| `response.completed` | `finish` | |
| 296 | + |
| 297 | +A defensive `finish` is emitted in the transform's `flush` callback so the SDK leaves the streaming state even if the server's terminal event is lost (disconnect, timeout). |
| 298 | + |
| 299 | +## Limitations |
| 300 | + |
| 301 | +- **Text-only user messages.** The agents plugin's `chatRequestSchema` accepts `message: string` only. The transport `console.warn`s and drops non-text parts (images, files) from the latest user message. Subclass `ResponsesApiTransport` and override `prepareSendMessagesRequest` to carry attachments via a parallel field, or use a different server endpoint. |
| 302 | +- **Beta peer dependencies.** This release is pinned to `@ai-sdk/react@~4.0.0-beta` and `ai@~7.0.0-beta`. The wire-translator may need adjustments when the AI SDK reaches stable. |
0 commit comments