@tanstack/ai-preact 0.11.1 → 0.12.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.
@@ -1,26 +1,49 @@
1
- import { useRef, useMemo } from "preact/hooks";
2
1
  import { createMcpAppBridge } from "@tanstack/ai-client";
2
+ import { useMemo, useRef } from "preact/hooks";
3
+ //#region src/use-mcp-app-bridge.ts
4
+ /**
5
+ * Preact wrapper around `createMcpAppBridge` that returns a **stable** bridge for
6
+ * the given `threadId`/`callEndpoint`, while always invoking the latest
7
+ * `chat.sendMessage` and `onLink` (kept in refs). This avoids both recreating
8
+ * the bridge on every render and the stale-closure / `exhaustive-deps` dance
9
+ * you'd otherwise write by hand:
10
+ *
11
+ * ```tsx
12
+ * const { sendMessage } = useChat({ threadId, connection })
13
+ * const bridge = useMcpAppBridge({
14
+ * threadId,
15
+ * callEndpoint: '/api/mcp-apps-call',
16
+ * chat: { sendMessage: async (content) => void sendMessage(content) },
17
+ * onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
18
+ * })
19
+ * // pass `bridge` to <MCPAppResource bridge={bridge} … />
20
+ * ```
21
+ *
22
+ * The bridge is recreated only when `threadId`, `callEndpoint`, `fetchImpl`, or
23
+ * the *presence* of `onLink` changes — passing a new inline `onLink`/`sendMessage`
24
+ * each render does not churn it.
25
+ */
3
26
  function useMcpAppBridge(options) {
4
- const { threadId, callEndpoint, chat, fetchImpl, onLink } = options;
5
- const chatRef = useRef(chat);
6
- chatRef.current = chat;
7
- const onLinkRef = useRef(onLink);
8
- onLinkRef.current = onLink;
9
- const hasOnLink = onLink != null;
10
- return useMemo(
11
- () => createMcpAppBridge({
12
- threadId,
13
- callEndpoint,
14
- fetchImpl,
15
- chat: {
16
- sendMessage: (content, body) => chatRef.current.sendMessage(content, body)
17
- },
18
- onLink: hasOnLink ? (url) => onLinkRef.current?.(url) : void 0
19
- }),
20
- [threadId, callEndpoint, fetchImpl, hasOnLink]
21
- );
27
+ const { threadId, callEndpoint, chat, fetchImpl, onLink } = options;
28
+ const chatRef = useRef(chat);
29
+ chatRef.current = chat;
30
+ const onLinkRef = useRef(onLink);
31
+ onLinkRef.current = onLink;
32
+ const hasOnLink = onLink != null;
33
+ return useMemo(() => createMcpAppBridge({
34
+ threadId,
35
+ callEndpoint,
36
+ fetchImpl,
37
+ chat: { sendMessage: (content, body) => chatRef.current.sendMessage(content, body) },
38
+ onLink: hasOnLink ? (url) => onLinkRef.current?.(url) : void 0
39
+ }), [
40
+ threadId,
41
+ callEndpoint,
42
+ fetchImpl,
43
+ hasOnLink
44
+ ]);
22
45
  }
