Skip to content

Commit c70492a

Browse files
committed
feat(appkit): agent chat UI primitives
Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
1 parent 4ee02ce commit c70492a

21 files changed

Lines changed: 2121 additions & 442 deletions

File tree

apps/dev-playground/client/src/routes/agent.route.tsx

Lines changed: 390 additions & 427 deletions
Large diffs are not rendered by default.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# ChatInput
2+
3+
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. Wire `onSubmit`, `status`, and `stop` from `useChat` / `Conversation`; the render prop returns a `submit` callback you can attach to either a form `onSubmit` or a button `onClick`.
4+
5+
6+
## ChatInput
7+
8+
Render-prop component that owns the input string, debounces submit,
9+
handles `Enter` / `Shift+Enter` / IME composition, and forwards an
10+
`isStreaming` flag for stop-button toggles. Wire `onSubmit`, `status`,
11+
and `stop` from `useChat` / `Conversation`; the render prop returns a
12+
`submit` callback you can attach to either a form `onSubmit` or a
13+
button `onClick`.
14+
15+
16+
**Source:** [`packages/appkit-ui/src/react/chat/headless/chat-input.tsx`](https://github.com/databricks/appkit/blob/main/packages/appkit-ui/src/react/chat/headless/chat-input.tsx)
17+
18+
19+
### Props
20+
21+
| Prop | Type | Required | Default | Description |
22+
|------|------|----------|---------|-------------|
23+
| `onSubmit` | `(message?: (Omit<TMessage, "id" \| "role"> & { id?: TMessage["id"] \| undefined; role?: TMessage["role"] \| undefined; } & { text?: undefined; files?: undefined; messageId?: string \| undefined; }) \| { ...; } \| { ...; } \| undefined, options?: ChatRequestOptions \| undefined) => Promise<...>` || - | Pass `sendMessage` straight through from `useChat` / `Conversation`. |
24+
| `status` | `enum` || - | Pass `status` from the chat helpers — drives `isStreaming`. |
25+
| `stop` | `() => void` || - | Pass `stop` from the chat helpers — exposed back to the render prop. |
26+
| `children` | `(props: ChatInputRenderProps) => ReactNode` || - | Render prop receiving the input state and submit/stop handlers. |
27+
28+
29+
30+
### Usage
31+
32+
```tsx
33+
import { ChatInput } from '@databricks/appkit-ui';
34+
35+
<ChatInput /* props */ />
36+
```
37+
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Conversation
2+
3+
Render-prop convenience over `useChat` + `useScrollToBottom`. Streams Responses-API SSE from an AppKit `agents()`-backed endpoint, captures the server-allocated `threadId` for multi-turn continuity, and auto-sticks the scroll container to the bottom while the user is at the bottom. Drop down to the underlying hooks for non-standard layouts (split panes, virtualized lists, external send buttons).
4+
5+
6+
## Conversation
7+
8+
Render-prop convenience over `useChat` + `useScrollToBottom`. Streams
9+
Responses-API SSE from an AppKit `agents()`-backed endpoint, captures
10+
the server-allocated `threadId` for multi-turn continuity, and
11+
auto-sticks the scroll container to the bottom while the user is at
12+
the bottom. Drop down to the underlying hooks for non-standard
13+
layouts (split panes, virtualized lists, external send buttons).
14+
15+
16+
**Source:** [`packages/appkit-ui/src/react/chat/headless/conversation.tsx`](https://github.com/databricks/appkit/blob/main/packages/appkit-ui/src/react/chat/headless/conversation.tsx)
17+
18+
19+
### Props
20+
21+
| Prop | Type | Required | Default | Description |
22+
|------|------|----------|---------|-------------|
23+
| `children` | `(props: ConversationRenderProps<TMessage>) => ReactNode` || - | Render prop receiving merged `useChat` + `useScrollToBottom` state. |
24+
| `api` | `string` || - | Chat endpoint URL (e.g. "/api/agents/chat"). |
25+
| `id` | `string` | | - | Stable chat id. Defaults to a fresh UUID per mount. |
26+
| `messages` | `TMessage[]` | | - | Initial messages (e.g. when hydrating from history). |
27+
| `headers` | `Resolvable<Record<string, string> \| Headers>` | | - | Extra fetch headers forwarded to the transport. |
28+
| `onData` | `ChatOnDataCallback<TMessage>` | | - | Fires for every `data-*` chunk (e.g. `data-approval-pending`). |
29+
| `onStreamPart` | `((chunk: UIMessageChunk) => void)` | | - | Fires synchronously for every chunk. Unaffected by render throttling. |
30+
| `onError` | `((error: Error) => void)` | | - | Called on stream errors. |
31+
32+
33+
34+
### Usage
35+
36+
```tsx
37+
import { Conversation } from '@databricks/appkit-ui';
38+
39+
<Conversation /* props */ />
40+
```
41+
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"label": "Chat primitives",
3+
"position": 7
4+
}
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
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

Comments
 (0)