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