@truefoundry/agent-ui-sdk 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +634 -0
  2. package/dist/index.d.ts +718 -0
  3. package/dist/index.js +2736 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +83 -0
  6. package/src/atoms/AskUserPrompt.tsx +102 -0
  7. package/src/atoms/AttachmentCard.tsx +92 -0
  8. package/src/atoms/AttachmentPickerButton.tsx +19 -0
  9. package/src/atoms/AttachmentPreviewDialog.tsx +34 -0
  10. package/src/atoms/BranchIndicator.tsx +41 -0
  11. package/src/atoms/CodeBlockHeader.tsx +44 -0
  12. package/src/atoms/ComposerShell.tsx +112 -0
  13. package/src/atoms/Markdown.test.tsx +28 -0
  14. package/src/atoms/Markdown.tsx +288 -0
  15. package/src/atoms/McpAuthPrompt.tsx +60 -0
  16. package/src/atoms/MessageActionBar.tsx +60 -0
  17. package/src/atoms/MessageBubble.tsx +80 -0
  18. package/src/atoms/MessageErrorBanner.tsx +26 -0
  19. package/src/atoms/MessageIndicator.tsx +23 -0
  20. package/src/atoms/OpenUIBlock.test.tsx +37 -0
  21. package/src/atoms/OpenUIBlock.tsx +50 -0
  22. package/src/atoms/ReasoningCard.tsx +52 -0
  23. package/src/atoms/SandboxArtifactList.tsx +63 -0
  24. package/src/atoms/ScrollToBottomButton.tsx +34 -0
  25. package/src/atoms/Skeletons.tsx +32 -0
  26. package/src/atoms/ThreadListMisc.tsx +76 -0
  27. package/src/atoms/ThreadListRow.tsx +81 -0
  28. package/src/atoms/ThreadShell.tsx +93 -0
  29. package/src/atoms/Toast.tsx +60 -0
  30. package/src/atoms/ToolApprovalBar.tsx +92 -0
  31. package/src/atoms/ToolCallCard.tsx +186 -0
  32. package/src/atoms/ToolGroupCard.tsx +52 -0
  33. package/src/atoms/UserMessageActionBar.tsx +62 -0
  34. package/src/atoms/WelcomeScreen.tsx +22 -0
  35. package/src/atoms/lib/cn.ts +6 -0
  36. package/src/atoms/primitives/Avatar.tsx +57 -0
  37. package/src/atoms/primitives/Button.tsx +73 -0
  38. package/src/atoms/primitives/Collapsible.tsx +32 -0
  39. package/src/atoms/primitives/Dialog.tsx +152 -0
  40. package/src/atoms/primitives/IconButton.tsx +43 -0
  41. package/src/atoms/primitives/Skeleton.tsx +17 -0
  42. package/src/atoms/primitives/Tooltip.tsx +58 -0
  43. package/src/containers/AskUserContainer.tsx +49 -0
  44. package/src/containers/AssistantMessageContainer.test.tsx +56 -0
  45. package/src/containers/AssistantMessageContainer.tsx +128 -0
  46. package/src/containers/AssistantTextContainer.test.tsx +111 -0
  47. package/src/containers/AssistantTextContainer.tsx +54 -0
  48. package/src/containers/AttachmentsContainer.tsx +83 -0
  49. package/src/containers/ComposerContainer.tsx +66 -0
  50. package/src/containers/ErrorToasterContainer.tsx +76 -0
  51. package/src/containers/McpAuthContainer.test.tsx +100 -0
  52. package/src/containers/McpAuthContainer.tsx +22 -0
  53. package/src/containers/MessageImageContainer.tsx +37 -0
  54. package/src/containers/ReasoningContainer.tsx +32 -0
  55. package/src/containers/RuntimeHarness.tsx +39 -0
  56. package/src/containers/Thread.tsx +9 -0
  57. package/src/containers/ThreadContainer.test.tsx +52 -0
  58. package/src/containers/ThreadContainer.tsx +87 -0
  59. package/src/containers/ThreadListContainer.tsx +63 -0
  60. package/src/containers/ToolCallContainer.test.tsx +135 -0
  61. package/src/containers/ToolCallContainer.tsx +167 -0
  62. package/src/containers/ToolGroupContainer.test.tsx +56 -0
  63. package/src/containers/ToolGroupContainer.tsx +24 -0
  64. package/src/containers/UserEditComposerContainer.test.tsx +52 -0
  65. package/src/containers/UserEditComposerContainer.tsx +86 -0
  66. package/src/containers/UserMessageContainer.test.tsx +99 -0
  67. package/src/containers/UserMessageContainer.tsx +26 -0
  68. package/src/containers/nestedApprovalBridge.ts +20 -0
  69. package/src/containers/useAttachmentPreviewSrc.ts +42 -0
  70. package/src/hooks/useComposerBusyState.test.ts +78 -0
  71. package/src/hooks/useComposerBusyState.ts +69 -0
  72. package/src/index.ts +193 -0
  73. package/src/openui.css +3 -0
  74. package/src/testSetup.ts +42 -0
  75. package/src/theme/SlotsProvider.test.tsx +64 -0
  76. package/src/theme/SlotsProvider.tsx +41 -0
  77. package/src/theme/defaultSlots.ts +131 -0
  78. package/src/theme/tokens.ts +98 -0
  79. package/src/theme/useClassDarkMode.ts +24 -0
