@tanstack/ai-client 0.21.0 → 0.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-client",
3
- "version": "0.21.0",
3
+ "version": "0.22.1",
4
4
  "description": "Framework-agnostic headless client for TanStack AI chat, realtime sessions, streaming transports, and media generations.",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -49,7 +49,7 @@
49
49
  "src"
50
50
  ],
51
51
  "dependencies": {
52
- "@tanstack/ai": "0.41.0",
52
+ "@tanstack/ai": "0.42.0",
53
53
  "@tanstack/ai-event-client": "0.6.8",
54
54
  "@tanstack/ai-utils": "0.3.1"
55
55
  },
@@ -39,10 +39,21 @@ import type {
39
39
  ConnectionStatus,
40
40
  MessagePart,
41
41
  MultimodalContent,
42
+ QueueBusyReason,
43
+ QueueOption,
44
+ QueueStrategy,
45
+ QueuedMessage,
46
+ SendMessageOptions,
42
47
  ToolCallPart,
43
48
  UIMessage,
49
+ WhenBusy,
44
50
  } from './types'
45
51
 
52
+ /** Internal queue entry — public {@link QueuedMessage} plus optional per-send body. */
53
+ interface InternalQueuedMessage extends QueuedMessage {
54
+ body?: Record<string, any>
55
+ }
56
+
46
57
  type ChatClientUpdateOptionsWithoutContext<
47
58
  TTools extends ReadonlyArray<AnyClientTool>,
