@tanstack/ai-react 0.19.3 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  AnyClientTool,
3
+ InterruptDefinition,
3
4
  InferSchemaType,
4
5
  ModelMessage,
5
6
  RunAgentResumeItem,
@@ -10,7 +11,7 @@ import type {
10
11
  BoundInterrupts,
11
12
  ChatClientOptions,
12
13
  ChatClientState,
13
- ChatInterrupt,
14
+ ResolvableChatInterrupt,
14
15
  ChatInterruptState,
15
16
  ChatRequestBody,
16
17
  ChatResumeState,
@@ -83,8 +84,10 @@ export type UseChatOptions<
83
84
  TTools extends ReadonlyArray<AnyClientTool> = any,
84
85
  TSchema extends SchemaInput | undefined = undefined,
85
86
  TContext = InferredClientContext<TTools>,
87
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =
88
+ readonly [],
86
89
  > = DistributedOmit<
87
- ChatClientOptions<TTools, TContext>,
90
+ ChatClientOptions<TTools, TContext, TInterrupts>,
88
91
  | 'onMessagesChange'
89
92
  | 'onLoadingChange'
90
93
  | 'onErrorChange'
@@ -97,10 +100,6 @@ export type UseChatOptions<
97
100
  | 'onRunIdChange'
98
101
  | 'context'
99
102
  | 'devtools'
100
- // `id` is not a hook option: the hook's identity is its `threadId`, which is
101
- // also the persistence key. Persist across reloads by passing a stable
102
- // `threadId`; there is no separate id to set.
103
- | 'id'
104
103
  > & {
105
104
  /** Display options for TanStack AI Devtools. */
106
105
  devtools?: AIDevtoolsDisplayOptions
@@ -126,9 +125,12 @@ export type UseChatOptions<
126
125
  export type UseChatReturn<
127
126
  TTools extends ReadonlyArray<AnyClientTool> = any,
128
127
  TSchema extends SchemaInput | undefined = undefined,
128
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =
129
+ readonly [],
129
130
  > = BaseUseChatReturn<
130
131
  TTools,
131
- TSchema extends SchemaInput ? InferSchemaType<TSchema> : unknown
132
+ TSchema extends SchemaInput ? InferSchemaType<TSchema> : unknown,
133
+ TInterrupts
132
134
  > &
133
135
  (TSchema extends SchemaInput
134
136
  ? {
@@ -151,6 +153,8 @@ export type UseChatReturn<
151
153
  interface BaseUseChatReturn<
152
154
  TTools extends ReadonlyArray<AnyClientTool> = any,
153
155
  TData = unknown,
156
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =
157
+ readonly [],
154
158
  > {
155
159
  /**
156
160
  * Current messages in the conversation. When `outputSchema` is supplied,
@@ -218,14 +222,18 @@ interface BaseUseChatReturn<
218
222
  * it, correlate a log line).
219
223
  */
220
224
  runId: string | null
221
- interrupts: BoundInterrupts<TTools>
225
+ interrupts: BoundInterrupts<TTools, TInterrupts>
222
226
  /** @deprecated Use `interrupts`. */
223
- pendingInterrupts: BoundInterrupts<TTools>
224
- interruptErrors: ChatInterruptState<TTools>['interruptErrors']
227
+ pendingInterrupts: BoundInterrupts<TTools, TInterrupts>
228
+ interruptErrors: ChatInterruptState<TTools, TInterrupts>['interruptErrors']
225
229
  resuming: boolean
226
230
  resolveInterrupts: {
227
231
  (approved: boolean): void
228
- (resolver: (interrupt: ChatInterrupt<TTools>) => undefined): void
232
+ (
233
+ resolver: (
234
+ interrupt: ResolvableChatInterrupt<TTools, TInterrupts>,
235
+ ) => undefined,
236
+ ): void
229
237
  }
230
238
  cancelInterrupts: () => void
231
239
  retryInterrupts: () => void
package/src/use-chat.ts CHANGED
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
4
4
  import type {
5
5
  AnyClientTool,
6
6
  InferSchemaType,
7
+ InterruptDefinition,
7
8
  ModelMessage,
8
9
  RunAgentResumeItem,
9
10
  SchemaInput,
@@ -11,7 +12,7 @@ import type {
11
12
  } from '@tanstack/ai/client'
12
13
  import type {
13
14
  ChatClientState,
14
- ChatInterrupt,
15
+ ResolvableChatInterrupt,
15
16
  ChatInterruptState,
16
17
  ChatResumeState,
17
18
  ConnectionStatus,
@@ -36,13 +37,15 @@ export function useChat<
36
37
  const TTools extends ReadonlyArray<AnyClientTool> = any,
37
38
  TSchema extends SchemaInput | undefined = undefined,
38
39
  TContext = InferredClientContext<TTools>,
40
+ const TInterrupts extends ReadonlyArray<
41
+ InterruptDefinition<any, any, any, any>
42
+ > = readonly [],
39
43
  >(
40
- options: UseChatOptions<TTools, TSchema, TContext>,
41
- ): UseChatReturn<TTools, TSchema> {
42
- // The hook's identity is its `threadId` — also the persistence key, so a
43
- // reload with the same `threadId` restores the same conversation. `hookId` is
44
- // only a stable fallback for React's client-recreation keying when no
45
- // `threadId` is given (an ephemeral chat), never a persistence key.
44
+ options: UseChatOptions<TTools, TSchema, TContext, TInterrupts>,
45
+ ): UseChatReturn<TTools, TSchema, TInterrupts> {
46
+ // The hook's identity is its `threadId`. Reload with the same `threadId`
47
+ // restores the same conversation. `hookId` is only a React recreation key
48
+ // when no `threadId` is given. It is never sent on the wire.
46
49
  const hookId = useId()
47
50
  const clientId = options.threadId ?? hookId
48
51
 
@@ -59,7 +62,7 @@ export function useChat<
59
62
  const [queue, setQueue] = useState<Array<QueuedMessage>>([])
60
63
  const [runId, setRunId] = useState<string | null>(null)
61
64
  const [interruptState, setInterruptState] = useState<
62
- ChatInterruptState<TTools>
65
+ ChatInterruptState<TTools, TInterrupts>
63
66
  >(() => ({
64
67
  interrupts: EMPTY_INTERRUPTS,
65
68
  pendingInterrupts: EMPTY_INTERRUPTS,
@@ -89,7 +92,8 @@ export function useChat<
89
92
  messagesRef.current = messages
90
93
 
91
94
  // Track current options in a ref to avoid recreating client when options change
92
- const optionsRef = useRef<UseChatOptions<TTools, TSchema, TContext>>(options)
95
+ const optionsRef =
96
+ useRef<UseChatOptions<TTools, TSchema, TContext, TInterrupts>>(options)
93
97
  optionsRef.current = options
94
98
 
95
99
  const syncResumeState = useCallback((target: ChatClient | null) => {
@@ -99,7 +103,7 @@ export function useChat<
99
103
  }, [])
100
104
 
101
105
  // Create ChatClient instance with callbacks to sync state
102
- const client = useMemo(() => {
106
+ const { client, initialization } = useMemo(() => {
103
107
  const messagesToUse = options.initialMessages || []
104
108
  isFirstMountRef.current = false
105
109
 
@@ -113,7 +117,7 @@ export function useChat<
113
117
  : { fetcher: initialOptions.fetcher }
114
118
 
115
119
  const instanceHolder: {
116
- current: ChatClient<TTools, TContext> | undefined
120
+ current: ChatClient<TTools, TContext, TInterrupts> | undefined
117
121
  } = { current: undefined }
118
122
  const getActiveInstance = () => {
119
123
  const currentInstance = instanceHolder.current
@@ -122,21 +126,43 @@ export function useChat<
122
126
  }
123
127
  return currentInstance
124
128
  }
125
- const pendingInitializationErrors: Array<Error> = []
126
- const instance = new ChatClient<TTools, TContext>({
129
+ // ChatClient may publish while its constructor is running or while async
130
+ // persistence resolves before commit. Preserve those exact notifications
131
+ // until this render commits; invoking them here would run state setters and
132
+ // user callbacks for a client React may abandon.
133
+ const initializationState = {
134
+ ready: false,
135
+ callbacks: [] as Array<() => void>,
136
+ }
137
+ const runOrQueueForActiveInstance = (callback: () => void) => {
138
+ if (!initializationState.ready) {
139
+ initializationState.callbacks.push(callback)
140
+ return
141
+ }
142
+ const currentInstance = instanceHolder.current
143
+ if (!currentInstance || activeClientRef.current !== currentInstance)
144
+ return
145
+ callback()
146
+ }
147
+ const instance = new ChatClient<TTools, TContext, TInterrupts>({
127
148
  devtoolsBridgeFactory: createChatDevtoolsBridge,
128
149
  ...transport,
129
150
  initialMessages: messagesToUse,
151
+ ...(typeof initialOptions.threadId === 'string' &&
152
+ initialOptions.persistence
153
+ ? {
154
+ persistence: initialOptions.persistence,
155
+ threadId: initialOptions.threadId,
156
+ }
157
+ : {
158
+ ...(initialOptions.threadId !== undefined && {
159
+ threadId: initialOptions.threadId,
160
+ }),
161
+ }),
130
162
  ...(initialOptions.body !== undefined && { body: initialOptions.body }),
131
- ...(initialOptions.threadId !== undefined && {
132
- threadId: initialOptions.threadId,
133
- }),
134
163
  ...(initialOptions.forwardedProps !== undefined && {
135
164
  forwardedProps: initialOptions.forwardedProps,
136
165
  }),
137
- ...(initialOptions.persistence !== undefined && {
138
- persistence: initialOptions.persistence,
139
- }),
140
166
  ...(initialOptions.initialResumeSnapshot !== undefined && {
141
167
  initialResumeSnapshot: initialOptions.initialResumeSnapshot,
142
168
  }),
@@ -150,100 +176,131 @@ export function useChat<
150
176
  outputKind: initialOptions.outputSchema ? 'structured' : 'chat',
151
177
  },
152
178
  onResponse: (response) => {
179
+ // ChatClient awaits this return value. Queuing would drop the promise.
153
180
  if (!getActiveInstance()) return
154
- void optionsRef.current.onResponse?.(response)
181
+ return optionsRef.current.onResponse?.(response)
155
182
  },
156
183
  onChunk: (chunk: StreamChunk) => {
157
- if (!getActiveInstance()) return
158
- optionsRef.current.onChunk?.(chunk)
184
+ runOrQueueForActiveInstance(() => {
185
+ optionsRef.current.onChunk?.(chunk)
186
+ })
159
187
  },
160
188
  onFinish: (message: UIMessage<TTools>) => {
161
- if (!getActiveInstance()) return
162
- optionsRef.current.onFinish?.(message)
189
+ runOrQueueForActiveInstance(() => {
190
+ optionsRef.current.onFinish?.(message)
191
+ })
163
192
  },
164
193
  onError: (error: Error) => {
165
- const currentInstance = instanceHolder.current
166
- if (!currentInstance) {
167
- pendingInitializationErrors.push(error)
168
- return
169
- }
170
- if (activeClientRef.current !== currentInstance) return
171
- optionsRef.current.onError?.(error)
194
+ runOrQueueForActiveInstance(() => {
195
+ optionsRef.current.onError?.(error)
196
+ })
172
197
  },
173
198
  ...(initialOptions.tools !== undefined && {
174
199
  tools: initialOptions.tools,
175
200
  }),
201
+ ...(initialOptions.interrupts !== undefined && {
202
+ interrupts: initialOptions.interrupts,
203
+ }),
176
204
  onCustomEvent: (eventType, data, context) => {
177
- if (!getActiveInstance()) return
178
- optionsRef.current.onCustomEvent?.(eventType, data, context)
205
+ runOrQueueForActiveInstance(() => {
206
+ optionsRef.current.onCustomEvent?.(eventType, data, context)
207
+ })
179
208
  },
180
209
  ...(options.streamProcessor !== undefined && {
181
210
  streamProcessor: options.streamProcessor,
182
211
  }),
183
212
  onMessagesChange: (newMessages: Array<UIMessage<TTools>>) => {
184
- if (!getActiveInstance()) return
185
- setMessages(newMessages)
213
+ runOrQueueForActiveInstance(() => {
214
+ setMessages(newMessages)
215
+ })
186
216
  },
187
217
  onLoadingChange: (newIsLoading: boolean) => {
188
- const currentInstance = getActiveInstance()
189
- if (!currentInstance) return
190
- setIsLoading(newIsLoading)
191
- syncResumeState(currentInstance)
218
+ runOrQueueForActiveInstance(() => {
219
+ const currentInstance = getActiveInstance()
220
+ if (!currentInstance) return
221
+ setIsLoading(newIsLoading)
222
+ syncResumeState(currentInstance)
223
+ })
192
224
  },
193
225
  onErrorChange: (newError: Error | undefined) => {
194
- if (!getActiveInstance()) return
195
- setError(newError)
226
+ runOrQueueForActiveInstance(() => {
227
+ setError(newError)
228
+ })
196
229
  },
197
230
  onStatusChange: (status: ChatClientState) => {
198
- if (!getActiveInstance()) return
199
- setStatus(status)
231
+ runOrQueueForActiveInstance(() => {
232
+ setStatus(status)
233
+ })
200
234
  },
201
235
  onSubscriptionChange: (nextIsSubscribed: boolean) => {
202
- if (!getActiveInstance()) return
203
- setIsSubscribed(nextIsSubscribed)
236
+ runOrQueueForActiveInstance(() => {
237
+ setIsSubscribed(nextIsSubscribed)
238
+ })
204
239
  },
205
240
  onConnectionStatusChange: (nextStatus: ConnectionStatus) => {
206
- if (!getActiveInstance()) return
207
- setConnectionStatus(nextStatus)
241
+ runOrQueueForActiveInstance(() => {
242
+ setConnectionStatus(nextStatus)
243
+ })
208
244
  },
209
245
  onSessionGeneratingChange: (isGenerating: boolean) => {
210
- if (!getActiveInstance()) return
211
- setSessionGenerating(isGenerating)
246
+ runOrQueueForActiveInstance(() => {
247
+ setSessionGenerating(isGenerating)
248
+ })
212
249
  },
213
250
  ...(optionsRef.current.queue !== undefined && {
214
251
  queue: optionsRef.current.queue,
215
252
  }),
216
253
  onQueueChange: (nextQueue: Array<QueuedMessage>) => {
217
- if (activeClientRef.current !== instance) return
218
- setQueue(nextQueue)
254
+ runOrQueueForActiveInstance(() => {
255
+ setQueue(nextQueue)
256
+ })
219
257
  },
220
258
  onRunIdChange: (nextRunId) => {
221
- if (!getActiveInstance()) return
222
- setRunId(nextRunId)
259
+ runOrQueueForActiveInstance(() => {
260
+ setRunId(nextRunId)
261
+ })
223
262
  },
224
263
  onResumeStateChange: (_nextResumeState, nextPendingInterrupts) => {
225
- if (!getActiveInstance()) return
226
- setInterruptState((current) => ({
227
- ...current,
228
- interrupts: nextPendingInterrupts,
229
- pendingInterrupts: nextPendingInterrupts,
230
- }))
264
+ runOrQueueForActiveInstance(() => {
265
+ setInterruptState((current) => ({
266
+ ...current,
267
+ interrupts: nextPendingInterrupts,
268
+ pendingInterrupts: nextPendingInterrupts,
269
+ }))
270
+ })
231
271
  },
232
- onInterruptStateChange: (nextInterruptState) => {
233
- if (!getActiveInstance()) return
234
- setInterruptState(nextInterruptState)
235
- optionsRef.current.onInterruptStateChange?.(nextInterruptState)
272
+ onInterruptStateChange: (nextInterruptState, context) => {
273
+ runOrQueueForActiveInstance(() => {
274
+ setInterruptState(nextInterruptState)
275
+ optionsRef.current.onInterruptStateChange?.(
276
+ nextInterruptState,
277
+ context,
278
+ )
279
+ })
236
280
  },
237
281
  })
238
282
  instanceHolder.current = instance
239
- activeClientRef.current = instance
240
- for (const error of pendingInitializationErrors) {
241
- if (activeClientRef.current !== instance) break
242
- optionsRef.current.onError?.(error)
243
- }
244
- return instance
283
+ return { client: instance, initialization: initializationState }
245
284
  }, [clientId, syncResumeState])
246
285
 
286
+ useEffect(() => {
287
+ activeClientRef.current = client
288
+ try {
289
+ // Keep initialization closed while draining so callbacks published by a
290
+ // queued callback are appended and delivered in the same commit.
291
+ while (initialization.callbacks.length > 0) {
292
+ if (activeClientRef.current !== client) {
293
+ initialization.callbacks.length = 0
294
+ break
295
+ }
296
+ initialization.callbacks.shift()?.()
297
+ }
298
+ } finally {
299
+ // A throw from a queued user callback must not leave the queue closed.
300
+ initialization.ready = true
301
+ }
302
+ }, [client, initialization])
303
+
247
304
  useEffect(() => {
248
305
  const clientMessages = client.getMessages()
249
306
  if (clientMessages !== messagesRef.current) {
@@ -327,7 +384,6 @@ export function useChat<
327
384
  clearTimeout(cleanupInvalidationRef.current)
328
385
  cleanupInvalidationRef.current = null
329
386
  }
330
- activeClientRef.current = client
331
387
  client.mountDevtools()
332
388
  // Delivery-durability resume is transparent: the resumable SSE connection
333
389
  // adapter re-attaches via the browser's native Last-Event-ID on reconnect.
@@ -458,7 +514,11 @@ export function useChat<
458
514
 
459
515
  const resolveInterrupts = useCallback(
460
516
  (
461
- resolution: boolean | ((interrupt: ChatInterrupt<TTools>) => undefined),
517
+ resolution:
518
+ | boolean
519
+ | ((
520
+ interrupt: ResolvableChatInterrupt<TTools, TInterrupts>,
521
+ ) => undefined),
462
522
  ) => {
463
523
  if (typeof resolution === 'boolean') {
464
524
  client.resolveInterrupts(resolution)
@@ -562,5 +622,5 @@ export function useChat<
562
622
  resumeInterrupts,
563
623
  partial,
564
624
  final,
565
- } as unknown as UseChatReturn<TTools, TSchema>
625
+ } as unknown as UseChatReturn<TTools, TSchema, TInterrupts>
566
626
  }
@@ -21,10 +21,6 @@ export interface UseGenerateAudioOptions<TOutput = AudioGenerationResult> {
21
21
  connection?: ConnectConnectionAdapter
22
22
  /** Direct async function for audio generation */
23
23
  fetcher?: GenerationFetcher<AudioGenerateInput, AudioGenerationResult>
24
- /**
25
- * @deprecated Prefer `threadId`. Only allowed when `threadId` is omitted (see `GenerationPersistenceOptions`).
26
- */
27
- id?: string
28
24
  /** Additional body parameters to send with connect-based adapter requests */
29
25
  body?: Record<string, any>
30
26
  /** Display options for TanStack AI Devtools. */
@@ -48,8 +44,8 @@ export interface UseGenerateAudioOptions<TOutput = AudioGenerationResult> {
48
44
  * id on the wire, which the protocol requires.
49
45
  *
50
46
  * **Required whenever `persistence` is set** — an app that cannot name the
51
- * scope has nothing to restore to. Optional for ephemeral generations, where
52
- * it falls back to `id` purely to satisfy the wire.
47
+ * scope has nothing to restore to. Optional for ephemeral generations. If
48
+ * omitted, the client mints a wire id after mount.
53
49
  */
54
50
  threadId?: string
55
51
  /**
@@ -144,7 +140,7 @@ export interface UseGenerateAudioReturn<TOutput = AudioGenerationResult> {
144
140
  export function useGenerateAudio<TTransformed = void>(
145
141
  options: Omit<
146
142
  UseGenerateAudioOptions,
147
- 'onResult' | 'persistence' | 'threadId' | 'id'
143
+ 'onResult' | 'persistence' | 'threadId'
148
144
  > & {
149
145
  onResult?: (result: AudioGenerationResult) => TTransformed
150
146
  } & GenerationPersistenceOptions,
@@ -21,10 +21,6 @@ export interface UseGenerateImageOptions<TOutput = ImageGenerationResult> {
21
21
  connection?: ConnectConnectionAdapter
22
22
  /** Direct async function for image generation */
23
23
  fetcher?: GenerationFetcher<ImageGenerateInput, ImageGenerationResult>
24
- /**
25
- * @deprecated Prefer `threadId`. Only allowed when `threadId` is omitted (see `GenerationPersistenceOptions`).
26
- */
27
- id?: string
28
24
  /** Additional body parameters to send with connect-based adapter requests */
29
25
  body?: Record<string, any>
30
26
  /** Display options for TanStack AI Devtools. */
@@ -48,8 +44,8 @@ export interface UseGenerateImageOptions<TOutput = ImageGenerationResult> {
48
44
  * id on the wire, which the protocol requires.
49
45
  *
50
46
  * **Required whenever `persistence` is set** — an app that cannot name the
51
- * scope has nothing to restore to. Optional for ephemeral generations, where
52
- * it falls back to `id` purely to satisfy the wire.
47
+ * scope has nothing to restore to. Optional for ephemeral generations. If
48
+ * omitted, the client mints a wire id after mount.
53
49
  */
54
50
  threadId?: string
55
51
  /**
@@ -146,7 +142,7 @@ export interface UseGenerateImageReturn<TOutput = ImageGenerationResult> {
146
142
  export function useGenerateImage<TTransformed = void>(
147
143
  options: Omit<
148
144
  UseGenerateImageOptions,
149
- 'onResult' | 'persistence' | 'threadId' | 'id'
145
+ 'onResult' | 'persistence' | 'threadId'
150
146
  > & {
151
147
  onResult?: (result: ImageGenerationResult) => TTransformed
152
148
  } & GenerationPersistenceOptions,
@@ -21,10 +21,6 @@ export interface UseGenerateSpeechOptions<TOutput = TTSResult> {
21
21
  connection?: ConnectConnectionAdapter
22
22
  /** Direct async function for speech generation */
23
23
  fetcher?: GenerationFetcher<SpeechGenerateInput, TTSResult>
24
- /**
25
- * @deprecated Prefer `threadId`. Only allowed when `threadId` is omitted (see `GenerationPersistenceOptions`).
26
- */
27
- id?: string
28
24
  /** Additional body parameters to send with connect-based adapter requests */
29
25
  body?: Record<string, any>
30
26
  /** Display options for TanStack AI Devtools. */
@@ -48,8 +44,8 @@ export interface UseGenerateSpeechOptions<TOutput = TTSResult> {
48
44
  * id on the wire, which the protocol requires.
49
45
  *
50
46
  * **Required whenever `persistence` is set** — an app that cannot name the
51
- * scope has nothing to restore to. Optional for ephemeral generations, where
52
- * it falls back to `id` purely to satisfy the wire.
47
+ * scope has nothing to restore to. Optional for ephemeral generations. If
48
+ * omitted, the client mints a wire id after mount.
53
49
  */
54
50
  threadId?: string
55
51
  /**
@@ -140,7 +136,7 @@ export interface UseGenerateSpeechReturn<TOutput = TTSResult> {
140
136
  export function useGenerateSpeech<TTransformed = void>(
141
137
  options: Omit<
142
138
  UseGenerateSpeechOptions,
143
- 'onResult' | 'persistence' | 'threadId' | 'id'
139
+ 'onResult' | 'persistence' | 'threadId'
144
140
  > & {
145
141
  onResult?: (result: TTSResult) => TTransformed
146
142
  } & GenerationPersistenceOptions,
@@ -11,6 +11,7 @@ import type {
11
11
  InferGenerationOutputFromReturn,
12
12
  VideoGenerateInput,
13
13
  VideoGenerateResult,
14
+ VideoGenerationClientOptions,
14
15
  VideoStatusInfo,
15
16
  } from '@tanstack/ai-client'
16
17
 
@@ -22,10 +23,6 @@ export interface UseGenerateVideoOptions<TOutput = VideoGenerateResult> {
22
23
  connection?: ConnectConnectionAdapter
23
24
  /** Direct async function that returns a completed video result */
24
25
  fetcher?: GenerationFetcher<VideoGenerateInput, VideoGenerateResult>
25
- /**
26
- * @deprecated Prefer `threadId`. Only allowed when `threadId` is omitted (see `GenerationPersistenceOptions`).
27
- */
28
- id?: string
29
26
  /** Additional body parameters to send with connect-based adapter requests */
30
27
  body?: Record<string, any>
31
28
  /** Display options for TanStack AI Devtools. */
@@ -49,8 +46,8 @@ export interface UseGenerateVideoOptions<TOutput = VideoGenerateResult> {
49
46
  * id on the wire, which the protocol requires.
50
47
  *
51
48
  * **Required whenever `persistence` is set** — an app that cannot name the
52
- * scope has nothing to restore to. Optional for ephemeral generations, where
53
- * it falls back to `id` purely to satisfy the wire.
49
+ * scope has nothing to restore to. Optional for ephemeral generations. If
50
+ * omitted, the client mints a wire id after mount.
54
51
  */
55
52
  threadId?: string
56
53
  /**
@@ -157,7 +154,7 @@ export interface UseGenerateVideoReturn<TOutput = VideoGenerateResult> {
157
154
  export function useGenerateVideo<TTransformed = void>(
158
155
  options: Omit<
159
156
  UseGenerateVideoOptions,
160
- 'onResult' | 'persistence' | 'threadId' | 'id'
157
+ 'onResult' | 'persistence' | 'threadId'
161
158
  > & {
162
159
  onResult?: (result: VideoGenerateResult) => TTransformed
163
160
  } & GenerationPersistenceOptions,
@@ -169,8 +166,8 @@ export function useGenerateVideo<TTransformed = void>(
169
166
  TTransformed
170
167
  >
171
168
  const hookId = useId()
172
- // Single identity: prefer `threadId`; deprecated `id` only when no threadId.
173
- const clientIdentity = options.threadId ?? options.id ?? hookId
169
+ // The hook identity is `threadId`. `hookId` is only a React recreation key.
170
+ const clientIdentity = options.threadId ?? hookId
174
171
 
175
172
  const [result, setResult] = useState<TOutput | null>(null)
176
173
  const [jobId, setJobId] = useState<string | null>(null)
@@ -192,13 +189,11 @@ export function useGenerateVideo<TTransformed = void>(
192
189
  // `?.()`'s implicit `undefined` doesn't widen the function
193
190
  // return type (which `exactOptionalPropertyTypes` rejects
194
191
  // against the strict-optional target).
195
- // Identity: pass `threadId` alone when set (never also pass deprecated `id`).
196
- const baseOptions = {
192
+ const baseOptions: Omit<
193
+ VideoGenerationClientOptions<TOutput>,
194
+ 'persistence' | 'threadId'
195
+ > = {
197
196
  body: opts.body,
198
- ...(opts.threadId !== undefined
199
- ? { threadId: opts.threadId }
200
- : { id: opts.id ?? hookId }),
201
- ...(opts.persistence !== undefined && { persistence: opts.persistence }),
202
197
  ...(opts.hydrateGeneration !== undefined && {
203
198
  hydrateGeneration: opts.hydrateGeneration,
204
199
  }),
@@ -255,9 +250,20 @@ export function useGenerateVideo<TTransformed = void>(
255
250
  },
256
251
  }
257
252
 
253
+ const persistenceProps =
254
+ typeof opts.threadId === 'string' && opts.persistence
255
+ ? {
256
+ persistence: opts.persistence,
257
+ threadId: opts.threadId,
258
+ }
259
+ : {
260
+ ...(opts.threadId !== undefined && { threadId: opts.threadId }),
261
+ }
262
+
258
263
  if (opts.connection) {
259
264
  return new VideoGenerationClient<TOutput>({
260
265
  ...baseOptions,
266
+ ...persistenceProps,
261
267
  connection: opts.connection,
262
268
  })
263
269
  }
@@ -265,6 +271,7 @@ export function useGenerateVideo<TTransformed = void>(
265
271
  if (opts.fetcher) {
266
272
  return new VideoGenerationClient<TOutput>({
267
273
  ...baseOptions,
274
+ ...persistenceProps,
268
275
  fetcher: opts.fetcher,
269
276
  })
270
277
  }