@tanstack/ai-react 0.22.4 → 0.23.0

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 (60) hide show
  1. package/README.md +11 -15
  2. package/dist/esm/chat-ui/chat-input.d.ts +57 -0
  3. package/dist/esm/chat-ui/chat-input.js +124 -0
  4. package/dist/esm/chat-ui/chat-input.js.map +1 -0
  5. package/dist/esm/chat-ui/chat-message.d.ts +91 -0
  6. package/dist/esm/chat-ui/chat-message.js +157 -0
  7. package/dist/esm/chat-ui/chat-message.js.map +1 -0
  8. package/dist/esm/chat-ui/chat-messages.d.ts +33 -0
  9. package/dist/esm/chat-ui/chat-messages.js +44 -0
  10. package/dist/esm/chat-ui/chat-messages.js.map +1 -0
  11. package/dist/esm/chat-ui/chat.d.ts +53 -0
  12. package/dist/esm/chat-ui/chat.js +57 -0
  13. package/dist/esm/chat-ui/chat.js.map +1 -0
  14. package/dist/esm/chat-ui/create-chat-hook.d.ts +48 -0
  15. package/dist/esm/chat-ui/create-chat-hook.js +57 -0
  16. package/dist/esm/chat-ui/create-chat-hook.js.map +1 -0
  17. package/dist/esm/chat-ui/create-ui-contexts.d.ts +29 -0
  18. package/dist/esm/chat-ui/create-ui-contexts.js +36 -0
  19. package/dist/esm/chat-ui/create-ui-contexts.js.map +1 -0
  20. package/dist/esm/chat-ui/create-ui.d.ts +153 -0
  21. package/dist/esm/chat-ui/create-ui.js +294 -0
  22. package/dist/esm/chat-ui/create-ui.js.map +1 -0
  23. package/dist/esm/chat-ui/markdown-plugins.d.ts +15 -0
  24. package/dist/esm/chat-ui/markdown-plugins.js +28 -0
  25. package/dist/esm/chat-ui/markdown-plugins.js.map +1 -0
  26. package/dist/esm/chat-ui/text-part.d.ts +57 -0
  27. package/dist/esm/chat-ui/text-part.js +46 -0
  28. package/dist/esm/chat-ui/text-part.js.map +1 -0
  29. package/dist/esm/chat-ui/thinking-part.d.ts +41 -0
  30. package/dist/esm/chat-ui/thinking-part.js +73 -0
  31. package/dist/esm/chat-ui/thinking-part.js.map +1 -0
  32. package/dist/esm/chat-ui/tool-approval.d.ts +52 -0
  33. package/dist/esm/chat-ui/tool-approval.js +83 -0
  34. package/dist/esm/chat-ui/tool-approval.js.map +1 -0
  35. package/dist/esm/chat-ui/tool-result-content.d.ts +18 -0
  36. package/dist/esm/chat-ui/tool-result-content.js +19 -0
  37. package/dist/esm/chat-ui/tool-result-content.js.map +1 -0
  38. package/dist/esm/create-chat-hook.d.ts +35 -0
  39. package/dist/esm/create-chat-hook.js +37 -0
  40. package/dist/esm/create-chat-hook.js.map +1 -0
  41. package/dist/esm/index.d.ts +1 -0
  42. package/dist/esm/index.js +2 -1
  43. package/dist/esm/ui.d.ts +10 -0
  44. package/dist/esm/ui.js +11 -0
  45. package/package.json +18 -4
  46. package/src/chat-ui/chat-input.tsx +177 -0
  47. package/src/chat-ui/chat-message.tsx +271 -0
  48. package/src/chat-ui/chat-messages.tsx +89 -0
  49. package/src/chat-ui/chat.tsx +98 -0
  50. package/src/chat-ui/create-chat-hook.tsx +103 -0
  51. package/src/chat-ui/create-ui-contexts.tsx +57 -0
  52. package/src/chat-ui/create-ui.tsx +761 -0
  53. package/src/chat-ui/markdown-plugins.ts +48 -0
  54. package/src/chat-ui/text-part.tsx +97 -0
  55. package/src/chat-ui/thinking-part.tsx +83 -0
  56. package/src/chat-ui/tool-approval.tsx +132 -0
  57. package/src/chat-ui/tool-result-content.ts +32 -0
  58. package/src/create-chat-hook.ts +67 -0
  59. package/src/index.ts +1 -0
  60. package/src/ui.ts +43 -0