48
59
  > = {
@@ -52,6 +63,7 @@ type ChatClientUpdateOptionsWithoutContext<
52
63
  body?: Record<string, any>
53
64
  forwardedProps?: Record<string, any>
54
65
  tools?: TTools
66
+ queue?: QueueOption
55
67
  onResponse?: (response?: Response) => void | Promise<void>
56
68
  onChunk?: (chunk: StreamChunk) => void
57
69
  onFinish?: (message: UIMessage) => void
@@ -59,6 +71,7 @@ type ChatClientUpdateOptionsWithoutContext<
59
71
  onSubscriptionChange?: (isSubscribed: boolean) => void
60
72
  onConnectionStatusChange?: (status: ConnectionStatus) => void
61
73
  onSessionGeneratingChange?: (isGenerating: boolean) => void
74
+ onQueueChange?: (queue: Array<QueuedMessage>) => void
62
75
  onCustomEvent?: (
63
76
  eventType: string,
64
77
  data: unknown,
@@ -89,6 +102,82 @@ function resolveTransport(transport: {
89
102
  throw new Error('ChatClient: either `connection` or `fetcher` is required.')
90
103
  }
91
104
 
105
+ export interface NormalizedQueueConfig {
106
+ whenBusy: WhenBusy
107
+ drain: 'fifo' | 'batch'
108
+ onOverflow: 'reject' | 'drop-oldest'
109
+ maxSize?: number
110
+ strategy?: QueueStrategy
111
+ }
112
+
113
+ export function normalizeQueueOption(
114
+ option: QueueOption | undefined,
115
+ ): NormalizedQueueConfig {
116
+ const base: NormalizedQueueConfig = {
117
+ whenBusy: 'queue',
118
+ drain: 'fifo',
119
+ onOverflow: 'reject',
120
+ }
121
+ if (!option) return base
122
+ if (typeof option === 'string') return { ...base, whenBusy: option }
123
+ if (typeof option === 'function') return { ...base, strategy: option }
124
+
125
+ const maxSize = option.maxSize
126
+ if (maxSize !== undefined) {
127
+ if (!Number.isInteger(maxSize) || maxSize < 0) {
128
+ throw new Error(
129
+ 'ChatClient: queue.maxSize must be a non-negative integer',
130
+ )
131
+ }
132
+ }
133
+
134
+ return {
135
+ whenBusy: option.whenBusy ?? 'queue',
136
+ drain: option.drain ?? 'fifo',
137
+ onOverflow: option.onOverflow ?? 'reject',
138
+ ...(maxSize !== undefined ? { maxSize } : {}),
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Merge a run of queued messages into a single send for `drain: 'batch'`.
144
+ * All-string content is joined with newlines; mixed/multimodal content is
145
+ * flattened into a single `ContentPart` array. The last item's `body` wins.
146
+ */
147
+ function mergeQueuedMessages(items: Array<InternalQueuedMessage>): {
148
+ content: string | MultimodalContent
149
+ body?: Record<string, any>
150
+ } {
151
+ const body = items.at(-1)?.body
152
+ const stringContents: Array<string> = []
153
+ for (const item of items) {
154
+ if (typeof item.content !== 'string') {
155
+ break
156
+ }
157
+ stringContents.push(item.content)
158
+ }
159
+ if (stringContents.length === items.length) {
160
+ return {
161
+ content: stringContents.join('\n'),
162
+ ...(body !== undefined ? { body } : {}),
163
+ }
164
+ }
165
+ const parts: Array<ContentPart> = []
166
+ for (const item of items) {
167
+ if (typeof item.content === 'string') {
168
+ parts.push({ type: 'text', content: item.content })
169
+ } else if (typeof item.content.content === 'string') {
170
+ parts.push({ type: 'text', content: item.content.content })
171
+ } else {
172
+ parts.push(...item.content.content)
173
+ }
174
+ }
175
+ return {
176
+ content: { content: parts },
177
+ ...(body !== undefined ? { body } : {}),
178
+ }
179
+ }
180
+
92
181
  export class ChatClient<
93
182
  TTools extends ReadonlyArray<AnyClientTool> = any,
94
183
  TContext = unknown,
@@ -110,6 +199,31 @@ export class ChatClient<
110
199
  private forwardedPropsOption: Record<string, any> = {}
111
200
  private context: TContext | undefined = undefined
112
201
  private pendingMessageBody: Record<string, any> | undefined = undefined
202
+ private queueConfig: NormalizedQueueConfig
203
+ private messageQueue: Array<InternalQueuedMessage> = []
204
+ /**
205
+ * True from the moment `sendMessage` claims the client until its
206
+ * `streamResponse` settles. Closes the race where concurrent callers both
207
+ * see `isLoading === false`, both append a user message, and only one stream
208
+ * actually runs (leaving stranded user messages with no reply).
209
+ */
210
+ private sendInFlight = false
211
+ /**
212
+ * True while `drainQueue` is delivering queued messages. Concurrent
213
+ * `sendMessage` calls during a drain are treated as busy and follow
214
+ * `whenBusy` (default: queue).
215
+ */
216
+ private messageQueueDraining = false
217
+ /**
218
+ * Set by `whenBusy: 'interrupt'` so an in-progress FIFO drain loop stops
219
+ * before starting the next queued item (the interrupting send owns the client).
220
+ */
221
+ private stopMessageQueueDrain = false
222
+ /**
223
+ * Sync claim held for the duration of `deliverMessage` so concurrent
224
+ * deliverers cannot both append a user message before only one stream runs.
225
+ */
226
+ private deliverClaim = false
113
227
  private isLoading = false
114
228
  private isSubscribed = false
115
229
  private error: Error | undefined = undefined
@@ -159,6 +273,7 @@ export class ChatClient<
159
273
  onSubscriptionChange: (isSubscribed: boolean) => void
160
274
  onConnectionStatusChange: (status: ConnectionStatus) => void
161
275
  onSessionGeneratingChange: (isGenerating: boolean) => void
276
+ onQueueChange: (queue: Array<QueuedMessage>) => void
162
277
  onCustomEvent: (
163
278
  eventType: string,
164
279
  data: unknown,
@@ -185,6 +300,7 @@ export class ChatClient<
185
300
  this.bodyOption = options.body || {}
186
301
  this.forwardedPropsOption = options.forwardedProps || {}
187
302
  this.context = options.context
303
+ this.queueConfig = normalizeQueueOption(options.queue)
188
304
  this.connection = normalizeConnectionAdapter(resolveTransport(options))
189
305
 
190
306
  // Build client tools map
@@ -215,6 +331,7 @@ export class ChatClient<
215
331
  options.onConnectionStatusChange || (() => {}),
216
332
  onSessionGeneratingChange:
217
333
  options.onSessionGeneratingChange || (() => {}),
334
+ onQueueChange: options.onQueueChange || (() => {}),
218
335
  onCustomEvent: options.onCustomEvent || (() => {}),
219
336
  },
220
337
  }
@@ -571,6 +688,7 @@ export class ChatClient<
571
688
  connectionStatus: this.connectionStatus,
572
689
  sessionGenerating: this.sessionGenerating,
573
690
  activeRunIds: Array.from(this.activeRunIds),
691
+ queue: this.getQueue(),
574
692
  ...(this.error ? { error: this.error.message } : {}),
575
693
  }
576
694
  }
@@ -608,6 +726,9 @@ export class ChatClient<
608
726
  }
609
727
  this.resolveProcessing()
610
728
  this.setIsLoading(false)
729
+ // Release deliver claim so an interrupting `deliverMessage` can append
730
+ // after abort (the superseded deliver's finally also clears the claim).
731
+ this.deliverClaim = false
611
732
  if (options?.setReadyStatus) {
612
733
  this.setStatus('ready')
613
734
  }
@@ -738,6 +859,8 @@ export class ChatClient<
738
859
  * - A MultimodalContent object with content array and optional custom ID
739
860
  * @param body - Optional body parameters to merge with the client's base body for this request.
740
861
  * Uses shallow merge with per-message body taking priority.
862
+ * @param sendOptions - Per-call overrides, e.g. `{ whenBusy: 'interrupt' }` to
863
+ * override the configured queue policy for this one send.
741
864
  *
742
865
  * @example
743
866
  * ```ts
@@ -747,6 +870,9 @@ export class ChatClient<
747
870
  * // Text message with custom body params
748
871
  * await client.sendMessage('Hello!', { temperature: 0.7 })
749
872
  *
873
+ * // Per-call whenBusy override (body must still be the 2nd arg on ChatClient)
874
+ * await client.sendMessage('Urgent', undefined, { whenBusy: 'interrupt' })
875
+ *
750
876
  * // Multimodal message with image
751
877
  * await client.sendMessage({
752
878
  * content: [
@@ -771,26 +897,131 @@ export class ChatClient<
771
897
  async sendMessage(
772
898
  content: string | MultimodalContent,
773
899
  body?: Record<string, any>,
900
+ sendOptions?: SendMessageOptions,
774
901
  ): Promise<void> {
775
902
  this.mountDevtools()
776
903
  const emptyMessage = typeof content === 'string' && !content.trim()
777
- if (emptyMessage || this.isLoading) {
904
+ if (emptyMessage) {
778
905
  return
779
906
  }
780
- // Normalize input to extract content, id, and validate
781
- const normalizedContent = this.normalizeMessageInput(content)
782
907
 
783
- // Store the per-message body for use in streamResponse
784
- this.pendingMessageBody = body
908
+ if (this.isSendBusy()) {
909
+ const { action, id } = this.decideWhenBusy(content, sendOptions)
910
+ if (action === 'drop') {
911
+ return
912
+ }
913
+ if (action === 'queue') {
914
+ this.enqueueMessage(content, body, id)
915
+ return
916
+ }
917
+ // 'interrupt': abort the current stream, then send now.
918
+ // Unlike stop(), does not flush already-queued messages — they drain
919
+ // after this interrupting send settles successfully.
920
+ // Claim sendInFlight *before* cancelling so a concurrent send cannot
921
+ // slip in between cancel and the deliver below.
922
+ this.stopMessageQueueDrain = true
923
+ this.sendInFlight = true
924
+ this.cancelInFlightStream({ setReadyStatus: true })
925
+ this.resetSessionGenerating()
926
+ } else {
927
+ this.sendInFlight = true
928
+ }
785
929
 
786
- // Add user message via processor
787
- const userMessage = this.processor.addUserMessage(
788
- normalizedContent.content,
789
- normalizedContent.id,
790
- )
791
- this.events.messageSent(userMessage.id, normalizedContent.content)
930
+ try {
931
+ await this.deliverMessage(content, body)
932
+ } finally {
933
+ this.sendInFlight = false
934
+ }
935
+ }
792
936
 
793
- await this.streamResponse()
937
+ /** True while a stream is active, a send is claiming the client, or the queue is draining. */
938
+ private isSendBusy(): boolean {
939
+ return this.isLoading || this.sendInFlight || this.messageQueueDraining
940
+ }
941
+
942
+ private resolveBusyReason(): QueueBusyReason {
943
+ if (this.isLoading) return 'streaming'
944
+ if (this.messageQueueDraining) return 'draining'
945
+ return 'sendInFlight'
946
+ }
947
+
948
+ /**
949
+ * Append a user message and run the stream. Used by both direct sends and
950
+ * queue drains — callers are responsible for busy/queue policy.
951
+ *
952
+ * Claims delivery synchronously before appending so concurrent callers
953
+ * cannot both add a user message when only one stream can run.
954
+ */
955
+ private async deliverMessage(
956
+ content: string | MultimodalContent,
957
+ body?: Record<string, any>,
958
+ ): Promise<boolean> {
959
+ if (this.isLoading || this.deliverClaim) {
960
+ return false
961
+ }
962
+ this.deliverClaim = true
963
+ try {
964
+ const normalizedContent = this.normalizeMessageInput(content)
965
+ this.pendingMessageBody = body
966
+ const userMessage = this.processor.addUserMessage(
967
+ normalizedContent.content,
968
+ normalizedContent.id,
969
+ )
970
+ this.events.messageSent(userMessage.id, normalizedContent.content)
971
+ return await this.streamResponse()
972
+ } finally {
973
+ this.deliverClaim = false
974
+ }
975
+ }
976
+
977
+ /**
978
+ * Resolve the effective action for a send that arrives while busy.
979
+ * The returned `id` is the id that will be stored if the action is `queue`.
980
+ */
981
+ private decideWhenBusy(
982
+ content: string | MultimodalContent,
983
+ sendOptions?: SendMessageOptions,
984
+ ): { action: WhenBusy; id: string } {
985
+ const id = this.generateUniqueId('queued')
986
+ if (sendOptions?.whenBusy) {
987
+ return { action: sendOptions.whenBusy, id }
988
+ }
989
+ const { strategy, whenBusy } = this.queueConfig
990
+ if (strategy) {
991
+ const { action } = strategy({
992
+ pending: {
993
+ id,
994
+ content,
995
+ createdAt: Date.now(),
996
+ },
997
+ busyReason: this.resolveBusyReason(),
998
+ queued: this.getQueue(),
999
+ })
1000
+ return { action, id }
1001
+ }
1002
+ return { action: whenBusy, id }
1003
+ }
1004
+
1005
+ private enqueueMessage(
1006
+ content: string | MultimodalContent,
1007
+ body?: Record<string, any>,
1008
+ id?: string,
1009
+ ): void {
1010
+ const { maxSize, onOverflow } = this.queueConfig
1011
+ if (maxSize !== undefined && this.messageQueue.length >= maxSize) {
1012
+ // maxSize 0 is a hard cap (never queue). drop-oldest cannot make room.
1013
+ if (onOverflow === 'reject' || maxSize === 0) {
1014
+ return
1015
+ }
1016
+ this.messageQueue.shift() // drop-oldest
1017
+ }
1018
+ this.messageQueue.push({
1019
+ id: id ?? this.generateUniqueId('queued'),
1020
+ content,
1021
+ createdAt: Date.now(),
1022
+ ...(body !== undefined ? { body } : {}),
1023
+ })
1024
+ this.emitQueueChange()
794
1025
  }
795
1026
 
796
1027
  /**
@@ -858,6 +1089,10 @@ export class ChatClient<
858
1089
  this.currentRunId = runId
859
1090
 
860
1091
  this.setIsLoading(true)
1092
+ // Hand off from deliverClaim to isLoading so nested drain can call
1093
+ // deliverMessage after this stream settles (while the outer deliver
1094
+ // is still on the stack).
1095
+ this.deliverClaim = false
861
1096
  this.setStatus('submitted')
862
1097
  this.setError(undefined)
863
1098
  this.errorReportedGeneration = null
@@ -1072,13 +1307,33 @@ export class ChatClient<
1072
1307
  await this.checkForContinuation()
1073
1308
  } catch (error) {
1074
1309
  console.error('Failed to continue flow after tool result:', error)
1310
+ // Continuation failed without starting a new stream — don't
1311
+ // leave queued user messages stranded forever. (isLoading is
1312
+ // already false in this finally block.)
1313
+ await this.drainQueue()
1314
+ }
1315
+ } else {
1316
+ if (this.status !== 'ready') {
1317
+ // Terminal run, but onStreamEnd never fired: the processor had
1318
+ // no assistant message to emit it for (e.g. a bare
1319
+ // RUN_FINISHED{stop}, #421). The normal path already set
1320
+ // 'ready', so this is a no-op.
1321
+ this.setStatus('ready')
1322
+ }
1323
+ // Auto-send queued messages once the run fully settles. When a
1324
+ // continuation runs instead (tool-result branch above), that
1325
+ // continuation's own finally drains the queue. Skip if a drain
1326
+ // loop is already walking the queue (avoids nested re-entry).
1327
+ if (!this.messageQueueDraining) {
1328
+ await this.drainQueue()
1075
1329
  }
1076
- } else if (this.status !== 'ready') {
1077
- // Terminal run, but onStreamEnd never fired: the processor had no
1078
- // assistant message to emit it for (e.g. a bare RUN_FINISHED{stop},
1079
- // #421). The normal path already set 'ready', so this is a no-op.
1080
- this.setStatus('ready')
1081
1330
  }
1331
+ } else {
1332
+ // Error/abort settle for the active generation: don't strand or
1333
+ // later mis-order queued messages. A failed turn flushes the queue
1334
+ // (consistent with stop()); it must NOT auto-drain into a likely
1335
+ // broken endpoint.
1336
+ this.flushQueue()
1082
1337
  }
1083
1338
  }
1084
1339
  }
@@ -1114,6 +1369,7 @@ export class ChatClient<
1114
1369
  setReadyStatus: true,
1115
1370
  abortSubscription: true,
1116
1371
  })
1372
+ this.discardPendingSends()
1117
1373
  this.resetSessionGenerating()
1118
1374
  this.setIsSubscribed(false)
1119
1375
  this.setConnectionStatus('disconnected')
@@ -1137,6 +1393,9 @@ export class ChatClient<
1137
1393
  if (this.isLoading) {
1138
1394
  this.cancelInFlightStream()
1139
1395
  }
1396
+ // Discard pending follow-ups so "regenerate last answer" does not also
1397
+ // auto-send messages that were typed during the previous stream.
1398
+ this.discardPendingSends()
1140
1399
 
1141
1400
  this.events.reloaded(lastUserMessageIndex)
1142
1401
 
@@ -1154,6 +1413,7 @@ export class ChatClient<
1154
1413
  stop(): void {
1155
1414
  const hadLocalStream = this.abortController !== null
1156
1415
  this.cancelInFlightStream({ setReadyStatus: true })
1416
+ this.discardPendingSends()
1157
1417
  if (hadLocalStream) {
1158
1418
  this.resetSessionGenerating()
1159
1419
  }
@@ -1181,6 +1441,7 @@ export class ChatClient<
1181
1441
  this.persistor.beginClear()
1182
1442
  }
1183
1443
  this.processor.clearMessages()
1444
+ this.discardPendingSends()
1184
1445
  this.persistor?.remove()
1185
1446
  this.setError(undefined)
1186
1447
  this.events.messagesCleared()
@@ -1376,6 +1637,129 @@ export class ChatClient<
1376
1637
  return this.processor.getMessages() as Array<UIMessage<TTools>>
1377
1638
  }
1378
1639
 
1640
+ /**
1641
+ * True when an interrupt (or another direct send) claimed the client during
1642
+ * a drain. Read via a method so cross-await mutations are not constant-folded
1643
+ * by control-flow analysis.
1644
+ */
1645
+ private shouldAbortMessageQueueDrain(): boolean {
1646
+ return this.isLoading || this.stopMessageQueueDrain
1647
+ }
1648
+
1649
+ /**
1650
+ * Deliver queued messages after a successful settle.
1651
+ * - `batch`: merge everything currently queued into one send, looping so
1652
+ * messages enqueued during that batch stream are not stranded.
1653
+ * - `fifo`: walk the queue in a loop, one stream at a time, until empty
1654
+ * (or until another send claims the client via interrupt).
1655
+ *
1656
+ * Uses `deliverMessage` directly so drains do not re-enter `sendMessage`'s
1657
+ * busy/queue policy (which would re-queue items and strand the rest).
1658
+ */
1659
+ private async drainQueue(): Promise<void> {
1660
+ // Note: do not gate on `sendInFlight`. Normal sends still hold
1661
+ // `sendInFlight` while `streamResponse`'s finally invokes drain; blocking
1662
+ // on it would permanently strand the queue.
1663
+ if (
1664
+ this.messageQueueDraining ||
1665
+ this.isLoading ||
1666
+ this.messageQueue.length === 0
1667
+ ) {
1668
+ return
1669
+ }
1670
+
1671
+ this.messageQueueDraining = true
1672
+ this.stopMessageQueueDrain = false
1673
+ try {
1674
+ if (this.queueConfig.drain === 'batch') {
1675
+ while (this.messageQueue.length > 0) {
1676
+ if (this.shouldAbortMessageQueueDrain()) {
1677
+ return
1678
+ }
1679
+ const items = this.messageQueue.splice(0)
1680
+ this.emitQueueChange()
1681
+ const merged = mergeQueuedMessages(items)
1682
+ const completed = await this.deliverMessage(
1683
+ merged.content,
1684
+ merged.body,
1685
+ )
1686
+ // Failed/aborted deliver flushes the rest of the queue in streamResponse.
1687
+ if (!completed || this.shouldAbortMessageQueueDrain()) {
1688
+ return
1689
+ }
1690
+ }
1691
+ return
1692
+ }
1693
+
1694
+ while (this.messageQueue.length > 0) {
1695
+ // Interrupt (or a new direct send) claimed the client — stop draining;
1696
+ // remaining items stay queued and will drain after that send settles.
1697
+ if (this.shouldAbortMessageQueueDrain()) {
1698
+ return
1699
+ }
1700
+ const next = this.messageQueue.shift()
1701
+ if (next === undefined) {
1702
+ return
1703
+ }
1704
+ this.emitQueueChange()
1705
+ const completed = await this.deliverMessage(next.content, next.body)
1706
+ // Failed/aborted deliver flushes the rest of the queue in streamResponse.
1707
+ if (!completed || this.shouldAbortMessageQueueDrain()) {
1708
+ return
1709
+ }
1710
+ }
1711
+ } finally {
1712
+ this.messageQueueDraining = false
1713
+ this.stopMessageQueueDrain = false
1714
+ }
1715
+ }
1716
+
1717
+ /**
1718
+ * Drop any in-flight send claim and discard pending queued messages
1719
+ * (stop / error / clear / unsubscribe / reload).
1720
+ */
1721
+ private discardPendingSends(): void {
1722
+ this.sendInFlight = false
1723
+ this.flushQueue()
1724
+ }
1725
+
1726
+ /**
1727
+ * Get the current send queue (messages held while a stream was in flight).
1728
+ */
1729
+ getQueue(): Array<QueuedMessage> {
1730
+ return this.messageQueue.map(({ id, content, createdAt }) => ({
1731
+ id,
1732
+ content,
1733
+ createdAt,
1734
+ }))
1735
+ }
1736
+
1737
+ private emitQueueChange(): void {
1738
+ this.callbacksRef.current.onQueueChange(this.getQueue())
1739
+ this.devtoolsBridge.emitSnapshot()
1740
+ }
1741
+
1742
+ /**
1743
+ * Remove a queued message by id before it drains.
1744
+ */
1745
+ cancelQueued(id: string): void {
1746
+ const index = this.messageQueue.findIndex((m) => m.id === id)
1747
+ if (index === -1) return
1748
+ this.messageQueue.splice(index, 1)
1749
+ this.emitQueueChange()
1750
+ }
1751
+
1752
+ /**
1753
+ * Discard all pending queued messages (stop / error / clear / unsubscribe /
1754
+ * reload). Does not send them. Emits `onQueueChange([])` when anything was
1755
+ * removed.
1756
+ */
1757
+ private flushQueue(): void {
1758
+ if (this.messageQueue.length === 0) return
1759
+ this.messageQueue = []
1760
+ this.emitQueueChange()
1761
+ }
1762
+
1379
1763
  /**
1380
1764
  * Get loading state
1381
1765
  */
@@ -1488,6 +1872,9 @@ export class ChatClient<
1488
1872
  }
1489
1873
  this.devtoolsBridge.notifyToolsChanged()
1490
1874
  }
1875
+ if (options.queue !== undefined) {
1876
+ this.queueConfig = normalizeQueueOption(options.queue)
1877
+ }
1491
1878
  if (options.onResponse !== undefined) {
1492
1879
  this.callbacksRef.current.onResponse = options.onResponse
1493
1880
  }
@@ -1512,6 +1899,9 @@ export class ChatClient<
1512
1899
  this.callbacksRef.current.onSessionGeneratingChange =
1513
1900
  options.onSessionGeneratingChange
1514
1901
  }
1902
+ if (options.onQueueChange !== undefined) {
1903
+ this.callbacksRef.current.onQueueChange = options.onQueueChange
1904
+ }
1515
1905
  if (options.onCustomEvent !== undefined) {
1516
1906
  this.callbacksRef.current.onCustomEvent = options.onCustomEvent
1517
1907
  }
package/src/devtools.ts CHANGED
@@ -16,6 +16,7 @@ import type {
16
16
  ChatClientState,
17
17
  ConnectionStatus,
18
18
  MessagePart,
19
+ QueuedMessage,
19
20
  ToolCallPart,
20
21
  UIMessage,
21
22
  } from './types'
@@ -114,6 +115,7 @@ export interface AIDevtoolsChatSnapshot {
114
115
  connectionStatus: ConnectionStatus
115
116
  sessionGenerating: boolean
116
117
  activeRunIds: Array<string>
118
+ queue?: Array<QueuedMessage>
117
119
  error?: string
118
120
  }
119
121
 
package/src/index.ts CHANGED
@@ -35,6 +35,13 @@ export type {
35
35
  ChatTransport,
36
36
  DistributedOmit,
37
37
  MultimodalContent,
38
+ QueuedMessage,
39
+ WhenBusy,
40
+ QueueBusyReason,
41
+ QueueConfig,
42
+ QueueStrategy,
43
+ QueueOption,
44
+ SendMessageOptions,
38
45
  } from './types'
39
46
  // Generation client types
40
47
  export type {