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