@@ -0,0 +1,761 @@
1
+ import { createContext, memo, useContext } from 'react'
2
+ import type { ComponentProps, ComponentType, Context, ReactNode } from 'react'
3
+ import {
4
+ automaticPartsForMessage,
5
+ collectInlineToolNames,
6
+ resolveInterruptComponent,
7
+ selectChatUI,
8
+ selectMessageUI,
9
+ } from '@tanstack/ai-client/ui'
10
+ import type {
11
+ ChatUIData,
12
+ ChatUIHasNamedInterrupts,
13
+ ChatUIHasNamedTools,
14
+ ChatUIInterrupt,
15
+ ChatUIInterruptName,
16
+ ChatUIInterruptOf,
17
+ ChatUIInterruptsOf,
18
+ ChatUIMessages,
19
+ ChatUINamedInterruptId,
20
+ ChatUIPartKey,
21
+ ChatUIPartOf,
22
+ ChatUISchemaOf,
23
+ ChatUISelectedPart,
24
+ ChatUISelectedPartOf,
25
+ ChatUIToolApproval,
26
+ ChatUIToolName,
27
+ ChatUIToolsOf,
28
+ } from '@tanstack/ai-client/ui'
29
+ import type {
30
+ MessagePart,
31
+ QueuedMessage,
32
+ ToolCallPart,
33
+ ToolResultPart,
34
+ UIMessage,
35
+ } from '@tanstack/ai-client'
36
+ import type { UseChatReturn } from '../types'
37
+ import { defaultChatUIContexts } from './create-ui-contexts'
38
+ import type { ChatUIContexts } from './create-ui-contexts'
39
+
40
+ export type ChatUIHost<TOptions = unknown> = UseChatReturn<
41
+ ChatUIToolsOf<TOptions>,
42
+ ChatUISchemaOf<TOptions>,
43
+ ChatUIInterruptsOf<TOptions>
44
+ >
45
+
46
+ export type ChatUIQueueItem = QueuedMessage & {
47
+ cancelQueued: () => void
48
+ }
49
+
50
+ export type LayoutProps<
51
+ TOptions,
52
+ TInput extends ComponentType<any> | undefined = ComponentType<
53
+ InputProps<TOptions>
54
+ >,
55
+ > = {
56
+ Messages: ComponentType
57
+ Interrupts: ComponentType
58
+ Queue: ComponentType
59
+ readonly __ui?: TOptions
60
+ } & (TInput extends ComponentType<any> ? { Input: ComponentType } : {})
61
+
62
+ export type MessageProps<TOptions> = {
63
+ message: UIMessage<ChatUIToolsOf<TOptions>, ChatUIData<TOptions>>
64
+ Parts: ComponentType
65
+ }
66
+
67
+ export type InputProps<TOptions> = {
68
+ readonly __ui?: TOptions
69
+ }
70
+
71
+ export type QueueProps<TOptions> = {
72
+ item: ChatUIQueueItem
73
+ readonly __ui?: TOptions
74
+ }
75
+
76
+ export type PartProps<TOptions, TKey extends ChatUIPartKey = ChatUIPartKey> = {
77
+ part: ChatUIPartOf<TOptions, TKey>
78
+ }
79
+
80
+ export type ToolProps<
81
+ TOptions,
82
+ TName extends ChatUIToolName<TOptions> = ChatUIToolName<TOptions>,
83
+ > = {
84
+ part: Extract<ToolCallPart<ChatUIToolsOf<TOptions>>, { name: TName }>
85
+ result?: ToolResultPart
86
+ interrupt?: ChatUIToolApproval<TOptions, TName>
87
+ }
88
+
89
+ export type InterruptProps<
90
+ TOptions,
91
+ TName extends ChatUIInterruptName<TOptions> = never,
92
+ > = {
93
+ interrupt: ChatUIInterruptOf<TOptions, TName>
94
+ readonly __ui?: TOptions
95
+ }
96
+
97
+ type GenericInterruptComponents<TOptions> =
98
+ ChatUIHasNamedInterrupts<TOptions> extends true
99
+ ? {
100
+ [K in ChatUINamedInterruptId<TOptions>]: ComponentType<
101
+ InterruptProps<TOptions, K & ChatUIInterruptName<TOptions>>
102
+ >
103
+ } & {
104
+ fallback?: ComponentType<InterruptProps<TOptions>>
105
+ }
106
+ : {
107
+ fallback?: ComponentType<InterruptProps<TOptions>>
108
+ }
109
+
110
+ type ToolApprovalMap<TOptions> = {
111
+ [K in ChatUIToolName<TOptions>]?: ComponentType<
112
+ InterruptProps<TOptions, K & ChatUIInterruptName<TOptions>>
113
+ >
114
+ }
115
+
116
+ /** The chrome around the message list: `layout`, `message`, and `input`. */
117
+ export type ChatUIChromeComponents<
118
+ TOptions,
119
+ TInput extends ComponentType<InputProps<TOptions>> | undefined =
120
+ ComponentType<InputProps<TOptions>>,
121
+ > = {
122
+ layout: ComponentType<LayoutProps<TOptions, TInput>>
123
+ message: ComponentType<MessageProps<TOptions>>
124
+ input?: TInput
125
+ queue?: ComponentType<QueueProps<TOptions>>
126
+ }
127
+
128
+ export type ChatUIPartsComponents<TOptions> = {
129
+ [K in ChatUIPartKey]?: ComponentType<PartProps<TOptions, K>>
130
+ } & {
131
+ fallback?: ComponentType<PartProps<TOptions>>
132
+ }
133
+
134
+ export type ChatUIInterruptsComponents<TOptions> = {
135
+ tools?: ToolApprovalMap<TOptions>
136
+ generic: GenericInterruptComponents<TOptions>
137
+ }
138
+
139
+ export type ChatUIComponents<
140
+ TOptions,
141
+ TInput extends ComponentType<InputProps<TOptions>> | undefined =
142
+ ComponentType<InputProps<TOptions>>,
143
+ > = {
144
+ components: ChatUIChromeComponents<TOptions, TInput>
145
+ partsComponents: ChatUIPartsComponents<TOptions>
146
+ } & (ChatUIHasNamedTools<TOptions> extends true
147
+ ? {
148
+ toolsComponents: {
149
+ [K in ChatUIToolName<TOptions>]: ComponentType<ToolProps<TOptions, K>>
150
+ }
151
+ }
152
+ : {
153
+ toolsComponents?: {
154
+ [K in ChatUIToolName<TOptions>]?: ComponentType<ToolProps<TOptions, K>>
155
+ }
156
+ }) &
157
+ (ChatUIHasNamedInterrupts<TOptions> extends true
158
+ ? { interruptsComponents: ChatUIInterruptsComponents<TOptions> }
159
+ : {
160
+ interruptsComponents?: {
161
+ tools?: ToolApprovalMap<TOptions>
162
+ generic?: GenericInterruptComponents<TOptions>
163
+ }
164
+ })
165
+
166
+ /** Scoped contexts, for widgets in other files or nested chat trees. */
167
+ export type ChatUIContextConfig = {
168
+ chatContext?: ChatUIContexts['chatContext']
169
+ partContext?: ChatUIContexts['partContext']
170
+ interruptContext?: ChatUIContexts['interruptContext']
171
+ }
172
+
173
+ export type ChatUIFactoryConfig<
174
+ TOptions,
175
+ TInput extends ComponentType<InputProps<TOptions>> | undefined =
176
+ ComponentType<InputProps<TOptions>>,
177
+ > = ChatUIComponents<TOptions, TInput> & {
178
+ context?: ChatUIContextConfig
179
+ }
180
+
181
+ type BoundWidget = ComponentType<Record<string, never>>
182
+
183
+ type PartMixins<TOptions> = {
184
+ [K in ChatUIPartKey]?: BoundWidget
185
+ } & {
186
+ [K in ChatUIToolName<TOptions>]: BoundWidget
187
+ } & {
188
+ Render: BoundWidget
189
+ }
190
+
191
+ type InterruptMixins<TOptions> = {
192
+ [K in ChatUINamedInterruptId<TOptions>]: BoundWidget
193
+ } & {
194
+ [K in ChatUIToolName<TOptions>]?: BoundWidget
195
+ } & {
196
+ fallback?: BoundWidget
197
+ Render: BoundWidget
198
+ }
199
+
200
+ function createWarnOnce() {
201
+ const seen = new Set<string>()
202
+ return (key: string, message: string) => {
203
+ if (process.env.NODE_ENV === 'production') return
204
+ if (seen.has(key)) return
205
+ seen.add(key)
206
+ console.warn(message)
207
+ }
208
+ }
209
+
210
+ function readMessages<TOptions>(chat: ChatUIHost<TOptions>) {
211
+ return chat.messages as ChatUIMessages<TOptions>
212
+ }
213
+
214
+ function readInterrupts<TOptions>(chat: ChatUIHost<TOptions>) {
215
+ return chat.interrupts ?? []
216
+ }
217
+
218
+ function queueItemEqual(
219
+ prev: { item: QueuedMessage; cancelQueued: (id: string) => void },
220
+ next: { item: QueuedMessage; cancelQueued: (id: string) => void },
221
+ ) {
222
+ return (
223
+ prev.item.id === next.item.id &&
224
+ prev.item.createdAt === next.item.createdAt &&
225
+ prev.item.content === next.item.content &&
226
+ prev.cancelQueued === next.cancelQueued
227
+ )
228
+ }
229
+
230
+ function selectedPartPropsEqual(
231
+ prev: { selected: ChatUISelectedPart },
232
+ next: { selected: ChatUISelectedPart },
233
+ ) {
234
+ if (prev.selected.key !== next.selected.key) return false
235
+ if (prev.selected.part !== next.selected.part) return false
236
+ if (prev.selected.key === 'toolCall' && next.selected.key === 'toolCall') {
237
+ return (
238
+ prev.selected.result === next.selected.result &&
239
+ prev.selected.interrupt === next.selected.interrupt
240
+ )
241
+ }
242
+ if (
243
+ prev.selected.key === 'toolResult' &&
244
+ next.selected.key === 'toolResult'
245
+ ) {
246
+ return prev.selected.matched === next.selected.matched
247
+ }
248
+ return true
249
+ }
250
+
251
+ function isSelectedPart(
252
+ value: MessagePart | ChatUISelectedPart,
253
+ ): value is ChatUISelectedPart {
254
+ return 'key' in value && 'part' in value
255
+ }
256
+
257
+ function bindMap(
258
+ map: Record<string, ComponentType<any> | undefined> | undefined,
259
+ bind: (component: ComponentType<any>) => BoundWidget,
260
+ ) {
261
+ const out: Record<string, BoundWidget> = {}
262
+ for (const [key, component] of Object.entries(map ?? {})) {
263
+ if (component) out[key] = bind(component)
264
+ }
265
+ return out
266
+ }
267
+
268
+ /**
269
+ * Bind chat options and UI widgets once at module scope. This matches Form
270
+ * `createFormHook` and Table `createTableHook`: widgets register here, mix
271
+ * onto Part / Interrupt / the UI kit, and automatic dispatch still walks the
272
+ * message list.
273
+ *
274
+ * `chatOptions` is type-only at runtime. Pass optional `chatContext`,
275
+ * `partContext`, and `interruptContext` from {@link createChatHookContexts}
276
+ * when widgets live in other files or when nested chats need isolation.
277
+ */
278
+ export function createChatUI<
279
+ const TOptions,
280
+ TInput extends ComponentType<any> | undefined =
281
+ | ComponentType<InputProps<NoInfer<TOptions>>>
282
+ | undefined,
283
+ >(options: TOptions, config: ChatUIFactoryConfig<NoInfer<TOptions>, TInput>) {
284
+ void options
285
+ const {
286
+ context: contextOption,
287
+ components,
288
+ partsComponents: parts,
289
+ toolsComponents: tools,
290
+ interruptsComponents: interrupts,
291
+ } = config as ChatUIFactoryConfig<TOptions, TInput> & {
292
+ toolsComponents?: Record<string, ComponentType<any> | undefined>
293
+ interruptsComponents?: {
294
+ tools?: Record<string, ComponentType<any> | undefined>
295
+ generic?: Record<string, ComponentType<any> | undefined>
296
+ }
297
+ }
298
+ const {
299
+ layout: Layout,
300
+ message: MessageComponent,
301
+ input: InputComponent,
302
+ queue: QueueItemComponent,
303
+ } = components
304
+ const {
305
+ chatContext: chatContextOption,
306
+ partContext: partContextOption,
307
+ interruptContext: interruptContextOption,
308
+ } = contextOption ?? {}
309
+ const warn = createWarnOnce()
310
+ const ChatContext = (chatContextOption ??
311
+ defaultChatUIContexts.chatContext) as Context<ChatUIHost<TOptions> | null>
312
+ const PartContext = (partContextOption ??
313
+ defaultChatUIContexts.partContext) as Context<ChatUISelectedPart | null>
314
+ const InterruptContext = (interruptContextOption ??
315
+ defaultChatUIContexts.interruptContext) as Context<ChatUIInterrupt | null>
316
+ const inlineToolNames = collectInlineToolNames(
317
+ interrupts?.tools as Record<string, unknown> | undefined,
318
+ Object.keys(tools ?? {}),
319
+ )
320
+
321
+ function useChatContext() {
322
+ const chat = useContext(ChatContext)
323
+ if (!chat) {
324
+ throw new Error(
325
+ '`useChatContext` must be used within `UI.Provider` or `UI.Chat`.',
326
+ )
327
+ }
328
+ return chat
329
+ }
330
+
331
+ function usePartContext<TKey extends ChatUIPartKey = ChatUIPartKey>() {
332
+ const selected = useContext(PartContext)
333
+ if (!selected) {
334
+ throw new Error(
335
+ '`usePartContext` must be used within `UI.Part` or an automatic part.',
336
+ )
337
+ }
338
+ return selected as ChatUISelectedPartOf<TOptions, TKey>
339
+ }
340
+
341
+ function useInterruptContext<
342
+ TName extends ChatUIInterruptName<TOptions> = ChatUIInterruptName<TOptions>,
343
+ >() {
344
+ const interrupt = useContext(InterruptContext)
345
+ if (!interrupt) {
346
+ throw new Error(
347
+ '`useInterruptContext` must be used within `UI.Interrupt`.',
348
+ )
349
+ }
350
+ return interrupt as ChatUIInterruptOf<TOptions, TName>
351
+ }
352
+
353
+ function bindPart(Component: ComponentType<PartProps<TOptions>>) {
354
+ return function BoundPart() {
355
+ const selected = usePartContext()
356
+ return <Component part={selected.part as PartProps<TOptions>['part']} />
357
+ }
358
+ }
359
+
360
+ function bindTool(Component: ComponentType<ToolProps<TOptions>>) {
361
+ return function BoundTool() {
362
+ const selected = usePartContext()
363
+ if (selected.key !== 'toolCall') return null
364
+ return (
365
+ <Component
366
+ part={selected.part as ToolProps<TOptions>['part']}
367
+ result={selected.result}
368
+ interrupt={selected.interrupt as ToolProps<TOptions>['interrupt']}
369
+ />
370
+ )
371
+ }
372
+ }
373
+
374
+ function bindInterrupt(Component: ComponentType<InterruptProps<TOptions>>) {
375
+ return function BoundInterrupt() {
376
+ const interrupt = useInterruptContext()
377
+ return (
378
+ <Component
379
+ interrupt={interrupt as InterruptProps<TOptions>['interrupt']}
380
+ />
381
+ )
382
+ }
383
+ }
384
+
385
+ const partMixins = bindMap(
386
+ parts as Record<string, ComponentType<any> | undefined>,
387
+ bindPart,
388
+ )
389
+ const toolMixins = bindMap(
390
+ tools as Record<string, ComponentType<any> | undefined>,
391
+ bindTool,
392
+ )
393
+ const interruptMixins = {
394
+ ...bindMap(
395
+ interrupts?.generic as
396
+ | Record<string, ComponentType<any> | undefined>
397
+ | undefined,
398
+ bindInterrupt,
399
+ ),
400
+ ...bindMap(
401
+ interrupts?.tools as
402
+ | Record<string, ComponentType<any> | undefined>
403
+ | undefined,
404
+ bindInterrupt,
405
+ ),
406
+ }
407
+
408
+ function mixPart(selected: ChatUISelectedPart) {
409
+ return Object.assign({}, selected, partMixins, toolMixins, {
410
+ Render: BoundRender,
411
+ }) as ChatUISelectedPart & PartMixins<TOptions>
412
+ }
413
+
414
+ function mixInterrupt(interrupt: ChatUIInterrupt) {
415
+ return Object.assign({}, interrupt, interruptMixins, {
416
+ Render: BoundInterruptRender,
417
+ }) as ChatUIInterrupt & InterruptMixins<TOptions>
418
+ }
419
+
420
+ function Provider({
421
+ chat,
422
+ children,
423
+ }: {
424
+ chat: ChatUIHost<TOptions>
425
+ children?: ReactNode
426
+ }) {
427
+ return <ChatContext.Provider value={chat}>{children}</ChatContext.Provider>
428
+ }
429
+
430
+ // Backstop for when the conditional `Input` type cannot be inferred (see the
431
+ // `input` note in docs/ui/react.md). The type hides `Input` when no `input`
432
+ // is registered, but inference degrades on some config shapes, so always
433
+ // supply a component: warn once rather than crash on an undefined element.
434
+ function MissingInput() {
435
+ warn(
436
+ 'input',
437
+ '[tanstack-ai-ui] Rendered <Input /> but no `input` component is registered.',
438
+ )
439
+ return null
440
+ }
441
+
442
+ // `Messages`, `Interrupts`, `Queue` and `InputComponent` are declared once
443
+ // per factory, so these props are stable for the lifetime of the kit.
444
+ const LayoutSlots = {
445
+ Messages: Messages as ComponentType,
446
+ Interrupts: Interrupts as ComponentType,
447
+ Queue: Queue as ComponentType,
448
+ Input: (InputComponent ?? MissingInput) as ComponentType,
449
+ }
450
+
451
+ function Chat({ chat }: { chat: ChatUIHost<TOptions> }) {
452
+ return (
453
+ <Provider chat={chat}>
454
+ <Layout {...(LayoutSlots as ComponentProps<ComponentType<any>>)} />
455
+ </Provider>
456
+ )
457
+ }
458
+
459
+ function Queue() {
460
+ const chat = useChatContext()
461
+ if (!QueueItemComponent) return null
462
+ const items = chat.queue
463
+ return (
464
+ <>
465
+ {items.map((item) => (
466
+ <QueueItemView
467
+ key={item.id}
468
+ cancelQueued={chat.cancelQueued}
469
+ item={item}
470
+ />
471
+ ))}
472
+ </>
473
+ )
474
+ }
475
+
476
+ const QueueItemView = memo(function QueueItemView({
477
+ item,
478
+ cancelQueued,
479
+ }: {
480
+ item: QueuedMessage
481
+ cancelQueued: (id: string) => void
482
+ }) {
483
+ if (!QueueItemComponent) return null
484
+ return (
485
+ <QueueItemComponent
486
+ item={{
487
+ ...item,
488
+ cancelQueued: () => {
489
+ cancelQueued(item.id)
490
+ },
491
+ }}
492
+ />
493
+ )
494
+ }, queueItemEqual)
495
+
496
+ function Messages({
497
+ children,
498
+ }: {
499
+ children?: (messages: ChatUIMessages<TOptions>) => ReactNode
500
+ } = {}) {
501
+ const chat = useChatContext()
502
+ const messages = readMessages(chat)
503
+ const interrupts = readInterrupts(chat)
504
+ if (children) return <>{children(messages)}</>
505
+ return (
506
+ <>
507
+ {messages.map((message) => (
508
+ <MessageView
509
+ key={message.id}
510
+ inlineToolNames={inlineToolNames}
511
+ interrupts={interrupts}
512
+ message={message}
513
+ />
514
+ ))}
515
+ </>
516
+ )
517
+ }
518
+
519
+ // Scoped to one message render. `Parts` reads it instead of closing over the
520
+ // message, which is what keeps its identity constant across re-renders — a
521
+ // fresh component per render would remount every part on each stream chunk.
522
+ type MessageRenderValue = {
523
+ message: ChatUIMessages<TOptions>[number]
524
+ interrupts: ReadonlyArray<ChatUIInterrupt>
525
+ inlineToolNames: ReadonlyArray<string>
526
+ }
527
+ const MessageRenderContext = createContext<MessageRenderValue | null>(null)
528
+
529
+ function Parts() {
530
+ const scope = useContext(MessageRenderContext)
531
+ if (!scope) {
532
+ throw new Error('`Parts` must be rendered by a `message` component.')
533
+ }
534
+ return (
535
+ <AutomaticParts
536
+ inlineToolNames={scope.inlineToolNames}
537
+ interrupts={scope.interrupts}
538
+ message={scope.message}
539
+ />
540
+ )
541
+ }
542
+
543
+ const MessageView = memo(function MessageView({
544
+ message,
545
+ interrupts,
546
+ inlineToolNames: names,
547
+ children,
548
+ }: {
549
+ message: ChatUIMessages<TOptions>[number]
550
+ interrupts: ReadonlyArray<ChatUIInterrupt>
551
+ inlineToolNames: ReadonlyArray<string>
552
+ children?: (parts: Array<ChatUISelectedPart>) => ReactNode
553
+ }) {
554
+ const selected = selectMessageUI(message, {
555
+ interrupts,
556
+ inlineToolNames: names,
557
+ })
558
+ if (children) return <>{children(selected.parts)}</>
559
+ return (
560
+ <MessageRenderContext.Provider
561
+ value={{ message, interrupts, inlineToolNames: names }}
562
+ >
563
+ <MessageComponent message={message} Parts={Parts} />
564
+ </MessageRenderContext.Provider>
565
+ )
566
+ })
567
+
568
+ function Message({
569
+ message,
570
+ children,
571
+ }: {
572
+ message: ChatUIMessages<TOptions>[number]
573
+ children?: (parts: Array<ChatUISelectedPart>) => ReactNode
574
+ }) {
575
+ const chat = useChatContext()
576
+ return (
577
+ <MessageView
578
+ children={children}
579
+ inlineToolNames={inlineToolNames}
580
+ interrupts={readInterrupts(chat)}
581
+ message={message}
582
+ />
583
+ )
584
+ }
585
+
586
+ const AutomaticParts = memo(function AutomaticParts({
587
+ message,
588
+ interrupts,
589
+ inlineToolNames: names,
590
+ }: {
591
+ message: ChatUIMessages<TOptions>[number]
592
+ interrupts: ReadonlyArray<ChatUIInterrupt>
593
+ inlineToolNames: ReadonlyArray<string>
594
+ }) {
595
+ const selected = selectMessageUI(message, {
596
+ interrupts,
597
+ inlineToolNames: names,
598
+ })
599
+ return (
600
+ <>
601
+ {automaticPartsForMessage(selected).map((part, index) => (
602
+ <SelectedPartView key={`${message.id}-${index}`} selected={part} />
603
+ ))}
604
+ </>
605
+ )
606
+ })
607
+
608
+ const SelectedPartInner = memo(function SelectedPartInner({
609
+ selected,
610
+ }: {
611
+ selected: ChatUISelectedPart
612
+ }) {
613
+ if (selected.key === 'toolCall') {
614
+ const name = selected.part.name
615
+ const Tool = tools?.[name as ChatUIToolName<TOptions>] as
616
+ | ComponentType<ToolProps<TOptions>>
617
+ | undefined
618
+ if (!Tool) {
619
+ warn(`tool:${name}`, `[tanstack-ai-ui] Missing tools.${name} component`)
620
+ return null
621
+ }
622
+ return (
623
+ <Tool
624
+ part={selected.part as ToolProps<TOptions>['part']}
625
+ result={selected.result}
626
+ interrupt={selected.interrupt as ToolProps<TOptions>['interrupt']}
627
+ />
628
+ )
629
+ }
630
+
631
+ const PartComponent = (parts[selected.key] ?? parts.fallback) as
632
+ | ComponentType<PartProps<TOptions>>
633
+ | undefined
634
+ if (!PartComponent) {
635
+ warn(
636
+ `part:${selected.key}`,
637
+ `[tanstack-ai-ui] Missing parts.${selected.key} component`,
638
+ )
639
+ return null
640
+ }
641
+ return <PartComponent part={selected.part as PartProps<TOptions>['part']} />
642
+ }, selectedPartPropsEqual)
643
+
644
+ const SelectedPartView = memo(function SelectedPartView({
645
+ selected,
646
+ }: {
647
+ selected: ChatUISelectedPart
648
+ }) {
649
+ return (
650
+ <PartContext.Provider value={selected}>
651
+ <SelectedPartInner selected={selected} />
652
+ </PartContext.Provider>
653
+ )
654
+ })
655
+
656
+ function BoundRender() {
657
+ const selected = usePartContext()
658
+ return <SelectedPartInner selected={selected} />
659
+ }
660
+
661
+ function Part({
662
+ part,
663
+ children,
664
+ }: {
665
+ part: MessagePart | ChatUISelectedPart
666
+ children?: (mixed: ChatUISelectedPart & PartMixins<TOptions>) => ReactNode
667
+ }) {
668
+ const chat = useChatContext()
669
+ const selected = isSelectedPart(part)
670
+ ? part
671
+ : selectMessageUI(
672
+ { id: 'part', role: 'assistant', parts: [part] },
673
+ { interrupts: readInterrupts(chat), inlineToolNames: [] },
674
+ ).parts[0]
675
+ if (!selected) return null
676
+ return (
677
+ <PartContext.Provider value={selected}>
678
+ {children ? (
679
+ children(mixPart(selected))
680
+ ) : (
681
+ <SelectedPartInner selected={selected} />
682
+ )}
683
+ </PartContext.Provider>
684
+ )
685
+ }
686
+
687
+ function Interrupts({
688
+ children,
689
+ }: {
690
+ children?: (interrupts: ReadonlyArray<ChatUIInterrupt>) => ReactNode
691
+ } = {}) {
692
+ const chat = useChatContext()
693
+ const selected = selectChatUI({
694
+ messages: readMessages(chat),
695
+ interrupts: readInterrupts(chat),
696
+ inlineToolNames,
697
+ })
698
+ if (children) return <>{children(selected.interrupts)}</>
699
+ return (
700
+ <>
701
+ {selected.interrupts.map((interrupt) => (
702
+ <Interrupt key={interrupt.id} interrupt={interrupt} />
703
+ ))}
704
+ </>
705
+ )
706
+ }
707
+
708
+ const InterruptInner = memo(function InterruptInner({
709
+ interrupt,
710
+ }: {
711
+ interrupt: ChatUIInterrupt
712
+ }) {
713
+ const Component = resolveInterruptComponent(interrupt, interrupts) as
714
+ | ComponentType<InterruptProps<TOptions>>
715
+ | undefined
716
+ if (!Component) {
717
+ warn(
718
+ `interrupt:${interrupt.id}`,
719
+ `[tanstack-ai-ui] Missing interrupt component for ${interrupt.kind}`,
720
+ )
721
+ return null
722
+ }
723
+ return <Component interrupt={interrupt} />
724
+ })
725
+
726
+ function BoundInterruptRender() {
727
+ const interrupt = useInterruptContext()
728
+ return <InterruptInner interrupt={interrupt} />
729
+ }
730
+
731
+ const Interrupt = memo(function Interrupt({
732
+ interrupt,
733
+ children,
734
+ }: {
735
+ interrupt: ChatUIInterrupt
736
+ children?: (mixed: ChatUIInterrupt & InterruptMixins<TOptions>) => ReactNode
737
+ }) {
738
+ return (
739
+ <InterruptContext.Provider value={interrupt}>
740
+ {children ? (
741
+ children(mixInterrupt(interrupt))
742
+ ) : (
743
+ <InterruptInner interrupt={interrupt} />
744
+ )}
745
+ </InterruptContext.Provider>
746
+ )
747
+ })
748
+
749
+ return {
750
+ Chat,
751
+ Provider,
752
+ Messages,
753
+ Message,
754
+ Part,
755
+ Interrupts,
756
+ Interrupt,
757
+ Queue,
758
+ useChatContext,
759
+ Input: InputComponent,
760
+ }
761
+ }