@tanstack/ai-client 0.20.0 → 0.22.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.
@@ -1,6 +1,6 @@
1
1
  import { AnyClientTool, ModelMessage, StreamChunk } from '@tanstack/ai/client';
2
2
  import { ConnectionAdapter } from './connection-adapters.js';
3
- import { ChatClientOptions, ChatClientState, ChatFetcher, ConnectionStatus, MultimodalContent, UIMessage } from './types.js';
3
+ import { ChatClientOptions, ChatClientState, ChatFetcher, ConnectionStatus, MultimodalContent, QueueOption, QueueStrategy, QueuedMessage, SendMessageOptions, UIMessage, WhenBusy } from './types.js';
4
4
  type ChatClientUpdateOptionsWithoutContext<TTools extends ReadonlyArray<AnyClientTool>> = {
5
5
  connection?: ConnectionAdapter;
6
6
  fetcher?: ChatFetcher;
@@ -8,6 +8,7 @@ type ChatClientUpdateOptionsWithoutContext<TTools extends ReadonlyArray<AnyClien
8
8
  body?: Record<string, any>;
9
9
  forwardedProps?: Record<string, any>;
10
10
  tools?: TTools;
11
+ queue?: QueueOption;
11
12
  onResponse?: (response?: Response) => void | Promise<void>;
12
13
  onChunk?: (chunk: StreamChunk) => void;
13
14
  onFinish?: (message: UIMessage) => void;
@@ -15,6 +16,7 @@ type ChatClientUpdateOptionsWithoutContext<TTools extends ReadonlyArray<AnyClien
15
16
  onSubscriptionChange?: (isSubscribed: boolean) => void;
16
17
  onConnectionStatusChange?: (status: ConnectionStatus) => void;
17
18
  onSessionGeneratingChange?: (isGenerating: boolean) => void;
19
+ onQueueChange?: (queue: Array<QueuedMessage>) => void;
18
20
  onCustomEvent?: (eventType: string, data: unknown, context: {
19
21
  toolCallId?: string;
20
22
  }) => void;
@@ -26,6 +28,14 @@ type ClientToolResult = {
26
28
  state?: 'output-available' | 'output-error';
27
29
  errorText?: string;
28
30
  };
31
+ export interface NormalizedQueueConfig {
32
+ whenBusy: WhenBusy;
33
+ drain: 'fifo' | 'batch';
34
+ onOverflow: 'reject' | 'drop-oldest';
35
+ maxSize?: number;
36
+ strategy?: QueueStrategy;
37
+ }
38
+ export declare function normalizeQueueOption(option: QueueOption | undefined): NormalizedQueueConfig;
29
39
  export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = any, TContext = unknown> {
30
40
  private readonly processor;
31
41
  private connection;
@@ -37,6 +47,31 @@ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = an
37
47
  private forwardedPropsOption;
38
48
  private context;
39
49
  private pendingMessageBody;
50
+ private queueConfig;
51
+ private messageQueue;
52
+ /**
53
+ * True from the moment `sendMessage` claims the client until its
54
+ * `streamResponse` settles. Closes the race where concurrent callers both
55
+ * see `isLoading === false`, both append a user message, and only one stream
56
+ * actually runs (leaving stranded user messages with no reply).
57
+ */
58
+ private sendInFlight;
59
+ /**
60
+ * True while `drainQueue` is delivering queued messages. Concurrent
61
+ * `sendMessage` calls during a drain are treated as busy and follow
62
+ * `whenBusy` (default: queue).
63
+ */
64
+ private messageQueueDraining;
65
+ /**
66
+ * Set by `whenBusy: 'interrupt'` so an in-progress FIFO drain loop stops
67
+ * before starting the next queued item (the interrupting send owns the client).
68
+ */
69
+ private stopMessageQueueDrain;
70
+ /**
71
+ * Sync claim held for the duration of `deliverMessage` so concurrent
72
+ * deliverers cannot both append a user message before only one stream runs.
73
+ */
74
+ private deliverClaim;
40
75
  private isLoading;
41
76
  private isSubscribed;
42
77
  private error;
@@ -119,6 +154,8 @@ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = an
119
154
  * - A MultimodalContent object with content array and optional custom ID
120
155
  * @param body - Optional body parameters to merge with the client's base body for this request.
121
156
  * Uses shallow merge with per-message body taking priority.
157
+ * @param sendOptions - Per-call overrides, e.g. `{ whenBusy: 'interrupt' }` to
158
+ * override the configured queue policy for this one send.
122
159
  *
123
160
  * @example
124
161
  * ```ts
@@ -128,6 +165,9 @@ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = an
128
165
  * // Text message with custom body params
129
166
  * await client.sendMessage('Hello!', { temperature: 0.7 })
130
167
  *
168
+ * // Per-call whenBusy override (body must still be the 2nd arg on ChatClient)
169
+ * await client.sendMessage('Urgent', undefined, { whenBusy: 'interrupt' })
170
+ *
131
171
  * // Multimodal message with image
132
172
  * await client.sendMessage({
133
173
  * content: [
@@ -149,7 +189,24 @@ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = an
149
189
  * )
150
190
  * ```
151
191
  */
152
- sendMessage(content: string | MultimodalContent, body?: Record<string, any>): Promise<void>;
192
+ sendMessage(content: string | MultimodalContent, body?: Record<string, any>, sendOptions?: SendMessageOptions): Promise<void>;
193
+ /** True while a stream is active, a send is claiming the client, or the queue is draining. */
194
+ private isSendBusy;
195
+ private resolveBusyReason;
196
+ /**
197
+ * Append a user message and run the stream. Used by both direct sends and
198
+ * queue drains — callers are responsible for busy/queue policy.
199
+ *
200
+ * Claims delivery synchronously before appending so concurrent callers
201
+ * cannot both add a user message when only one stream can run.
202
+ */
203
+ private deliverMessage;
204
+ /**
205
+ * Resolve the effective action for a send that arrives while busy.
206
+ * The returned `id` is the id that will be stored if the action is `queue`.
207
+ */
208
+ private decideWhenBusy;
209
+ private enqueueMessage;
153
210
  /**
154
211
  * Normalize the message input to extract content and optional id.
155
212
  * Trims string content automatically.
@@ -223,6 +280,43 @@ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = an
223
280
  * Get current messages
224
281
  */
225
282
  getMessages(): Array<UIMessage<TTools>>;
283
+ /**
284
+ * True when an interrupt (or another direct send) claimed the client during
285
+ * a drain. Read via a method so cross-await mutations are not constant-folded
286
+ * by control-flow analysis.
287
+ */
288
+ private shouldAbortMessageQueueDrain;
289
+ /**
290
+ * Deliver queued messages after a successful settle.
291
+ * - `batch`: merge everything currently queued into one send, looping so
292
+ * messages enqueued during that batch stream are not stranded.
293
+ * - `fifo`: walk the queue in a loop, one stream at a time, until empty
294
+ * (or until another send claims the client via interrupt).
295
+ *
296
+ * Uses `deliverMessage` directly so drains do not re-enter `sendMessage`'s
297
+ * busy/queue policy (which would re-queue items and strand the rest).
298
+ */
299
+ private drainQueue;
300
+ /**
301
+ * Drop any in-flight send claim and discard pending queued messages
302
+ * (stop / error / clear / unsubscribe / reload).
303
+ */
304
+ private discardPendingSends;
305
+ /**
306
+ * Get the current send queue (messages held while a stream was in flight).
307
+ */
308
+ getQueue(): Array<QueuedMessage>;
309
+ private emitQueueChange;
310
+ /**
311
+ * Remove a queued message by id before it drains.
312
+ */
313
+ cancelQueued(id: string): void;
314
+ /**
315
+ * Discard all pending queued messages (stop / error / clear / unsubscribe /
316
+ * reload). Does not send them. Emits `onQueueChange([])` when anything was
317
+ * removed.
318
+ */
319
+ private flushQueue;
226
320
  /**
227
321
  * Get loading state
228
322
  */
@@ -13,6 +13,60 @@ function resolveTransport(transport) {
13
13
  if (fetcher) return fetcherToConnectionAdapter(fetcher);
14
14
  throw new Error("ChatClient: either `connection` or `fetcher` is required.");
15
15
  }
16
+ function normalizeQueueOption(option) {
17
+ const base = {
18
+ whenBusy: "queue",
19
+ drain: "fifo",
20
+ onOverflow: "reject"
21
+ };
22
+ if (!option) return base;
23
+ if (typeof option === "string") return { ...base, whenBusy: option };
24
+ if (typeof option === "function") return { ...base, strategy: option };
25
+ const maxSize = option.maxSize;
26
+ if (maxSize !== void 0) {
27
+ if (!Number.isInteger(maxSize) || maxSize < 0) {
28
+ throw new Error(
29
+ "ChatClient: queue.maxSize must be a non-negative integer"
30
+ );
31
+ }
32
+ }
33
+ return {
34
+ whenBusy: option.whenBusy ?? "queue",
35
+ drain: option.drain ?? "fifo",
36
+ onOverflow: option.onOverflow ?? "reject",
37
+ ...maxSize !== void 0 ? { maxSize } : {}
38
+ };
39
+ }
40
+ function mergeQueuedMessages(items) {
41
+ const body = items.at(-1)?.body;
42
+ const stringContents = [];
43
+ for (const item of items) {
44
+ if (typeof item.content !== "string") {
45
+ break;
46
+ }
47
+ stringContents.push(item.content);
48
+ }
49
+ if (stringContents.length === items.length) {
50
+ return {
51
+ content: stringContents.join("\n"),
52
+ ...body !== void 0 ? { body } : {}
53
+ };
54
+ }
55
+ const parts = [];
56
+ for (const item of items) {
57
+ if (typeof item.content === "string") {
58
+ parts.push({ type: "text", content: item.content });
59
+ } else if (typeof item.content.content === "string") {
60
+ parts.push({ type: "text", content: item.content.content });
61
+ } else {
62
+ parts.push(...item.content.content);
63
+ }
64
+ }
65
+ return {
66
+ content: { content: parts },
67
+ ...body !== void 0 ? { body } : {}
68
+ };
69
+ }
16
70
  class ChatClient {
17
71
  processor;
18
72
  connection;
@@ -31,6 +85,31 @@ class ChatClient {
31
85
  forwardedPropsOption = {};
32
86
  context = void 0;
33
87
  pendingMessageBody = void 0;
88
+ queueConfig;
89
+ messageQueue = [];
90
+ /**
91
+ * True from the moment `sendMessage` claims the client until its
92
+ * `streamResponse` settles. Closes the race where concurrent callers both
93
+ * see `isLoading === false`, both append a user message, and only one stream
94
+ * actually runs (leaving stranded user messages with no reply).
95
+ */
96
+ sendInFlight = false;
97
+ /**
98
+ * True while `drainQueue` is delivering queued messages. Concurrent
99
+ * `sendMessage` calls during a drain are treated as busy and follow
100
+ * `whenBusy` (default: queue).
101
+ */
102
+ messageQueueDraining = false;
103
+ /**
104
+ * Set by `whenBusy: 'interrupt'` so an in-progress FIFO drain loop stops
105
+ * before starting the next queued item (the interrupting send owns the client).
106
+ */
107
+ stopMessageQueueDrain = false;
108
+ /**
109
+ * Sync claim held for the duration of `deliverMessage` so concurrent
110
+ * deliverers cannot both append a user message before only one stream runs.
111
+ */
112
+ deliverClaim = false;
34
113
  isLoading = false;
35
114
  isSubscribed = false;
36
115
  error = void 0;
@@ -80,6 +159,7 @@ class ChatClient {
80
159
  this.bodyOption = options.body || {};
81
160
  this.forwardedPropsOption = options.forwardedProps || {};
82
161
  this.context = options.context;
162
+ this.queueConfig = normalizeQueueOption(options.queue);
83
163
  this.connection = normalizeConnectionAdapter(resolveTransport(options));
84
164
  this.clientToolsRef = { current: /* @__PURE__ */ new Map() };
85
165
  if (options.tools) {
@@ -113,6 +193,8 @@ class ChatClient {
113
193
  }),
114
194
  onSessionGeneratingChange: options.onSessionGeneratingChange || (() => {
115
195
  }),
196
+ onQueueChange: options.onQueueChange || (() => {
197
+ }),
116
198
  onCustomEvent: options.onCustomEvent || (() => {
117
199
  })
118
200
  }
@@ -383,6 +465,7 @@ class ChatClient {
383
465
  connectionStatus: this.connectionStatus,
384
466
  sessionGenerating: this.sessionGenerating,
385
467
  activeRunIds: Array.from(this.activeRunIds),
468
+ queue: this.getQueue(),
386
469
  ...this.error ? { error: this.error.message } : {}
387
470
  };
388
471
  }
@@ -412,6 +495,7 @@ class ChatClient {
412
495
  }
413
496
  this.resolveProcessing();
414
497
  this.setIsLoading(false);
498
+ this.deliverClaim = false;
415
499
  if (options?.setReadyStatus) {
416
500
  this.setStatus("ready");
417
501
  }
@@ -513,6 +597,8 @@ class ChatClient {
513
597
  * - A MultimodalContent object with content array and optional custom ID
514
598
  * @param body - Optional body parameters to merge with the client's base body for this request.
515
599
  * Uses shallow merge with per-message body taking priority.
600
+ * @param sendOptions - Per-call overrides, e.g. `{ whenBusy: 'interrupt' }` to
601
+ * override the configured queue policy for this one send.
516
602
  *
517
603
  * @example
518
604
  * ```ts
@@ -522,6 +608,9 @@ class ChatClient {
522
608
  * // Text message with custom body params
523
609
  * await client.sendMessage('Hello!', { temperature: 0.7 })
524
610
  *
611
+ * // Per-call whenBusy override (body must still be the 2nd arg on ChatClient)
612
+ * await client.sendMessage('Urgent', undefined, { whenBusy: 'interrupt' })
613
+ *
525
614
  * // Multimodal message with image
526
615
  * await client.sendMessage({
527
616
  * content: [
@@ -543,20 +632,107 @@ class ChatClient {
543
632
  * )
544
633
  * ```
545
634
  */
546
- async sendMessage(content, body) {
635
+ async sendMessage(content, body, sendOptions) {
547
636
  this.mountDevtools();
548
637
  const emptyMessage = typeof content === "string" && !content.trim();
549
- if (emptyMessage || this.isLoading) {
638
+ if (emptyMessage) {
550
639
  return;
551
640
  }
552
- const normalizedContent = this.normalizeMessageInput(content);
553
- this.pendingMessageBody = body;
554
- const userMessage = this.processor.addUserMessage(
555
- normalizedContent.content,
556
- normalizedContent.id
557
- );
558
- this.events.messageSent(userMessage.id, normalizedContent.content);
559
- await this.streamResponse();
641
+ if (this.isSendBusy()) {
642
+ const { action, id } = this.decideWhenBusy(content, sendOptions);
643
+ if (action === "drop") {
644
+ return;
645
+ }
646
+ if (action === "queue") {
647
+ this.enqueueMessage(content, body, id);
648
+ return;
649
+ }
650
+ this.stopMessageQueueDrain = true;
651
+ this.sendInFlight = true;
652
+ this.cancelInFlightStream({ setReadyStatus: true });
653
+ this.resetSessionGenerating();
654
+ } else {
655
+ this.sendInFlight = true;
656
+ }
657
+ try {
658
+ await this.deliverMessage(content, body);
659
+ } finally {
660
+ this.sendInFlight = false;
661
+ }
662
+ }
663
+ /** True while a stream is active, a send is claiming the client, or the queue is draining. */
664
+ isSendBusy() {
665
+ return this.isLoading || this.sendInFlight || this.messageQueueDraining;
666
+ }
667
+ resolveBusyReason() {
668
+ if (this.isLoading) return "streaming";
669
+ if (this.messageQueueDraining) return "draining";
670
+ return "sendInFlight";
671
+ }
672
+ /**
673
+ * Append a user message and run the stream. Used by both direct sends and
674
+ * queue drains — callers are responsible for busy/queue policy.
675
+ *
676
+ * Claims delivery synchronously before appending so concurrent callers
677
+ * cannot both add a user message when only one stream can run.
678
+ */
679
+ async deliverMessage(content, body) {
680
+ if (this.isLoading || this.deliverClaim) {
681
+ return false;
682
+ }
683
+ this.deliverClaim = true;
684
+ try {
685
+ const normalizedContent = this.normalizeMessageInput(content);
686
+ this.pendingMessageBody = body;
687
+ const userMessage = this.processor.addUserMessage(
688
+ normalizedContent.content,
689
+ normalizedContent.id
690
+ );
691
+ this.events.messageSent(userMessage.id, normalizedContent.content);
692
+ return await this.streamResponse();
693
+ } finally {
694
+ this.deliverClaim = false;
695
+ }
696
+ }
697
+ /**
698
+ * Resolve the effective action for a send that arrives while busy.
699
+ * The returned `id` is the id that will be stored if the action is `queue`.
700
+ */
701
+ decideWhenBusy(content, sendOptions) {
702
+ const id = this.generateUniqueId("queued");
703
+ if (sendOptions?.whenBusy) {
704
+ return { action: sendOptions.whenBusy, id };
705
+ }
706
+ const { strategy, whenBusy } = this.queueConfig;
707
+ if (strategy) {
708
+ const { action } = strategy({
709
+ pending: {
710
+ id,
711
+ content,
712
+ createdAt: Date.now()
713
+ },
714
+ busyReason: this.resolveBusyReason(),
715
+ queued: this.getQueue()
716
+ });
717
+ return { action, id };
718
+ }
719
+ return { action: whenBusy, id };
720
+ }
721
+ enqueueMessage(content, body, id) {
722
+ const { maxSize, onOverflow } = this.queueConfig;
723
+ if (maxSize !== void 0 && this.messageQueue.length >= maxSize) {
724
+ if (onOverflow === "reject" || maxSize === 0) {
725
+ return;
726
+ }
727
+ this.messageQueue.shift();
728
+ }
729
+ this.messageQueue.push({
730
+ id: id ?? this.generateUniqueId("queued"),
731
+ content,
732
+ createdAt: Date.now(),
733
+ ...body !== void 0 ? { body } : {}
734
+ });
735
+ this.emitQueueChange();
560
736
  }
561
737
  /**
562
738
  * Normalize the message input to extract content and optional id.
@@ -602,6 +778,7 @@ class ChatClient {
602
778
  const runId = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
603
779
  this.currentRunId = runId;
604
780
  this.setIsLoading(true);
781
+ this.deliverClaim = false;
605
782
  this.setStatus("submitted");
606
783
  this.setError(void 0);
607
784
  this.errorReportedGeneration = null;
@@ -740,10 +917,18 @@ class ChatClient {
740
917
  await this.checkForContinuation();
741
918
  } catch (error) {
742
919
  console.error("Failed to continue flow after tool result:", error);
920
+ await this.drainQueue();
921
+ }
922
+ } else {
923
+ if (this.status !== "ready") {
924
+ this.setStatus("ready");
925
+ }
926
+ if (!this.messageQueueDraining) {
927
+ await this.drainQueue();
743
928
  }
744
- } else if (this.status !== "ready") {
745
- this.setStatus("ready");
746
929
  }
930
+ } else {
931
+ this.flushQueue();
747
932
  }
748
933
  }
749
934
  }
@@ -774,6 +959,7 @@ class ChatClient {
774
959
  setReadyStatus: true,
775
960
  abortSubscription: true
776
961
  });
962
+ this.discardPendingSends();
777
963
  this.resetSessionGenerating();
778
964
  this.setIsSubscribed(false);
779
965
  this.setConnectionStatus("disconnected");
@@ -791,6 +977,7 @@ class ChatClient {
791
977
  if (this.isLoading) {
792
978
  this.cancelInFlightStream();
793
979
  }
980
+ this.discardPendingSends();
794
981
  this.events.reloaded(lastUserMessageIndex);
795
982
  this.processor.removeMessagesAfter(lastUserMessageIndex);
796
983
  this.devtoolsBridge.emitSnapshot();
@@ -802,6 +989,7 @@ class ChatClient {
802
989
  stop() {
803
990
  const hadLocalStream = this.abortController !== null;
804
991
  this.cancelInFlightStream({ setReadyStatus: true });
992
+ this.discardPendingSends();
805
993
  if (hadLocalStream) {
806
994
  this.resetSessionGenerating();
807
995
  }
@@ -826,6 +1014,7 @@ class ChatClient {
826
1014
  this.persistor.beginClear();
827
1015
  }
828
1016
  this.processor.clearMessages();
1017
+ this.discardPendingSends();
829
1018
  this.persistor?.remove();
830
1019
  this.setError(void 0);
831
1020
  this.events.messagesCleared();
@@ -974,6 +1163,109 @@ class ChatClient {
974
1163
  getMessages() {
975
1164
  return this.processor.getMessages();
976
1165
  }
1166
+ /**
1167
+ * True when an interrupt (or another direct send) claimed the client during
1168
+ * a drain. Read via a method so cross-await mutations are not constant-folded
1169
+ * by control-flow analysis.
1170
+ */
1171
+ shouldAbortMessageQueueDrain() {
1172
+ return this.isLoading || this.stopMessageQueueDrain;
1173
+ }
1174
+ /**
1175
+ * Deliver queued messages after a successful settle.
1176
+ * - `batch`: merge everything currently queued into one send, looping so
1177
+ * messages enqueued during that batch stream are not stranded.
1178
+ * - `fifo`: walk the queue in a loop, one stream at a time, until empty
1179
+ * (or until another send claims the client via interrupt).
1180
+ *
1181
+ * Uses `deliverMessage` directly so drains do not re-enter `sendMessage`'s
1182
+ * busy/queue policy (which would re-queue items and strand the rest).
1183
+ */
1184
+ async drainQueue() {
1185
+ if (this.messageQueueDraining || this.isLoading || this.messageQueue.length === 0) {
1186
+ return;
1187
+ }
1188
+ this.messageQueueDraining = true;
1189
+ this.stopMessageQueueDrain = false;
1190
+ try {
1191
+ if (this.queueConfig.drain === "batch") {
1192
+ while (this.messageQueue.length > 0) {
1193
+ if (this.shouldAbortMessageQueueDrain()) {
1194
+ return;
1195
+ }
1196
+ const items = this.messageQueue.splice(0);
1197
+ this.emitQueueChange();
1198
+ const merged = mergeQueuedMessages(items);
1199
+ const completed = await this.deliverMessage(
1200
+ merged.content,
1201
+ merged.body
1202
+ );
1203
+ if (!completed || this.shouldAbortMessageQueueDrain()) {
1204
+ return;
1205
+ }
1206
+ }
1207
+ return;
1208
+ }
1209
+ while (this.messageQueue.length > 0) {
1210
+ if (this.shouldAbortMessageQueueDrain()) {
1211
+ return;
1212
+ }
1213
+ const next = this.messageQueue.shift();
1214
+ if (next === void 0) {
1215
+ return;
1216
+ }
1217
+ this.emitQueueChange();
1218
+ const completed = await this.deliverMessage(next.content, next.body);
1219
+ if (!completed || this.shouldAbortMessageQueueDrain()) {
1220
+ return;
1221
+ }
1222
+ }
1223
+ } finally {
1224
+ this.messageQueueDraining = false;
1225
+ this.stopMessageQueueDrain = false;
1226
+ }
1227
+ }
1228
+ /**
1229
+ * Drop any in-flight send claim and discard pending queued messages
1230
+ * (stop / error / clear / unsubscribe / reload).
1231
+ */
1232
+ discardPendingSends() {
1233
+ this.sendInFlight = false;
1234
+ this.flushQueue();
1235
+ }
1236
+ /**
1237
+ * Get the current send queue (messages held while a stream was in flight).
1238
+ */
1239
+ getQueue() {
1240
+ return this.messageQueue.map(({ id, content, createdAt }) => ({
1241
+ id,
1242
+ content,
1243
+ createdAt
1244
+ }));
1245
+ }
1246
+ emitQueueChange() {
1247
+ this.callbacksRef.current.onQueueChange(this.getQueue());
1248
+ this.devtoolsBridge.emitSnapshot();
1249
+ }
1250
+ /**
1251
+ * Remove a queued message by id before it drains.
1252
+ */
1253
+ cancelQueued(id) {
1254
+ const index = this.messageQueue.findIndex((m) => m.id === id);
1255
+ if (index === -1) return;
1256
+ this.messageQueue.splice(index, 1);
1257
+ this.emitQueueChange();
1258
+ }
1259
+ /**
1260
+ * Discard all pending queued messages (stop / error / clear / unsubscribe /
1261
+ * reload). Does not send them. Emits `onQueueChange([])` when anything was
1262
+ * removed.
1263
+ */
1264
+ flushQueue() {
1265
+ if (this.messageQueue.length === 0) return;
1266
+ this.messageQueue = [];
1267
+ this.emitQueueChange();
1268
+ }
977
1269
  /**
978
1270
  * Get loading state
979
1271
  */
@@ -1060,6 +1352,9 @@ class ChatClient {
1060
1352
  }
1061
1353
  this.devtoolsBridge.notifyToolsChanged();
1062
1354
  }
1355
+ if (options.queue !== void 0) {
1356
+ this.queueConfig = normalizeQueueOption(options.queue);
1357
+ }
1063
1358
  if (options.onResponse !== void 0) {
1064
1359
  this.callbacksRef.current.onResponse = options.onResponse;
1065
1360
  }
@@ -1081,6 +1376,9 @@ class ChatClient {
1081
1376
  if (options.onSessionGeneratingChange !== void 0) {
1082
1377
  this.callbacksRef.current.onSessionGeneratingChange = options.onSessionGeneratingChange;
1083
1378
  }
1379
+ if (options.onQueueChange !== void 0) {
1380
+ this.callbacksRef.current.onQueueChange = options.onQueueChange;
1381
+ }
1084
1382
  if (options.onCustomEvent !== void 0) {
1085
1383
  this.callbacksRef.current.onCustomEvent = options.onCustomEvent;
1086
1384
  }
@@ -1092,6 +1390,7 @@ class ChatClient {
1092
1390
  }
1093
1391
  }
1094
1392
  export {
1095
- ChatClient
1393
+ ChatClient,
1394
+ normalizeQueueOption
1096
1395
  };
1097
1396
  //# sourceMappingURL=chat-client.js.map