package/README.md ADDED
@@ -0,0 +1,634 @@
1
+ # @truefoundry/agent-ui-sdk
2
+
3
+ A design-system-agnostic chat UI SDK for TrueFoundry agents, built on top of
4
+ `[@assistant-ui/react](https://www.assistant-ui.com/)` and
5
+ `@truefoundry/assistant-ui-runtime`.
6
+
7
+ ```
8
+ truefoundry-gateway-sdk (AgentSessionClient)
9
+ → @truefoundry/assistant-ui-runtime (useTrueFoundryAgentRuntime)
10
+ → @assistant-ui/react (primitives, ExternalStoreRuntime)
11
+ → THIS SDK (Thread, ThreadListContainer, atoms + containers)
12
+ ```
13
+
14
+ It exists to separate **presentation** from **state**, so the visual design
15
+ can be replaced later without touching any data-fetching or runtime-wiring
16
+ logic. You bring the runtime; the SDK renders it.
17
+
18
+ ## Contents
19
+
20
+ - [Install](#install)
21
+ - [Styling](#styling)
22
+ - [Quick start](#quick-start)
23
+ - [OpenUI blocks](#openui-blocks)
24
+ - [Customizing the UI (examples)](#customizing-the-ui-examples)
25
+ - [Architecture](#architecture)
26
+ - [What's in the box](#whats-in-the-box)
27
+ - [Known gaps](#known-gaps)
28
+ - [Troubleshooting: "requires an AuiProvider"](#troubleshooting-requires-an-auiprovider)
29
+ - [Development](#development)
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ yarn add @truefoundry/agent-ui-sdk \
35
+ @assistant-ui/react @assistant-ui/core \
36
+ @truefoundry/assistant-ui-runtime truefoundry-gateway-sdk \
37
+ react react-dom
38
+ ```
39
+
40
+ Every one of these peers must resolve to a **single instance** across your app.
41
+ Two copies of `react` or `@assistant-ui/core` are the most likely source of a
42
+ `"requires an AuiProvider"` error — see
43
+ [Troubleshooting](#troubleshooting-requires-an-auiprovider).
44
+
45
+ > `@truefoundry/assistant-ui-runtime` isn't on npm yet. Until it is, install it
46
+ > from a prebuilt tarball, e.g. add to your `package.json`:
47
+ > `"@truefoundry/assistant-ui-runtime": "@truefoundry/assistant-ui-runtime"`.
48
+
49
+ ## Styling
50
+
51
+ SDK components use Tailwind utility classes. Import the SDK stylesheet in your
52
+ **Tailwind entry CSS file** (for example `globals.css`) with a CSS `@import` so
53
+ the SDK's own `@source` directive participates in the same Tailwind build:
54
+
55
+ ```css
56
+ @import "tailwindcss";
57
+ @import "@truefoundry/agent-ui-sdk/openui.css";
58
+ ```
59
+
60
+ No manual `@source` pointing into `node_modules` is needed — the SDK stylesheet
61
+ declares `@source "."` relative to its install location, so Tailwind scans the
62
+ SDK's components automatically.
63
+
64
+ **Use a CSS `@import`, not a JS import** (`import "@truefoundry/agent-ui-sdk/openui.css"`).
65
+ A JS import may load OpenUI styles but will not activate Tailwind's content
66
+ scanning for SDK utilities.
67
+
68
+ Provide your own design tokens as CSS variables (`--primary`, `--background`,
69
+ `--muted`, `--border`, …) in the consumer app — the SDK stays
70
+ design-system-agnostic and references those variables rather than hardcoded
71
+ colors or spacing.
72
+
73
+ ## Quick start
74
+
75
+ ```tsx
76
+ import { AssistantRuntimeProvider } from "@assistant-ui/react";
77
+ import {
78
+ useTrueFoundryAgentRuntime,
79
+ trueFoundryAttachmentAdapter,
80
+ } from "@truefoundry/assistant-ui-runtime";
81
+ import { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
82
+ import {
83
+ Thread,
84
+ ThreadListContainer,
85
+ ErrorToasterProvider,
86
+ TooltipProvider,
87
+ } from "@truefoundry/agent-ui-sdk";
88
+
89
+ const client = new AgentSessionClient({
90
+ apiKey: process.env.TFY_API_KEY!,
91
+ environment: process.env.TFY_GATEWAY_URL!, // https://gateway.truefoundry.ai/<tenant>
92
+ });
93
+
94
+ export function App() {
95
+ const runtime = useTrueFoundryAgentRuntime({
96
+ client,
97
+ agentName: "my-agent",
98
+ adapters: { attachments: trueFoundryAttachmentAdapter }, // optional: enables file uploads
99
+ });
100
+
101
+ return (
102
+ <AssistantRuntimeProvider runtime={runtime}>
103
+ <ErrorToasterProvider>
104
+ <TooltipProvider>
105
+ <main style={{ display: "flex", height: "100dvh" }}>
106
+ <aside style={{ width: 256 }}>
107
+ <ThreadListContainer />
108
+ </aside>
109
+ <div style={{ flex: 1 }}>
110
+ <Thread />
111
+ </div>
112
+ </main>
113
+ </TooltipProvider>
114
+ </ErrorToasterProvider>
115
+ </AssistantRuntimeProvider>
116
+ );
117
+ }
118
+ ```
119
+
120
+ That's the whole integration. Three things to know:
121
+
122
+ - `**<Thread />**` is the fully-assembled message list + composer. It handles
123
+ the welcome screen, loading skeleton, streaming, tool calls, reasoning
124
+ blocks, attachments, and the ask-user / MCP-auth prompts automatically.
125
+ - `**<ThreadListContainer />**` is the session sidebar (new / select / archive
126
+ / delete).
127
+ - `**<ErrorToasterProvider>` / `<TooltipProvider>**` are required wrappers:
128
+ gateway/runtime errors surface through the toaster, and tooltips on built-in
129
+ buttons need the provider.
130
+
131
+ Need a custom layout around the input? Compose the pieces yourself instead of
132
+ `<Thread />`:
133
+
134
+ ```tsx
135
+ import { ThreadContainer, ComposerContainer } from "@truefoundry/agent-ui-sdk";
136
+
137
+ <ThreadContainer composer={<ComposerContainer />} />;
138
+ ```
139
+
140
+ ## OpenUI blocks
141
+
142
+ Assistant messages that include an OpenUI Lang fenced block render
143
+ automatically — no custom markdown wiring required:
144
+
145
+ ```markdown
146
+ ```openui
147
+ Card() {
148
+ title: "Quarterly revenue"
149
+ TextContent() { text: "Revenue is up 12%." }
150
+ }
151
+ ```
152
+ ```
153
+
154
+ `<Thread />` routes assistant text through the SDK `Markdown` atom, which
155
+ dispatches `language-openui` fences to `OpenUIBlock`. Streaming state is passed
156
+ through while the message is still running.
157
+
158
+ `OpenUIBlock` wraps output in OpenUI's `ThemeProvider` and syncs `mode` with a
159
+ `.dark` class on `<html>` by default. Pass an explicit `mode` prop to override,
160
+ or wrap a larger subtree in OpenUI's `ThemeProvider` yourself for custom token
161
+ overrides (`lightTheme` / `darkTheme`).
162
+
163
+ Make sure the SDK stylesheet is imported as CSS (see [Styling](#styling)) — the
164
+ `@import` form is required for OpenUI's Tailwind utilities.
165
+
166
+ ## Customizing the UI (examples)
167
+
168
+ Every feature-level atom is resolved through `useSlot`, so overriding one is
169
+ just a prop on `<SlotsProvider>`. Nested `<SlotsProvider>`s fall through to the
170
+ default, so you only ever override what actually changes.
171
+
172
+ ### The 60-second version
173
+
174
+ `WelcomeScreen` renders immediately for a new/empty thread, so it's the fastest
175
+ change to see:
176
+
177
+ ```tsx
178
+ import { SlotsProvider, Thread } from "@truefoundry/agent-ui-sdk";
179
+
180
+ function MyWelcome({ heading }: { heading?: string }) {
181
+ return <p className="px-4 py-6 text-center text-lg">{heading ?? "Hey 👋 what are we building today?"}</p>;
182
+ }
183
+
184
+ <SlotsProvider overrides={{ WelcomeScreen: MyWelcome }}>
185
+ <Thread />
186
+ </SlotsProvider>;
187
+ ```
188
+
189
+ Every other atom keeps rendering the SDK defaults. That's the whole pattern;
190
+ the rest of this section applies it to harder cases.
191
+
192
+ ### Which names are actually resolved via `useSlot`?
193
+
194
+ Not every name declared on `AtomSlots` is resolved via `useSlot` today.
195
+
196
+ - **Feature-level atoms are** — `MessageBubble`, `ComposerShell`,
197
+ `ToolCallCard`, `WelcomeScreen`, `ThreadListRow`, `Toast`, `ToastStack`,
198
+ `AskUserPrompt`, `ScrollToBottomButton`, etc. Overriding these works exactly
199
+ as documented here.
200
+ - **Shared low-level primitives are not** — `Button`, `IconButton`, `Tooltip`*,
201
+ `Dialog*`, `Avatar*`, `Collapsible*`, `Skeleton`, `CodeBlockHeader` are
202
+ declared in `AtomSlots` and populated in `defaultSlots`, but the atoms that
203
+ render them currently `import` them directly instead of going through
204
+ `useSlot`. Overriding just `Button` will **not** change the Submit button
205
+ inside `AskUserPrompt`.
206
+
207
+ If you need to change a primitive everywhere, override the higher-level atom
208
+ that embeds it (examples 4 and 6 do exactly this).
209
+
210
+ ### Restyling
211
+
212
+ **1. Restyle the chat bubbles.** `MessageBubble` is a discriminated union on
213
+ `variant`, so user and assistant messages can have completely different shells
214
+ while still receiving the same slots (`children`, `error`, `branchIndicator`,
215
+ `actionBar`/`attachments`):
216
+
217
+ ```tsx
218
+ import { SlotsProvider, Thread, type MessageBubbleProps } from "@truefoundry/agent-ui-sdk";
219
+
220
+ function BubbleV2(props: MessageBubbleProps) {
221
+ if (props.variant === "user") {
222
+ return (
223
+ <div className="ml-auto max-w-[75%] rounded-2xl bg-indigo-600 px-4 py-2 text-white">
224
+ {props.children}
225
+ </div>
226
+ );
227
+ }
228
+ return (
229
+ <div className="mr-auto max-w-[75%] rounded-2xl bg-neutral-100 px-4 py-2 dark:bg-neutral-800">
230
+ {props.children}
231
+ {props.error}
232
+ <div className="mt-1 flex gap-1 text-xs text-neutral-500">
233
+ {props.branchIndicator}
234
+ {props.actionBar}
235
+ </div>
236
+ </div>
237
+ );
238
+ }
239
+
240
+ <SlotsProvider overrides={{ MessageBubble: BubbleV2 }}>
241
+ <Thread />
242
+ </SlotsProvider>;
243
+ ```
244
+
245
+ **2. Give one part of your app a different look.** `SlotsProvider`s nest, and
246
+ each `useSlot` falls back to the nearest ancestor — so an embedded widget can
247
+ look different from the full-page app without a second theming system, and
248
+ without its overrides leaking out:
249
+
250
+ ```tsx
251
+ import { SlotsProvider, Thread } from "@truefoundry/agent-ui-sdk";
252
+
253
+ function CompactWelcome({ heading }: { heading?: string }) {
254
+ return <p className="px-4 py-2 text-sm text-muted-foreground">{heading ?? "Ask me anything"}</p>;
255
+ }
256
+
257
+ // Only this subtree sees CompactWelcome. A <Thread /> elsewhere keeps the default.
258
+ function EmbeddedWidget() {
259
+ return (
260
+ <SlotsProvider overrides={{ WelcomeScreen: CompactWelcome }}>
261
+ <Thread />
262
+ </SlotsProvider>
263
+ );
264
+ }
265
+ ```
266
+
267
+ **3. Decorate instead of replace.** An override can just wrap the default and
268
+ add behavior — the cheapest way to bolt on analytics without owning markup:
269
+
270
+ ```tsx
271
+ import { forwardRef } from "react";
272
+ import {
273
+ SlotsProvider,
274
+ Thread,
275
+ ScrollToBottomButton as DefaultScrollButton,
276
+ type ScrollToBottomButtonProps,
277
+ } from "@truefoundry/agent-ui-sdk";
278
+
279
+ const TrackedScrollButton = forwardRef<HTMLButtonElement, ScrollToBottomButtonProps>((props, ref) => (
280
+ <DefaultScrollButton
281
+ {...props}
282
+ ref={ref}
283
+ onClick={(event) => {
284
+ analytics.track("scroll_to_bottom_clicked");
285
+ props.onClick?.(event);
286
+ }}
287
+ />
288
+ ));
289
+
290
+ <SlotsProvider overrides={{ ScrollToBottomButton: TrackedScrollButton }}>
291
+ <Thread />
292
+ </SlotsProvider>;
293
+ ```
294
+
295
+ **4. Give specific tools their own visual treatment.** `ToolCallCard` receives
296
+ `name`/`variant`/`status` on every call, so you can special-case one tool and
297
+ fall through to the default (also exported) for everything else — sub-agents,
298
+ mcp-listings, and every other tool:
299
+
300
+ ```tsx
301
+ import { SlotsProvider, Thread, ToolCallCard as DefaultToolCallCard, type ToolCallCardProps } from "@truefoundry/agent-ui-sdk";
302
+ import { SearchIcon } from "lucide-react";
303
+
304
+ function CustomToolCallCard(props: ToolCallCardProps) {
305
+ if (props.variant === "tool" && props.name === "web_search") {
306
+ return (
307
+ <div className={props.isError ? "rounded-lg border border-destructive/40 p-2" : "rounded-lg border p-2"}>
308
+ <button onClick={props.onToggle} className="flex items-center gap-2 text-sm">
309
+ <SearchIcon className="size-4" />
310
+ Searching the web{props.durationText ? ` · ${props.durationText}` : ""}
311
+ </button>
312
+ {props.expanded && props.result && <pre className="mt-2 text-xs whitespace-pre-wrap">{props.result}</pre>}
313
+ </div>
314
+ );
315
+ }
316
+ return <DefaultToolCallCard {...props} />;
317
+ }
318
+
319
+ <SlotsProvider overrides={{ ToolCallCard: CustomToolCallCard }}>
320
+ <Thread />
321
+ </SlotsProvider>;
322
+ ```
323
+
324
+ ### Changing behavior, not just looks
325
+
326
+ **5. Turn ask-user into instant click-to-answer.** The shipped `AskUserPrompt`
327
+ uses a select-then-submit radio flow (see [Known gaps](#known-gaps)). Slots
328
+ only cover atoms, not containers — and the fix can't live in an atom override,
329
+ because the shipped `AskUserContainer` computes `onSubmit` from state set by a
330
+ *previous* `onSelectOption`. So skip the container and talk to the runtime hook
331
+ it wraps directly:
332
+
333
+ ```tsx
334
+ import { useTrueFoundryToolResponses } from "@truefoundry/assistant-ui-runtime";
335
+ import { useThreadIsRunning } from "@assistant-ui/core/react";
336
+
337
+ function InstantAskUser() {
338
+ const { pending, respond } = useTrueFoundryToolResponses();
339
+ const isRunning = useThreadIsRunning();
340
+ const item = pending[0];
341
+ if (item == null) return null;
342
+
343
+ return (
344
+ <div className="flex flex-wrap gap-2 rounded-xl border p-3">
345
+ <p className="w-full text-sm font-medium">{item.question ?? "Answer required"}</p>
346
+ {(item.options ?? []).map((label) => (
347
+ <button
348
+ key={label}
349
+ disabled={isRunning}
350
+ onClick={() => respond({ toolCallId: item.toolCallId, content: label })}
351
+ className="rounded-full border px-3 py-1 text-sm"
352
+ >
353
+ {label}
354
+ </button>
355
+ ))}
356
+ </div>
357
+ );
358
+ }
359
+ ```
360
+
361
+ Wire it in with a thin composer wrapper that renders `<InstantAskUser />` in
362
+ place of `<AskUserContainer />` — mirror the branching in
363
+ `[ComposerContainer](./src/containers/ComposerContainer.tsx)` (check
364
+ `mcpPending`, then tool responses, then fall through to `<ComposerShell />`)
365
+ and pass your wrapper to `<ThreadContainer composer={<YourComposer />} />`.
366
+
367
+ **6. Replace the composer while keeping cancel.** `ComposerShell` receives
368
+ `isRunning` and `onCancel` from `ComposerContainer` (the default renders a
369
+ Cancel button while a turn streams), plus `value`/`placeholder`/`disabled`/
370
+ `onValueChange`/`onSubmit`/`onAttach`/`attachments`. Your override owns all the
371
+ markup but keeps the same wiring:
372
+
373
+ ```tsx
374
+ import { SlotsProvider, Thread, IconButton, type ComposerShellProps } from "@truefoundry/agent-ui-sdk";
375
+ import { ArrowUpIcon, SquareIcon } from "lucide-react";
376
+
377
+ function CompactComposer({
378
+ value,
379
+ placeholder,
380
+ disabled,
381
+ isRunning,
382
+ onValueChange,
383
+ onSubmit,
384
+ onCancel,
385
+ }: ComposerShellProps) {
386
+ return (
387
+ <div className="border-border/60 flex w-full flex-col gap-2 rounded-3xl border p-2">
388
+ <textarea
389
+ value={value}
390
+ placeholder={placeholder}
391
+ disabled={disabled}
392
+ onChange={(event) => onValueChange(event.target.value)}
393
+ className="w-full resize-none bg-transparent px-2 py-1 outline-none"
394
+ />
395
+ <div className="flex justify-end px-1">
396
+ {isRunning ? (
397
+ <IconButton tooltip="Stop generating" onClick={onCancel} disabled={!onCancel}>
398
+ <SquareIcon />
399
+ </IconButton>
400
+ ) : (
401
+ <IconButton tooltip="Send" onClick={onSubmit} disabled={disabled || value.trim().length === 0}>
402
+ <ArrowUpIcon />
403
+ </IconButton>
404
+ )}
405
+ </div>
406
+ </div>
407
+ );
408
+ }
409
+
410
+ <SlotsProvider overrides={{ ComposerShell: CompactComposer }}>
411
+ <Thread />
412
+ </SlotsProvider>;
413
+ ```
414
+
415
+ **7. Reroute a cross-cutting behavior.** `ErrorToasterProvider` resolves
416
+ `Toast`/`ToastStack` via `useSlot`, so you can pipe every gateway/runtime error
417
+ through a different toast library (e.g. `sonner`) without touching
418
+ `useErrorToaster` or any `showError` call site:
419
+
420
+ ```tsx
421
+ import { useEffect } from "react";
422
+ import {
423
+ SlotsProvider,
424
+ ErrorToasterProvider,
425
+ Thread,
426
+ type ToastProps,
427
+ type ToastStackProps,
428
+ } from "@truefoundry/agent-ui-sdk";
429
+ import { Toaster, toast as sonnerToast } from "sonner";
430
+
431
+ function SonnerToast({ title, description, open, onOpenChange }: ToastProps) {
432
+ useEffect(() => {
433
+ if (!open) return;
434
+ sonnerToast.error(title, { description });
435
+ onOpenChange(false); // sonner now owns the visible toast
436
+ }, [open, title, description, onOpenChange]);
437
+ return null;
438
+ }
439
+
440
+ function SonnerToastStack({ children }: ToastStackProps) {
441
+ return (
442
+ <>
443
+ {children}
444
+ <Toaster richColors />
445
+ </>
446
+ );
447
+ }
448
+
449
+ // SlotsProvider must be an ancestor of ErrorToasterProvider — the provider
450
+ // resolves the slot at its own position in the tree.
451
+ <SlotsProvider overrides={{ Toast: SonnerToast, ToastStack: SonnerToastStack }}>
452
+ <ErrorToasterProvider>
453
+ <Thread />
454
+ </ErrorToasterProvider>
455
+ </SlotsProvider>;
456
+ ```
457
+
458
+ **8. Layer local-only UI onto a row without touching the container.**
459
+ `ThreadListRow`'s contract (`title`/`active`/`onSelect`/`onArchive`/`onDelete`)
460
+ has no concept of "pinned" — nothing needs to. An override is just a component,
461
+ so it can hold state the container never sees:
462
+
463
+ ```tsx
464
+ import { useState } from "react";
465
+ import {
466
+ SlotsProvider,
467
+ ThreadListContainer,
468
+ ThreadListRow as DefaultRow,
469
+ type ThreadListRowProps,
470
+ } from "@truefoundry/agent-ui-sdk";
471
+ import { PinIcon } from "lucide-react";
472
+
473
+ function PinnableRow(props: ThreadListRowProps) {
474
+ const [pinned, setPinned] = useState(false);
475
+ return (
476
+ <div className="relative">
477
+ <DefaultRow {...props} />
478
+ <button
479
+ onClick={(event) => {
480
+ event.stopPropagation();
481
+ setPinned((prev) => !prev);
482
+ }}
483
+ className="absolute left-1.5 top-1/2 -translate-y-1/2"
484
+ >
485
+ <PinIcon className={pinned ? "size-3.5 fill-current" : "size-3.5"} />
486
+ </button>
487
+ </div>
488
+ );
489
+ }
490
+
491
+ <SlotsProvider overrides={{ ThreadListRow: PinnableRow }}>
492
+ <ThreadListContainer />
493
+ </SlotsProvider>;
494
+ ```
495
+
496
+ ## Architecture
497
+
498
+ Every piece of UI is split into two layers.
499
+
500
+ ### Atoms (`src/atoms/**`)
501
+
502
+ Pure, stateless, prop-driven components. An atom:
503
+
504
+ - Never imports from `@assistant-ui/*` or the TrueFoundry runtime.
505
+ - Never calls a runtime/data hook — only local `useState`/`useMemo`/
506
+ `useCallback` for purely visual concerns.
507
+ - Owns **all** Tailwind classes and JSX for what it renders.
508
+ - Only knows "render this data, call this callback."
509
+
510
+ This is the swap point: replacing the design system later means writing new
511
+ components that satisfy the same prop contracts — zero container changes.
512
+
513
+ ### Containers (`src/containers/`**)
514
+
515
+ Stateful glue. A container:
516
+
517
+ - Reads assistant-ui primitives/hooks (`ThreadPrimitive`, `MessagePrimitive`,
518
+ `useAui`, `useAuiState`, `@assistant-ui/core/react` hooks) and TrueFoundry
519
+ runtime hooks (`useTrueFoundryApprovals`, `useTrueFoundryToolResponses`,
520
+ `useTrueFoundryCancel`, …).
521
+ - Derives plain data and callbacks from that state.
522
+ - Resolves every atom via `useSlot("AtomName")` — **never** imports an atom
523
+ directly — and passes derived props straight through.
524
+ - Contains no decorative styling of its own.
525
+
526
+ ### The slot registry (`src/theme/SlotsProvider.tsx`)
527
+
528
+ `AtomSlots` is an interface that starts empty and gets augmented by every atom
529
+ module via `declare module "../theme/SlotsProvider.js"` — so adding an atom
530
+ never requires editing the registry. `defaultSlots.ts` supplies this SDK's own
531
+ implementation for every slot; `useSlot` falls back through nested
532
+ `<SlotsProvider>`s to those defaults.
533
+
534
+ ```tsx
535
+ import { SlotsProvider, Thread } from "@truefoundry/agent-ui-sdk";
536
+ import { MyBubble } from "./my-design-system/MessageBubble";
537
+
538
+ <SlotsProvider overrides={{ MessageBubble: MyBubble }}>
539
+ <Thread />
540
+ </SlotsProvider>;
541
+ ```
542
+
543
+ ### Token contract (`src/theme/tokens.ts`)
544
+
545
+ `DesignTokens` is a semantic color/radius/spacing/typography contract exposed
546
+ via `useTokens()` / `<TokensProvider>`. The shipped default atoms don't consume
547
+ it yet (they port the reference app's Tailwind classes verbatim); it exists so
548
+ a future design system has a token surface to bind to.
549
+
550
+ ## What's in the box
551
+
552
+
553
+ | Area | Containers | Notes |
554
+ | ------------ | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
555
+ | Thread shell | `ThreadContainer`, `Thread` | Welcome screen, loading skeleton, scroll-to-bottom, message list |
556
+ | Messages | `AssistantMessageContainer`, `UserMessageContainer`, `AssistantTextContainer` | Text, branch picker, error banner, copy/export action bar; ````openui` fences render via `OpenUIBlock` |
557
+ | Tools | `ToolCallContainer`, `ToolGroupContainer` | Tool-call rendering, approval flow, sub-agent nesting (recursive) |
558
+ | Reasoning | `ReasoningContainer` | Streaming-aware collapsible reasoning blocks |
559
+ | Composer | `ComposerContainer`, `AskUserContainer`, `McpAuthContainer` | Message input with cancel-while-streaming; auto-swaps to ask-user / MCP-auth prompts when a turn is paused. `McpAuthContainer` renders a per-server "Authorize" link; the user confirms with "Continue" (no background polling) |
560
+ | Attachments | `ComposerAttachmentsContainer`, `MessageAttachmentsContainer`, `ComposerAttachmentPickerContainer` | Staging tray in composer; image/file chips in user bubbles |
561
+ | Thread list | `ThreadListContainer` | Flat list (not grouped by date): new / select / archive / delete |
562
+ | Errors | `ErrorToasterProvider`, `useErrorToaster` | Global toast for gateway/runtime errors |
563
+
564
+
565
+ Every atom's prop type is exported from `[src/index.ts](./src/index.ts)` — that
566
+ file is the definitive list of what's public.
567
+
568
+ ## Known gaps
569
+
570
+ Deliberate deviations from the reference app, flagged rather than silently
571
+ dropped:
572
+
573
+ - `**AskUserPrompt**` uses a select-then-submit radio flow, not the reference
574
+ app's immediate click-to-answer buttons. See example 5 to change this.
575
+ - `**ToolCallCard**` ships an `mcp-listing` variant (type + atom) that no
576
+ container currently selects — the runtime has no signal distinguishing
577
+ "listing MCP tools" from any other tool call. Its `tool` variant also carries
578
+ an `approvalSlot`/`durationText` beyond the originally-sketched contract,
579
+ needed to keep the tool-approval flow working.
580
+ - **Attachments**: image previews use a `contentType` fallback because the
581
+ TrueFoundry attachment adapter tags all uploads as `type: "file"`.
582
+ - `**ThreadListContainer`** renders one flat list, not grouped by
583
+ Today/Yesterday/Earlier.
584
+ - `**McpAuthContainer**` has no per-server auth status. `McpServerAuthInfo`
585
+ (`{ id, name, authUrl }`) carries no authorized/pending flag, so multi-server
586
+ auth is all-or-nothing: one shared Continue button.
587
+ - Several `AtomSlots` entries (`Button`, `IconButton`, `Tooltip*`, `Dialog*`,
588
+ `Avatar*`, `Collapsible*`, `Skeleton`, `CodeBlockHeader`) are declared and
589
+ populated in `defaultSlots`, but no shipped atom resolves them via `useSlot`
590
+ yet — the atoms import them directly. Override the higher-level atom instead
591
+ (see "Which names are actually resolved via `useSlot`?").
592
+
593
+ ## Troubleshooting: "requires an AuiProvider"
594
+
595
+ `@assistant-ui/core` (and `@assistant-ui/store`, which holds the React Context
596
+ `AuiProvider` relies on) takes `react` as a peer dependency. If your app and
597
+ this SDK resolve to two different `react` versions — even two patch versions
598
+ apart — your package manager may install two physically separate copies of
599
+ `@assistant-ui/core`/`@assistant-ui/store`. React Context identity is tied to
600
+ the *module instance*, so hooks like `useAui`/`useAuiState` called inside this
601
+ SDK read from a different Context than your `<AssistantRuntimeProvider>`,
602
+ producing:
603
+
604
+ ```
605
+ Uncaught Error: You are using a component or hook that requires an
606
+ AuiProvider. Wrap your component in an <AuiProvider> component.
607
+ ```
608
+
609
+ even though a provider **is** mounted. Diagnose with:
610
+
611
+ ```bash
612
+ yarn why @assistant-ui/core
613
+ yarn why @assistant-ui/store
614
+ ```
615
+
616
+ If either reports more than one instance, align every `react`/`react-dom`
617
+ version across the workspace (same range — don't mix pinned-exact and
618
+ caret-ranged) and reinstall.
619
+
620
+ ## Development
621
+
622
+ ```bash
623
+ yarn install
624
+ yarn build # tsup → dist/
625
+ yarn typecheck # tsc --noEmit
626
+ yarn test # vitest run
627
+ ```
628
+
629
+ Tests render containers against a real (in-memory) assistant-ui runtime via
630
+ `[src/containers/RuntimeHarness.tsx](./src/containers/RuntimeHarness.tsx)` — no
631
+ network calls, no real gateway required.
632
+
633
+ To produce a distributable tarball: `yarn pack:tgz` (installs, builds, then
634
+ `yarn pack`).