23
- export {
24
- useMcpAppBridge
25
- };
26
- //# sourceMappingURL=use-mcp-app-bridge.js.map
46
+ //#endregion
47
+ export { useMcpAppBridge };
48
+
49
+ //# sourceMappingURL=use-mcp-app-bridge.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-mcp-app-bridge.js","sources":["../../src/use-mcp-app-bridge.ts"],"sourcesContent":["import { useMemo, useRef } from 'preact/hooks'\nimport { createMcpAppBridge } from '@tanstack/ai-client'\nimport type {\n CreateMcpAppBridgeOptions,\n McpAppBridge,\n} from '@tanstack/ai-client'\n\nexport type UseMcpAppBridgeOptions = CreateMcpAppBridgeOptions\n\n/**\n * Preact wrapper around `createMcpAppBridge` that returns a **stable** bridge for\n * the given `threadId`/`callEndpoint`, while always invoking the latest\n * `chat.sendMessage` and `onLink` (kept in refs). This avoids both recreating\n * the bridge on every render and the stale-closure / `exhaustive-deps` dance\n * you'd otherwise write by hand:\n *\n * ```tsx\n * const { sendMessage } = useChat({ threadId, connection })\n * const bridge = useMcpAppBridge({\n * threadId,\n * callEndpoint: '/api/mcp-apps-call',\n * chat: { sendMessage: async (content) => void sendMessage(content) },\n * onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'),\n * })\n * // pass `bridge` to <MCPAppResource bridge={bridge} … />\n * ```\n *\n * The bridge is recreated only when `threadId`, `callEndpoint`, `fetchImpl`, or\n * the *presence* of `onLink` changes — passing a new inline `onLink`/`sendMessage`\n * each render does not churn it.\n */\nexport function useMcpAppBridge(options: UseMcpAppBridgeOptions): McpAppBridge {\n const { threadId, callEndpoint, chat, fetchImpl, onLink } = options\n\n // Latest-value refs so the bridge identity stays stable but its callbacks are\n // never stale (the bridge calls `.current` at invocation time, not creation).\n const chatRef = useRef(chat)\n chatRef.current = chat\n const onLinkRef = useRef(onLink)\n onLinkRef.current = onLink\n\n // Whether a link handler was supplied governs the bridge's link behavior\n // (forward vs. display-only warn), so it's part of the bridge's identity.\n const hasOnLink = onLink != null\n\n return useMemo(\n () =>\n createMcpAppBridge({\n threadId,\n callEndpoint,\n fetchImpl,\n chat: {\n sendMessage: (content, body) =>\n chatRef.current.sendMessage(content, body),\n },\n onLink: hasOnLink ? (url) => onLinkRef.current?.(url) : undefined,\n }),\n [threadId, callEndpoint, fetchImpl, hasOnLink],\n )\n}\n"],"names":[],"mappings":";;AA+BO,SAAS,gBAAgB,SAA+C;AAC7E,QAAM,EAAE,UAAU,cAAc,MAAM,WAAW,WAAW;AAI5D,QAAM,UAAU,OAAO,IAAI;AAC3B,UAAQ,UAAU;AAClB,QAAM,YAAY,OAAO,MAAM;AAC/B,YAAU,UAAU;AAIpB,QAAM,YAAY,UAAU;AAE5B,SAAO;AAAA,IACL,MACE,mBAAmB;AAAA,MACjB;AAAA,MACA;AAAA,MACA;AAAA,MACA,MAAM;AAAA,QACJ,aAAa,CAAC,SAAS,SACrB,QAAQ,QAAQ,YAAY,SAAS,IAAI;AAAA,MAAA;AAAA,MAE7C,QAAQ,YAAY,CAAC,QAAQ,UAAU,UAAU,GAAG,IAAI;AAAA,IAAA,CACzD;AAAA,IACH,CAAC,UAAU,cAAc,WAAW,SAAS;AAAA,EAAA;AAEjD;"}
1
+ {"version":3,"file":"use-mcp-app-bridge.js","names":[],"sources":["../../src/use-mcp-app-bridge.ts"],"sourcesContent":["import { useMemo, useRef } from 'preact/hooks'\nimport { createMcpAppBridge } from '@tanstack/ai-client'\nimport type {\n CreateMcpAppBridgeOptions,\n McpAppBridge,\n} from '@tanstack/ai-client'\n\nexport type UseMcpAppBridgeOptions = CreateMcpAppBridgeOptions\n\n/**\n * Preact wrapper around `createMcpAppBridge` that returns a **stable** bridge for\n * the given `threadId`/`callEndpoint`, while always invoking the latest\n * `chat.sendMessage` and `onLink` (kept in refs). This avoids both recreating\n * the bridge on every render and the stale-closure / `exhaustive-deps` dance\n * you'd otherwise write by hand:\n *\n * ```tsx\n * const { sendMessage } = useChat({ threadId, connection })\n * const bridge = useMcpAppBridge({\n * threadId,\n * callEndpoint: '/api/mcp-apps-call',\n * chat: { sendMessage: async (content) => void sendMessage(content) },\n * onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'),\n * })\n * // pass `bridge` to <MCPAppResource bridge={bridge} … />\n * ```\n *\n * The bridge is recreated only when `threadId`, `callEndpoint`, `fetchImpl`, or\n * the *presence* of `onLink` changes — passing a new inline `onLink`/`sendMessage`\n * each render does not churn it.\n */\nexport function useMcpAppBridge(options: UseMcpAppBridgeOptions): McpAppBridge {\n const { threadId, callEndpoint, chat, fetchImpl, onLink } = options\n\n // Latest-value refs so the bridge identity stays stable but its callbacks are\n // never stale (the bridge calls `.current` at invocation time, not creation).\n const chatRef = useRef(chat)\n chatRef.current = chat\n const onLinkRef = useRef(onLink)\n onLinkRef.current = onLink\n\n // Whether a link handler was supplied governs the bridge's link behavior\n // (forward vs. display-only warn), so it's part of the bridge's identity.\n const hasOnLink = onLink != null\n\n return useMemo(\n () =>\n createMcpAppBridge({\n threadId,\n callEndpoint,\n fetchImpl,\n chat: {\n sendMessage: (content, body) =>\n chatRef.current.sendMessage(content, body),\n },\n onLink: hasOnLink ? (url) => onLinkRef.current?.(url) : undefined,\n }),\n [threadId, callEndpoint, fetchImpl, hasOnLink],\n )\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,SAAgB,gBAAgB,SAA+C;CAC7E,MAAM,EAAE,UAAU,cAAc,MAAM,WAAW,WAAW;CAI5D,MAAM,UAAU,OAAO,IAAI;CAC3B,QAAQ,UAAU;CAClB,MAAM,YAAY,OAAO,MAAM;CAC/B,UAAU,UAAU;CAIpB,MAAM,YAAY,UAAU;CAE5B,OAAO,cAEH,mBAAmB;EACjB;EACA;EACA;EACA,MAAM,EACJ,cAAc,SAAS,SACrB,QAAQ,QAAQ,YAAY,SAAS,IAAI,EAC7C;EACA,QAAQ,aAAa,QAAQ,UAAU,UAAU,GAAG,IAAI,KAAA;CAC1D,CAAC,GACH;EAAC;EAAU;EAAc;EAAW;CAAS,CAC/C;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-preact",
3
- "version": "0.11.1",
3
+ "version": "0.12.1",
4
4
  "description": "Preact hooks for TanStack AI streaming chat and typed messages.",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -46,7 +46,7 @@
46
46
  "tool-calling"
47
47
  ],
48
48
  "dependencies": {
49
- "@tanstack/ai-client": "0.22.1"
49
+ "@tanstack/ai-client": "0.23.1"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@mcp-ui/client": "^7.1.1",
@@ -54,13 +54,13 @@
54
54
  "@vitest/coverage-v8": "4.0.14",
55
55
  "jsdom": "^27.2.0",
56
56
  "preact": "^10.26.9",
57
- "vite": "^7.3.3",
58
- "@tanstack/ai": "0.42.0"
57
+ "vite": "^8.1.4",
58
+ "@tanstack/ai": "0.43.1"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@mcp-ui/client": "^7",
62
62
  "preact": ">=10.11.0",
63
- "@tanstack/ai": "^0.42.0"
63
+ "@tanstack/ai": "^0.43.1"
64
64
  },
65
65
  "peerDependenciesMeta": {
66
66
  "@mcp-ui/client": {
@@ -69,8 +69,8 @@
69
69
  },
70
70
  "scripts": {
71
71
  "clean": "premove ./build ./dist",
72
- "lint:fix": "eslint ./src --fix",
73
- "test:eslint": "eslint ./src",
72
+ "lint:fix": "oxlint src --type-aware --fix",
73
+ "test:oxlint": "oxlint src --type-aware",
74
74
  "test:lib": "vitest run",
75
75
  "test:lib:dev": "pnpm test:lib --watch",
76
76
  "test:types": "tsc",
package/src/index.ts CHANGED
@@ -16,6 +16,16 @@ export type {
16
16
 
17
17
  export {
18
18
  fetchServerSentEvents,
19
+ localStoragePersistence,
20
+ sessionStoragePersistence,
21
+ indexedDBPersistence,
22
+ StorageUnavailableError,
23
+ type ChatClientPersistence,
24
+ type ChatPersistedState,
25
+ type ChatPersistenceOption,
26
+ type ChatStorageAdapter,
27
+ type WebStoragePersistenceOptions,
28
+ type IndexedDBPersistenceOptions,
19
29
  fetchHttpStream,
20
30
  xhrServerSentEvents,
21
31
  xhrHttpStream,
package/src/types.ts CHANGED
@@ -1,9 +1,18 @@
1
- import type { AnyClientTool, ModelMessage, SchemaInput } from '@tanstack/ai'
1
+ import type {
2
+ AnyClientTool,
3
+ ModelMessage,
4
+ RunAgentResumeItem,
5
+ SchemaInput,
6
+ } from '@tanstack/ai'
2
7
  import type {
3
8
  AIDevtoolsDisplayOptions,
9
+ BoundInterrupts,
4
10
  ChatClientOptions,
5
11
  ChatClientState,
12
+ ChatInterrupt,
13
+ ChatInterruptState,
6
14
  ChatRequestBody,
15
+ ChatResumeState,
7
16
  ClientContextOptionFromTools,
8
17
  ConnectionStatus,
9
18
  DistributedOmit,
@@ -60,8 +69,14 @@ export type UseChatOptions<
60
69
  | 'onConnectionStatusChange'
61
70
  | 'onSessionGeneratingChange'
62
71
  | 'onQueueChange'
72
+ | 'onResumeStateChange'
73
+ | 'onRunIdChange'
63
74
  | 'context'
64
75
  | 'devtools'
76
+ // `id` is not a hook option: the hook's identity is its `threadId`, which is
77
+ // also the persistence key. Persist across reloads by passing a stable
78
+ // `threadId`; there is no separate id to set.
79
+ | 'id'
65
80
  > & {
66
81
  live?: boolean
67
82
  /** Display options for TanStack AI Devtools. */
@@ -126,6 +141,39 @@ export interface UseChatReturn<
126
141
  approved: boolean
127
142
  }) => Promise<void>
128
143
 
144
+ /**
145
+ * The id of the run this client has in flight — one it started or rejoined —
146
+ * or `null` when there is none (including while a run sits paused on an
147
+ * interrupt, waiting on approval).
148
+ *
149
+ * A run is one turn of the conversation, so this changes from turn to turn. A
150
+ * whole tool loop stays inside one run, while resuming after an interrupt
151
+ * continues the turn under a new id — so one user message can produce several
152
+ * run ids. Use it to talk to your own server about that run (cancel it, poll
153
+ * it, correlate a log line).
154
+ */
155
+ runId: string | null
156
+ interrupts: BoundInterrupts<TTools>
157
+ /** @deprecated Use `interrupts`. */
158
+ pendingInterrupts: BoundInterrupts<TTools>
159
+ interruptErrors: ChatInterruptState<TTools>['interruptErrors']
160
+ resuming: boolean
161
+ resolveInterrupts: {
162
+ (approved: boolean): void
163
+ (resolver: (interrupt: ChatInterrupt<TTools>) => undefined): void
164
+ }
165
+ cancelInterrupts: () => void
166
+ retryInterrupts: () => void
167
+ resumeInterruptsUnsafe: (
168
+ resume: Array<RunAgentResumeItem>,
169
+ state?: ChatResumeState,
170
+ ) => Promise<boolean>
171
+ /** @deprecated Use bound interrupt methods or `resumeInterruptsUnsafe`. */
172
+ resumeInterrupts: (
173
+ resume: Array<RunAgentResumeItem>,
174
+ state?: ChatResumeState,
175
+ ) => Promise<boolean>
176
+
129
177
  /**
130
178
  * Reload the last assistant message
131
179
  */
package/src/use-chat.ts CHANGED
@@ -10,12 +10,19 @@ import {
10
10
  } from 'preact/hooks'
11
11
  import type {
12
12
  ChatClientState,
13
+ ChatInterrupt,
14
+ ChatInterruptState,
15
+ ChatResumeState,
13
16
  ConnectionStatus,
14
17
  InferredClientContext,
15
18
  QueuedMessage,
16
19
  SendMessageOptions,
17
20
  } from '@tanstack/ai-client'
18
- import type { AnyClientTool, ModelMessage } from '@tanstack/ai'
21
+ import type {
22
+ AnyClientTool,
23
+ ModelMessage,
24
+ RunAgentResumeItem,
25
+ } from '@tanstack/ai'
19
26
 
20
27
  import type {
21
28
  MultimodalContent,
@@ -24,12 +31,19 @@ import type {
24
31
  UseChatReturn,
25
32
  } from './types'
26
33
 
34
+ const EMPTY_INTERRUPTS = Object.freeze([])
35
+ const EMPTY_INTERRUPT_ERRORS = Object.freeze([])
36
+
27
37
  export function useChat<
28
38
  const TTools extends ReadonlyArray<AnyClientTool> = any,
29
39
  TContext = InferredClientContext<TTools>,
30
40
  >(options: UseChatOptions<TTools, TContext>): UseChatReturn<TTools> {
41
+ // The hook's identity is its `threadId` — also the persistence key, so a
42
+ // reload with the same `threadId` restores the same conversation. `hookId` is
43
+ // only a stable fallback for client-recreation keying when no `threadId` is
44
+ // given (an ephemeral chat), never a persistence key.
31
45
  const hookId = useId()
32
- const clientId = options.id || hookId
46
+ const clientId = options.threadId ?? hookId
33
47
 
34
48
  const [messages, setMessages] = useState<Array<UIMessage<TTools>>>(
35
49
  options.initialMessages || [],
@@ -42,6 +56,15 @@ export function useChat<
42
56
  useState<ConnectionStatus>('disconnected')
43
57
  const [sessionGenerating, setSessionGenerating] = useState(false)
44
58
  const [queue, setQueue] = useState<Array<QueuedMessage>>([])
59
+ const [runId, setRunId] = useState<string | null>(null)
60
+ const [interruptState, setInterruptState] = useState<
61
+ ChatInterruptState<TTools>
62
+ >(() => ({
63
+ interrupts: EMPTY_INTERRUPTS,
64
+ pendingInterrupts: EMPTY_INTERRUPTS,
65
+ interruptErrors: EMPTY_INTERRUPT_ERRORS,
66
+ resuming: false,
67
+ }))
45
68
 
46
69
  // Track current messages in a ref to preserve them when client is recreated
47
70
  const messagesRef = useRef<Array<UIMessage<TTools>>>(
@@ -52,10 +75,20 @@ export function useChat<
52
75
  const cleanupInvalidationRef = useRef<ReturnType<typeof setTimeout> | null>(
53
76
  null,
54
77
  )
78
+ const cleanupDisposalRef = useRef<{
79
+ client: ChatClient
80
+ timeout: ReturnType<typeof setTimeout>
81
+ } | null>(null)
55
82
  const optionsRef = useRef<UseChatOptions<TTools, TContext>>(options)
56
83
 
57
84
  optionsRef.current = options
58
85
 
86
+ const syncResumeState = useCallback((target: ChatClient | null) => {
87
+ if (!target) return
88
+ setRunId(target.getCurrentRunId())
89
+ setInterruptState(target.getInterruptState())
90
+ }, [])
91
+
59
92
  useEffect(() => {
60
93
  messagesRef.current = messages
61
94
  }, [messages])
@@ -73,10 +106,20 @@ export function useChat<
73
106
  ? { connection: initialOptions.connection }
74
107
  : { fetcher: initialOptions.fetcher }
75
108
 
109
+ const instanceHolder: {
110
+ current: ChatClient<TTools, TContext> | undefined
111
+ } = { current: undefined }
112
+ const getActiveInstance = () => {
113
+ const currentInstance = instanceHolder.current
114
+ if (!currentInstance || activeClientRef.current !== currentInstance) {
115
+ return undefined
116
+ }
117
+ return currentInstance
118
+ }
119
+ const pendingInitializationErrors: Array<Error> = []
76
120
  const instance = new ChatClient<TTools, TContext>({
77
121
  devtoolsBridgeFactory: createChatDevtoolsBridge,
78
122
  ...transport,
79
- id: clientId,
80
123
  initialMessages: messagesToUse,
81
124
  ...(initialOptions.body !== undefined && { body: initialOptions.body }),
82
125
  ...(initialOptions.threadId !== undefined && {
@@ -88,6 +131,9 @@ export function useChat<
88
131
  ...(initialOptions.persistence !== undefined && {
89
132
  persistence: initialOptions.persistence,
90
133
  }),
134
+ ...(initialOptions.initialResumeSnapshot !== undefined && {
135
+ initialResumeSnapshot: initialOptions.initialResumeSnapshot,
136
+ }),
91
137
  ...(initialOptions.context !== undefined && {
92
138
  context: initialOptions.context,
93
139
  }),
@@ -101,23 +147,28 @@ export function useChat<
101
147
  // Capturing the function reference directly would freeze it to whatever
102
148
  // the parent passed on the first render.
103
149
  onResponse: (response) => {
104
- if (activeClientRef.current !== instance) return
150
+ if (!getActiveInstance()) return
105
151
  return optionsRef.current.onResponse?.(response)
106
152
  },
107
153
  onChunk: (chunk) => {
108
- if (activeClientRef.current !== instance) return
154
+ if (!getActiveInstance()) return
109
155
  optionsRef.current.onChunk?.(chunk)
110
156
  },
111
157
  onFinish: (message) => {
112
- if (activeClientRef.current !== instance) return
158
+ if (!getActiveInstance()) return
113
159
  optionsRef.current.onFinish?.(message)
114
160
  },
115
161
  onError: (err) => {
116
- if (activeClientRef.current !== instance) return
162
+ const currentInstance = instanceHolder.current
163
+ if (!currentInstance) {
164
+ pendingInitializationErrors.push(err)
165
+ return
166
+ }
167
+ if (activeClientRef.current !== currentInstance) return
117
168
  optionsRef.current.onError?.(err)
118
169
  },
119
170
  onCustomEvent: (eventType, data, context) => {
120
- if (activeClientRef.current !== instance) return
171
+ if (!getActiveInstance()) return
121
172
  optionsRef.current.onCustomEvent?.(eventType, data, context)
122
173
  },
123
174
  ...(initialOptions.tools !== undefined && {
@@ -127,44 +178,68 @@ export function useChat<
127
178
  streamProcessor: options.streamProcessor,
128
179
  }),
129
180
  onMessagesChange: (newMessages: Array<UIMessage<TTools>>) => {
130
- if (activeClientRef.current !== instance) return
181
+ if (!getActiveInstance()) return
131
182
  setMessages(newMessages)
132
183
  },
133
184
  onLoadingChange: (newIsLoading: boolean) => {
134
- if (activeClientRef.current !== instance) return
185
+ const currentInstance = getActiveInstance()
186
+ if (!currentInstance) return
135
187
  setIsLoading(newIsLoading)
188
+ syncResumeState(currentInstance)
136
189
  },
137
190
  onStatusChange: (newStatus: ChatClientState) => {
138
- if (activeClientRef.current !== instance) return
191
+ if (!getActiveInstance()) return
139
192
  setStatus(newStatus)
140
193
  },
141
194
  onErrorChange: (newError: Error | undefined) => {
142
- if (activeClientRef.current !== instance) return
195
+ if (!getActiveInstance()) return
143
196
  setError(newError)
144
197
  },
145
198
  onSubscriptionChange: (nextIsSubscribed: boolean) => {
146
- if (activeClientRef.current !== instance) return
199
+ if (!getActiveInstance()) return
147
200
  setIsSubscribed(nextIsSubscribed)
148
201
  },
149
202
  onConnectionStatusChange: (nextStatus: ConnectionStatus) => {
150
- if (activeClientRef.current !== instance) return
203
+ if (!getActiveInstance()) return
151
204
  setConnectionStatus(nextStatus)
152
205
  },
153
206
  onSessionGeneratingChange: (isGenerating: boolean) => {
154
- if (activeClientRef.current !== instance) return
207
+ if (!getActiveInstance()) return
155
208
  setSessionGenerating(isGenerating)
156
209
  },
157
210
  ...(optionsRef.current.queue !== undefined && {
158
211
  queue: optionsRef.current.queue,
159
212
  }),
160
213
  onQueueChange: (nextQueue: Array<QueuedMessage>) => {
161
- if (activeClientRef.current !== instance) return
214
+ if (!getActiveInstance()) return
162
215
  setQueue(nextQueue)
163
216
  },
217
+ onRunIdChange: (nextRunId) => {
218
+ if (!getActiveInstance()) return
219
+ setRunId(nextRunId)
220
+ },
221
+ onResumeStateChange: (_nextResumeState, nextPendingInterrupts) => {
222
+ if (!getActiveInstance()) return
223
+ setInterruptState((current) => ({
224
+ ...current,
225
+ interrupts: nextPendingInterrupts,
226
+ pendingInterrupts: nextPendingInterrupts,
227
+ }))
228
+ },
229
+ onInterruptStateChange: (nextInterruptState) => {
230
+ if (!getActiveInstance()) return
231
+ setInterruptState(nextInterruptState)
232
+ optionsRef.current.onInterruptStateChange?.(nextInterruptState)
233
+ },
164
234
  })
235
+ instanceHolder.current = instance
165
236
  activeClientRef.current = instance
237
+ for (const initializationError of pendingInitializationErrors) {
238
+ if (activeClientRef.current !== instance) break
239
+ optionsRef.current.onError?.(initializationError)
240
+ }
166
241
  return instance
167
- }, [clientId])
242
+ }, [clientId, syncResumeState])
168
243
 
169
244
  useEffect(() => {
170
245
  const clientMessages = client.getMessages()
@@ -203,17 +278,41 @@ export function useChat<
203
278
  }
204
279
  }, [client, options.live])
205
280
 
281
+ // ONLY THE VIEW ON SCREEN HOLDS A STREAM. See the same effect in
282
+ // `@tanstack/ai-react`. A page can own many chats and a browser allows only ~6
283
+ // connections per origin, so one long-lived stream per chat starves everything
284
+ // else once a handful of views have been open. `attach` is idempotent and
285
+ // `detach` keeps the transcript and the resume pointer, so a remount picks the
286
+ // run straight back up from the durable log.
287
+ //
288
+ // Immediate, unlike the deferred disposal below, which a remount can skip —
289
+ // correct for disposal, useless for a connection.
290
+ useEffect(() => {
291
+ client.attach()
292
+ return () => {
293
+ client.detach()
294
+ }
295
+ }, [client])
296
+
206
297
  // Cleanup on unmount: stop any in-flight requests
207
298
  // Note: We only cleanup when client changes or component unmounts.
208
299
  // DO NOT include isLoading in dependencies - that would cause the cleanup
209
300
  // to run when isLoading changes, aborting continuation requests.
210
301
  useEffect(() => {
302
+ if (cleanupDisposalRef.current?.client === client) {
303
+ clearTimeout(cleanupDisposalRef.current.timeout)
304
+ cleanupDisposalRef.current = null
305
+ }
211
306
  if (cleanupInvalidationRef.current) {
212
307
  clearTimeout(cleanupInvalidationRef.current)
213
308
  cleanupInvalidationRef.current = null
214
309
  }
215
310
  activeClientRef.current = client
216
311
  client.mountDevtools()
312
+ // Delivery-durability resume is transparent: the resumable SSE connection
313
+ // adapter reattaches via the browser's native Last-Event-ID on reconnect.
314
+ // We only seed interrupt (state) resume from the client here.
315
+ syncResumeState(client)
217
316
 
218
317
  return () => {
219
318
  cleanupInvalidationRef.current = setTimeout(() => {
@@ -231,9 +330,18 @@ export function useChat<
231
330
  } else {
232
331
  client.stop()
233
332
  }
234
- client.dispose()
333
+ const disposal = {
334
+ client,
335
+ timeout: setTimeout(() => {
336
+ client.dispose()
337
+ if (cleanupDisposalRef.current === disposal) {
338
+ cleanupDisposalRef.current = null
339
+ }
340
+ }, 0),
341
+ }
342
+ cleanupDisposalRef.current = disposal
235
343
  }
236
- }, [client])
344
+ }, [client, syncResumeState])
237
345
 
238
346
  // All callback options are read through optionsRef at call time, so fresh
239
347
  // closures from each render are picked up without recreating the client.
@@ -242,28 +350,40 @@ export function useChat<
242
350
  content: string | MultimodalContent,
243
351
  sendOptions?: SendMessageOptions,
244
352
  ) => {
245
- await client.sendMessage(content, undefined, sendOptions)
353
+ try {
354
+ await client.sendMessage(content, undefined, sendOptions)
355
+ } finally {
356
+ syncResumeState(client)
357
+ }
246
358
  },
247
- [client],
359
+ [client, syncResumeState],
248
360
  )
249
361
 
250
362
  const cancelQueued = useCallback(
251
363
  (id: string) => {
252
364
  client.cancelQueued(id)
253
365
  },
254
- [client],
366
+ [client, syncResumeState],
255
367
  )
256
368
 
257
369
  const append = useCallback(
258
370
  async (message: ModelMessage | UIMessage) => {
259
- await client.append(message)
371
+ try {
372
+ await client.append(message)
373
+ } finally {
374
+ syncResumeState(client)
375
+ }
260
376
  },
261
- [client],
377
+ [client, syncResumeState],
262
378
  )
263
379
 
264
380
  const reload = useCallback(async () => {
265
- await client.reload()
266
- }, [client])
381
+ try {
382
+ await client.reload()
383
+ } finally {
384
+ syncResumeState(client)
385
+ }
386
+ }, [client, syncResumeState])
267
387
 
268
388
  const stop = useCallback(() => {
269
389
  client.stop()
@@ -271,7 +391,8 @@ export function useChat<
271
391
 
272
392
  const clear = useCallback(() => {
273
393
  client.clear()
274
- }, [client])
394
+ syncResumeState(client)
395
+ }, [client, syncResumeState])
275
396
 
276
397
  const setMessagesManually = useCallback(
277
398
  (newMessages: Array<UIMessage<TTools>>) => {
@@ -296,7 +417,44 @@ export function useChat<
296
417
  const addToolApprovalResponse = useCallback(
297
418
  async (response: { id: string; approved: boolean }) => {
298
419
  await client.addToolApprovalResponse(response)
420
+ syncResumeState(client)
299
421
  },
422
+ [client, syncResumeState],
423
+ )
424
+
425
+ const resumeInterrupts = useCallback(
426
+ async (resumeItems: Array<RunAgentResumeItem>, state?: ChatResumeState) => {
427
+ const result = await client.resumeInterrupts(resumeItems, state)
428
+ syncResumeState(client)
429
+ return result
430
+ },
431
+ [client, syncResumeState],
432
+ )
433
+
434
+ const resolveInterrupts = useCallback(
435
+ (
436
+ resolution: boolean | ((interrupt: ChatInterrupt<TTools>) => undefined),
437
+ ) => {
438
+ if (typeof resolution === 'boolean') {
439
+ client.resolveInterrupts(resolution)
440
+ } else {
441
+ client.resolveInterrupts(resolution)
442
+ }
443
+ },
444
+ [client],
445
+ )
446
+
447
+ const cancelInterrupts = useCallback(() => {
448
+ client.cancelInterrupts()
449
+ }, [client])
450
+
451
+ const retryInterrupts = useCallback(() => {
452
+ client.retryInterrupts()
453
+ }, [client])
454
+
455
+ const resumeInterruptsUnsafe = useCallback(
456
+ (resumeItems: Array<RunAgentResumeItem>, state?: ChatResumeState) =>
457
+ client.resumeInterruptsUnsafe(resumeItems, state),
300
458
  [client],
301
459
  )
302
460
 
@@ -320,5 +478,15 @@ export function useChat<
320
478
  addToolApprovalResponse,
321
479
  queue,
322
480
  cancelQueued,
481
+ runId,
482
+ interrupts: interruptState.interrupts,
483
+ pendingInterrupts: interruptState.pendingInterrupts,
484
+ interruptErrors: interruptState.interruptErrors,
485
+ resuming: interruptState.resuming,
486
+ resolveInterrupts,
487
+ cancelInterrupts,
488
+ retryInterrupts,
489
+ resumeInterruptsUnsafe,
490
+ resumeInterrupts,
323
491
  }
324
492
  }
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;"}