@devicai/ui 0.50.0 → 0.52.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.
Files changed (53) hide show
  1. package/README.md +68 -1
  2. package/dist/cjs/api/client.js +15 -0
  3. package/dist/cjs/api/client.js.map +1 -1
  4. package/dist/cjs/api/types.js.map +1 -1
  5. package/dist/cjs/components/ChatDrawer/ChatDrawer.js +138 -11
  6. package/dist/cjs/components/ChatDrawer/ChatDrawer.js.map +1 -1
  7. package/dist/cjs/components/ChatDrawer/ChatInput.js +69 -13
  8. package/dist/cjs/components/ChatDrawer/ChatInput.js.map +1 -1
  9. package/dist/cjs/components/ChatDrawer/ChatMessages.js +6 -2
  10. package/dist/cjs/components/ChatDrawer/ChatMessages.js.map +1 -1
  11. package/dist/cjs/components/ChatDrawer/QueueNotice.js +46 -0
  12. package/dist/cjs/components/ChatDrawer/QueueNotice.js.map +1 -0
  13. package/dist/cjs/components/IntegrationsModal/integrationChoice.js +59 -0
  14. package/dist/cjs/components/IntegrationsModal/integrationChoice.js.map +1 -0
  15. package/dist/cjs/hooks/useDevicChat.js +277 -32
  16. package/dist/cjs/hooks/useDevicChat.js.map +1 -1
  17. package/dist/cjs/hooks/usePolling.js +5 -2
  18. package/dist/cjs/hooks/usePolling.js.map +1 -1
  19. package/dist/cjs/index.js +7 -0
  20. package/dist/cjs/index.js.map +1 -1
  21. package/dist/cjs/styles.css +1 -1
  22. package/dist/esm/api/client.d.ts +2 -5
  23. package/dist/esm/api/client.js +15 -0
  24. package/dist/esm/api/client.js.map +1 -1
  25. package/dist/esm/api/types.d.ts +69 -1
  26. package/dist/esm/api/types.js.map +1 -1
  27. package/dist/esm/components/ChatDrawer/ChatDrawer.js +139 -12
  28. package/dist/esm/components/ChatDrawer/ChatDrawer.js.map +1 -1
  29. package/dist/esm/components/ChatDrawer/ChatDrawer.types.d.ts +56 -4
  30. package/dist/esm/components/ChatDrawer/ChatInput.js +69 -13
  31. package/dist/esm/components/ChatDrawer/ChatInput.js.map +1 -1
  32. package/dist/esm/components/ChatDrawer/ChatMessages.js +6 -2
  33. package/dist/esm/components/ChatDrawer/ChatMessages.js.map +1 -1
  34. package/dist/esm/components/ChatDrawer/QueueNotice.d.ts +23 -0
  35. package/dist/esm/components/ChatDrawer/QueueNotice.js +44 -0
  36. package/dist/esm/components/ChatDrawer/QueueNotice.js.map +1 -0
  37. package/dist/esm/components/ChatDrawer/index.d.ts +2 -0
  38. package/dist/esm/components/IntegrationsModal/index.d.ts +1 -0
  39. package/dist/esm/components/IntegrationsModal/integrationChoice.d.ts +16 -0
  40. package/dist/esm/components/IntegrationsModal/integrationChoice.js +54 -0
  41. package/dist/esm/components/IntegrationsModal/integrationChoice.js.map +1 -0
  42. package/dist/esm/hooks/index.d.ts +1 -1
  43. package/dist/esm/hooks/useDevicChat.d.ts +65 -5
  44. package/dist/esm/hooks/useDevicChat.js +277 -32
  45. package/dist/esm/hooks/useDevicChat.js.map +1 -1
  46. package/dist/esm/hooks/usePolling.d.ts +12 -0
  47. package/dist/esm/hooks/usePolling.js +5 -2
  48. package/dist/esm/hooks/usePolling.js.map +1 -1
  49. package/dist/esm/index.d.ts +5 -5
  50. package/dist/esm/index.js +2 -0
  51. package/dist/esm/index.js.map +1 -1
  52. package/dist/esm/styles.css +1 -1
  53. package/package.json +1 -1
@@ -7,6 +7,7 @@ var client = require('../api/client.js');
7
7
  var logger = require('../utils/logger.js');
8
8
  var usePolling = require('./usePolling.js');
9
9
  var useModelInterface = require('./useModelInterface.js');
10
+ var assistantInfo = require('../api/assistantInfo.js');
10
11
 
11
12
  /**
12
13
  * Cadence for the handoff watch, which only waits for the parent thread to
@@ -14,6 +15,17 @@ var useModelInterface = require('./useModelInterface.js');
14
15
  * A configured `pollingInterval` still wins over it.
15
16
  */
16
17
  const DEFAULT_HANDOFF_POLL_INTERVAL_MS = 5000;
18
+ /**
19
+ * How many polls the conversation may read as finished while it still owes an
20
+ * answer to something queued, before the wait is given up.
21
+ *
22
+ * The window is real: a run liquidates, and the follow-up run that drains the
23
+ * queue has not marked itself as processing yet. Bounded so a message that
24
+ * never comes back cannot poll forever.
25
+ */
26
+ const QUEUE_HANDOVER_GRACE_TICKS = 60;
27
+ /** Messages are matched to their optimistic copies by text, so it is normalized. */
28
+ const normalizeText = (text) => (text ?? '').trim();
17
29
  /**
18
30
  * Main hook for managing chat with a Devic assistant
19
31
  *
@@ -37,7 +49,7 @@ const DEFAULT_HANDOFF_POLL_INTERVAL_MS = 5000;
37
49
  * ```
38
50
  */
39
51
  function useDevicChat(options) {
40
- const { assistantId, chatUid: initialChatUid, apiKey: propsApiKey, baseUrl: propsBaseUrl, tenantId, tenantMetadata, subtenantId, subtenantMetadata, tags, enabledTools, disabledIntegrations, modelInterfaceTools = [], pollingInterval: propsPollingInterval, onMessageSent, onMessageReceived, onToolCall, onError, onChatCreated, onFileUpload, debug: propsDebug, } = options;
52
+ const { assistantId, chatUid: initialChatUid, apiKey: propsApiKey, baseUrl: propsBaseUrl, tenantId, tenantMetadata, subtenantId, subtenantMetadata, tags, enabledTools, disabledIntegrations, modelInterfaceTools = [], pollingInterval: propsPollingInterval, onMessageSent, onMessageReceived, onToolCall, onError, onChatCreated, onFileUpload, messageQueue, debug: propsDebug, } = options;
41
53
  // Get context (may be null if not wrapped in provider)
42
54
  const context = DevicContext.useOptionalDevicContext();
43
55
  // Resolve configuration
@@ -89,6 +101,13 @@ function useDevicChat(options) {
89
101
  // Keep a ref to chatUid so async callbacks always read the latest value
90
102
  const chatUidRef = React.useRef(chatUid);
91
103
  chatUidRef.current = chatUid;
104
+ // Read by `sendMessage`, which must not restate the state of a run already in
105
+ // flight — its own callback identity would otherwise carry a stale value.
106
+ const isLoadingRef = React.useRef(isLoading);
107
+ isLoadingRef.current = isLoading;
108
+ // Two messages sent inside the same millisecond would otherwise share a uid,
109
+ // which the queue makes easy to do.
110
+ const optimisticSeqRef = React.useRef(0);
92
111
  // Refs for callbacks
93
112
  const onMessageReceivedRef = React.useRef(onMessageReceived);
94
113
  const onErrorRef = React.useRef(onError);
@@ -109,6 +128,55 @@ function useDevicChat(options) {
109
128
  clientRef.current.setConfig({ apiKey, baseUrl });
110
129
  }
111
130
  }, [apiKey, baseUrl]);
131
+ // --- Message queue --------------------------------------------------------
132
+ /**
133
+ * Messages accepted by this conversation that the model has not seen yet.
134
+ * Taken from the poll, so it counts whatever else queued on the conversation
135
+ * too — another tab, or the same person writing from another channel.
136
+ */
137
+ const [queuedCount, setQueuedCount] = React.useState(0);
138
+ /**
139
+ * When something was queued from here that has not been answered yet.
140
+ *
141
+ * Deliberately not a list of the texts sent: a drain can merge several queued
142
+ * messages into a single user turn, so what comes back is not what went out
143
+ * and matching them by text would wait forever. What is being waited for is an
144
+ * answer, and an assistant message written after the message was accepted is
145
+ * that answer.
146
+ */
147
+ const awaitingAnswerSinceRef = React.useRef(null);
148
+ const queueGraceTicksRef = React.useRef(0);
149
+ const rememberAwaiting = React.useCallback(() => {
150
+ awaitingAnswerSinceRef.current = Date.now();
151
+ }, []);
152
+ const resetQueueState = React.useCallback(() => {
153
+ awaitingAnswerSinceRef.current = null;
154
+ queueGraceTicksRef.current = 0;
155
+ setQueuedCount(0);
156
+ }, []);
157
+ /**
158
+ * Asked for as soon as the hook is alive, rather than when the first run
159
+ * starts: the answer decides whether the input stays open while the assistant
160
+ * works, and resolving it late means the box visibly closes and reopens on the
161
+ * first message of every session. One request, shared with everything else
162
+ * that asks about this assistant. Passing `messageQueue` skips it entirely.
163
+ */
164
+ const queueLookup = assistantInfo.useAssistantInfo({
165
+ assistantId,
166
+ client: clientRef.current,
167
+ baseUrl,
168
+ credential: apiKey || 'session',
169
+ enabled: messageQueue === undefined,
170
+ });
171
+ /**
172
+ * Absent means no. An assistant that has not answered yet, or an API too old
173
+ * to carry the field, leaves the input closed while it works — the same thing
174
+ * it did before this existed. Opening it on a maybe would promise a queue the
175
+ * conversation then refuses.
176
+ */
177
+ const queueEnabled = messageQueue ??
178
+ (queueLookup.settled &&
179
+ queueLookup.assistant?.messageQueueEnabled === true);
112
180
  // Resume chat state based on realtime status.
113
181
  // Called after loading chat history to detect in-progress conversations.
114
182
  const resumeFromRealtimeStatus = React.useCallback(async (targetChatUid) => {
@@ -118,11 +186,16 @@ function useDevicChat(options) {
118
186
  const realtime = await clientRef.current.getRealtimeHistory(assistantId, targetChatUid);
119
187
  logRef.current.log('[useDevicChat] resumeFromRealtimeStatus:', realtime.status);
120
188
  // Update messages with realtime data (may be fresher than static history)
121
- if (realtime.chatHistory?.length) {
122
- setMessages(realtime.chatHistory);
189
+ const queuedOnServer = (realtime.pendingUserMessages ?? []).map((m) => ({
190
+ ...m,
191
+ queued: true,
192
+ }));
193
+ if (realtime.chatHistory?.length || queuedOnServer.length) {
194
+ setMessages([...(realtime.chatHistory ?? []), ...queuedOnServer]);
123
195
  }
124
196
  mergeRecalledMemories(realtime.recalledMemories);
125
197
  setStatus(realtime.status);
198
+ setQueuedCount(realtime.queuedMessages ?? 0);
126
199
  if (realtime.status === 'processing') {
127
200
  // Chat is still processing — resume polling
128
201
  setIsLoading(true);
@@ -143,6 +216,13 @@ function useDevicChat(options) {
143
216
  setHandedOffSubThreadId(subThreadId);
144
217
  }
145
218
  }
219
+ else if ((realtime.queuedMessages ?? 0) > 0) {
220
+ // The run settled, but the conversation still owes an answer to
221
+ // something queued — reopened on a conversation whose follow-up run
222
+ // has not started yet. Watch it until the queue is served.
223
+ setIsLoading(true);
224
+ setShouldPoll(true);
225
+ }
146
226
  else {
147
227
  // completed or error — just stop
148
228
  setIsLoading(false);
@@ -214,30 +294,84 @@ function useDevicChat(options) {
214
294
  ],
215
295
  onUpdate: async (data) => {
216
296
  logRef.current.log('[useDevicChat] onUpdate called, status:', data.status);
297
+ // An assistant message written after something was queued from here is
298
+ // the answer that was being waited for.
299
+ const awaitingSince = awaitingAnswerSinceRef.current;
300
+ if (awaitingSince !== null &&
301
+ (data.queuedMessages ?? 0) === 0 &&
302
+ (data.chatHistory ?? []).some((m) => m.role === 'assistant' && (m.timestamp ?? 0) > awaitingSince)) {
303
+ awaitingAnswerSinceRef.current = null;
304
+ }
217
305
  // Merge realtime data with optimistic messages.
218
306
  // When a server user message matches an optimistic one by text, adopt the
219
307
  // optimistic uid so React's key stays stable (avoids unmount/remount flicker).
220
308
  setMessages((prev) => {
221
- const normalize = (t) => (t ?? '').trim();
222
- const optimisticUserByText = new Map(prev
309
+ // A queue of uids per text rather than one: two identical messages are
310
+ // two messages, and pairing both with the same optimistic copy would
311
+ // drop one of them from the conversation.
312
+ const optimisticUserByText = new Map();
313
+ prev
223
314
  .filter((m) => m.role === 'user' && m.uid.startsWith('temp-'))
224
- .map((m) => [normalize(m.content?.message), m.uid]));
225
- const adoptedTempUids = new Set();
226
- const merged = data.chatHistory.map((m) => {
227
- if (m.role === 'user') {
228
- const tempUid = optimisticUserByText.get(normalize(m.content?.message));
229
- if (tempUid) {
230
- adoptedTempUids.add(tempUid);
231
- // Keep the server uid around: recall anchors reference it.
232
- return { ...m, uid: tempUid, serverUid: m.uid };
233
- }
234
- }
235
- return m;
315
+ .forEach((m) => {
316
+ const key = normalizeText(m.content?.message);
317
+ const bucket = optimisticUserByText.get(key);
318
+ if (bucket)
319
+ bucket.push(m.uid);
320
+ else
321
+ optimisticUserByText.set(key, [m.uid]);
236
322
  });
237
- const mergedUIDs = new Set(merged.map((m) => m.uid));
238
- const optimistic = prev.filter((m) => !mergedUIDs.has(m.uid) && !adoptedTempUids.has(m.uid));
239
- return [...merged, ...optimistic];
323
+ const adoptedTempUids = new Set();
324
+ const adopt = (m) => {
325
+ if (m.role !== 'user')
326
+ return m;
327
+ const tempUid = optimisticUserByText
328
+ .get(normalizeText(m.content?.message))
329
+ ?.shift();
330
+ if (!tempUid)
331
+ return m;
332
+ adoptedTempUids.add(tempUid);
333
+ // Keep the server uid around: recall anchors reference it.
334
+ return { ...m, uid: tempUid, serverUid: m.uid };
335
+ };
336
+ const merged = data.chatHistory.map(adopt);
337
+ // Accepted, but not part of the conversation yet. Drawn between the
338
+ // history and the optimistic ones, which is where they land once a
339
+ // turn takes them. The copy is the server's, so it survives a reload —
340
+ // where the API does not return them, this is empty and the optimistic
341
+ // ones kept below stand in.
342
+ const queued = (data.pendingUserMessages ?? []).map((m) => ({
343
+ ...adopt(m),
344
+ queued: true,
345
+ }));
346
+ // Which of this client's own queued bubbles to keep drawing.
347
+ //
348
+ // Matching them to the history by text does not work: a drain can
349
+ // merge several queued messages into one user turn, and an optimistic
350
+ // copy that never finds its pair would sit there marked as queued for
351
+ // the rest of the conversation. The server's queue is the authority,
352
+ // and these are kept only where it cannot speak for them:
353
+ // - too recently accepted for this poll to have seen them;
354
+ // - counted by `queuedMessages` but not itemised, which is what an
355
+ // API without `pendingUserMessages` reports.
356
+ const optimisticQueued = prev.filter((m) => m.queued && m.uid.startsWith('temp-'));
357
+ const tooRecent = Date.now() - pollingInterval;
358
+ const keptQueued = new Set(optimisticQueued
359
+ .filter((m) => (m.queuedAt ?? 0) > tooRecent)
360
+ .map((m) => m.uid));
361
+ let unitemised = (data.queuedMessages ?? queued.length) - queued.length - keptQueued.size;
362
+ for (let i = optimisticQueued.length - 1; i >= 0 && unitemised > 0; i--) {
363
+ if (keptQueued.has(optimisticQueued[i].uid))
364
+ continue;
365
+ keptQueued.add(optimisticQueued[i].uid);
366
+ unitemised -= 1;
367
+ }
368
+ const mergedUIDs = new Set([...merged, ...queued].map((m) => m.uid));
369
+ const optimistic = prev.filter((m) => !mergedUIDs.has(m.uid) &&
370
+ !adoptedTempUids.has(m.uid) &&
371
+ (!m.queued || keptQueued.has(m.uid)));
372
+ return [...merged, ...queued, ...optimistic];
240
373
  });
374
+ setQueuedCount(data.queuedMessages ?? 0);
241
375
  // Surface recall events while the run is still processing, so the
242
376
  // "recalled memories" strip shows before the first response lands.
243
377
  mergeRecalledMemories(data.recalledMemories);
@@ -252,9 +386,28 @@ function useDevicChat(options) {
252
386
  await handlePendingToolCalls(data);
253
387
  }
254
388
  },
389
+ holdOpen: (data) => {
390
+ // Only `completed` is worth waiting on. An error, a usage limit or a
391
+ // gate mean something else is going on, and holding the poll open would
392
+ // just be watching a conversation that is not coming back.
393
+ const owed = (data.queuedMessages ?? 0) > 0 ||
394
+ awaitingAnswerSinceRef.current !== null;
395
+ if (!owed || data.status !== 'completed') {
396
+ queueGraceTicksRef.current = 0;
397
+ return false;
398
+ }
399
+ queueGraceTicksRef.current += 1;
400
+ if (queueGraceTicksRef.current <= QUEUE_HANDOVER_GRACE_TICKS)
401
+ return true;
402
+ // Nothing came back in time: stop pretending it will.
403
+ logRef.current.warn('[useDevicChat] queue handover window expired, stopping the watch');
404
+ awaitingAnswerSinceRef.current = null;
405
+ return false;
406
+ },
255
407
  onStop: (data) => {
256
408
  logRef.current.log('[useDevicChat] onStop called, status:', data?.status);
257
409
  setShouldPoll(false);
410
+ queueGraceTicksRef.current = 0;
258
411
  if (data?.status === 'limit_exceeded') {
259
412
  // The message was blocked by a tenant/subtenant usage limit before
260
413
  // reaching the LLM. Surface the details so the UI can show a banner.
@@ -345,15 +498,23 @@ function useDevicChat(options) {
345
498
  const err = new Error('API client not configured. Please provide an API key.');
346
499
  setError(err);
347
500
  onErrorRef.current?.(err);
348
- return;
501
+ return { rejected: true, reason: 'error', message: err.message, restoredText: message };
502
+ }
503
+ // Whether something was already running when this was written. If it was,
504
+ // this send must not restate it: turning the indicator on and off around a
505
+ // message that merely joined a queue would report on a run it has nothing
506
+ // to do with — and a refusal would then stop an indicator for a run that
507
+ // is still perfectly alive.
508
+ const wasBusy = isLoadingRef.current;
509
+ if (!wasBusy) {
510
+ setIsLoading(true);
511
+ setStatus('processing');
349
512
  }
350
- setIsLoading(true);
351
513
  setError(null);
352
514
  setLimitExceeded(null);
353
- setStatus('processing');
354
515
  // Add user message optimistically (show file names before upload)
355
516
  const userMessage = {
356
- uid: `temp-${Date.now()}`,
517
+ uid: `temp-${Date.now()}-${optimisticSeqRef.current++}`,
357
518
  role: 'user',
358
519
  content: {
359
520
  message,
@@ -451,9 +612,53 @@ function useDevicChat(options) {
451
612
  // Start polling for results
452
613
  logRef.current.log('[useDevicChat] Setting shouldPoll to true');
453
614
  setShouldPoll(true);
615
+ if (response.queued) {
616
+ // Accepted, but not on its way to the model yet. Draw it as such, and
617
+ // remember it: the poll has to keep running until it comes back inside
618
+ // the conversation, however long the run in flight takes.
619
+ rememberAwaiting();
620
+ const queuedAt = Date.now();
621
+ setMessages((prev) => prev.map((m) => m.uid === userMessage.uid ? { ...m, queued: true, queuedAt } : m));
622
+ setQueuedCount(response.queuePosition || 1);
623
+ return {
624
+ queued: true,
625
+ queuePosition: response.queuePosition || 1,
626
+ willProcess: response.willProcess || 'next_turn',
627
+ };
628
+ }
629
+ return { queued: false };
454
630
  }
455
631
  catch (err) {
456
632
  const error = err instanceof Error ? err : new Error(String(err));
633
+ // Nothing was accepted either way, so the bubble goes.
634
+ setMessages((prev) => prev.filter((m) => m.uid !== userMessage.uid));
635
+ // The conversation turning a message down is not the conversation
636
+ // breaking. Two shapes of the same 409: an assistant that does not queue
637
+ // says so by name, and a full queue comes back as a plain conflict.
638
+ const refusal = err instanceof client.DevicApiError
639
+ ? err.errorType === 'CHAT_BUSY'
640
+ ? 'chat_busy'
641
+ : err.statusCode === 409
642
+ ? 'queue_full'
643
+ : null
644
+ : null;
645
+ if (refusal) {
646
+ logRef.current.log('[useDevicChat] send refused:', refusal, error.message);
647
+ // Deliberately leaves `isLoading`, `status` and `error` alone: the run
648
+ // this message was written into is still going, and reporting the
649
+ // refusal by stopping its indicator — or by painting the conversation
650
+ // as failed — would be a lie about the run, not about the send.
651
+ if (!wasBusy) {
652
+ setIsLoading(false);
653
+ setStatus('idle');
654
+ }
655
+ return {
656
+ rejected: true,
657
+ reason: refusal,
658
+ message: error.message,
659
+ restoredText: message,
660
+ };
661
+ }
457
662
  // A synchronous usage-limit block surfaces as HTTP 429 /
458
663
  // TENANT_LIMIT_EXCEEDED (sync send path). Async sends surface it via the
459
664
  // realtime `limit_exceeded` status instead — both are handled.
@@ -465,11 +670,20 @@ function useDevicChat(options) {
465
670
  setLimitExceeded(details);
466
671
  }
467
672
  setError(error);
468
- setIsLoading(false);
469
- setStatus('error');
673
+ // Same reasoning as the refusal above: a send that failed while a run
674
+ // was already going says nothing about that run, which the poll is still
675
+ // watching. The error is reported either way.
676
+ if (!wasBusy) {
677
+ setIsLoading(false);
678
+ setStatus('error');
679
+ }
470
680
  onErrorRef.current?.(error);
471
- // Remove optimistic user message on error
472
- setMessages((prev) => prev.filter((m) => m.uid !== userMessage.uid));
681
+ return {
682
+ rejected: true,
683
+ reason: 'error',
684
+ message: error.message,
685
+ restoredText: message,
686
+ };
473
687
  }
474
688
  }, [
475
689
  chatUid,
@@ -487,6 +701,7 @@ function useDevicChat(options) {
487
701
  toolSchemas,
488
702
  onMessageSent,
489
703
  onFileUpload,
704
+ rememberAwaiting,
490
705
  ]);
491
706
  // Clear chat
492
707
  const clearChat = React.useCallback(() => {
@@ -504,9 +719,10 @@ function useDevicChat(options) {
504
719
  setError(null);
505
720
  setLimitExceeded(null);
506
721
  setRecalledMemories([]);
722
+ resetQueueState();
507
723
  pendingWidgetCallsRef.current = [];
508
724
  setPendingWidgetCalls([]);
509
- }, []);
725
+ }, [resetQueueState]);
510
726
  // Load existing chat
511
727
  const loadChat = React.useCallback(async (loadChatUid) => {
512
728
  if (!clientRef.current) {
@@ -528,6 +744,8 @@ function useDevicChat(options) {
528
744
  setIsLoading(true);
529
745
  setError(null);
530
746
  setRecalledMemories([]);
747
+ // The queue belongs to the conversation being left behind.
748
+ resetQueueState();
531
749
  try {
532
750
  const history = await clientRef.current.getChatHistory(assistantId, loadChatUid, { tenantId: resolvedTenantId });
533
751
  setMessages(history.chatContent);
@@ -542,7 +760,13 @@ function useDevicChat(options) {
542
760
  onErrorRef.current?.(error);
543
761
  setIsLoading(false);
544
762
  }
545
- }, [assistantId, resolvedTenantId, resumeFromRealtimeStatus, mergeRecalledMemories]);
763
+ }, [
764
+ assistantId,
765
+ resolvedTenantId,
766
+ resumeFromRealtimeStatus,
767
+ mergeRecalledMemories,
768
+ resetQueueState,
769
+ ]);
546
770
  // Handoff polling: while handedOff is true, poll the realtime endpoint every
547
771
  // 5s (or the configured cadence) to detect when the parent thread is no
548
772
  // longer in handed_off state.
@@ -629,19 +853,38 @@ function useDevicChat(options) {
629
853
  const stopChat = React.useCallback(async () => {
630
854
  const uid = chatUidRef.current;
631
855
  logRef.current.log('[useDevicChat] stopChat called, chatUid:', uid);
856
+ let discarded = [];
632
857
  if (clientRef.current && uid) {
633
858
  try {
634
- await clientRef.current.stopChat(assistantId, uid);
859
+ const result = await clientRef.current.stopChat(assistantId, uid);
860
+ discarded = result?.discardedMessages ?? [];
635
861
  logRef.current.log('[useDevicChat] stopChat API call succeeded');
636
862
  }
637
863
  catch (err) {
638
864
  logRef.current.warn('[useDevicChat] stopChat API call failed:', err);
639
865
  }
640
866
  }
867
+ // Stopping throws away whatever was queued behind the run — answering it
868
+ // would be the opposite of what was just asked for. The bubbles go with it,
869
+ // and the text is handed back so it can return to the box instead of
870
+ // disappearing. An API that does not report what it discarded leaves the
871
+ // bubbles alone, and the next poll has the last word.
872
+ if (discarded.length) {
873
+ setMessages((prev) => prev.filter((m) => !m.queued));
874
+ }
875
+ resetQueueState();
641
876
  setShouldPoll(false);
642
877
  setIsLoading(false);
643
878
  setStatus('idle');
644
- }, [assistantId]);
879
+ const restoredText = discarded
880
+ .map((m) => m.content?.message)
881
+ .filter(Boolean)
882
+ .join('\n');
883
+ return {
884
+ discarded: discarded.length,
885
+ ...(restoredText ? { restoredText } : {}),
886
+ };
887
+ }, [assistantId, resetQueueState]);
645
888
  return {
646
889
  messages,
647
890
  chatUid,
@@ -652,6 +895,8 @@ function useDevicChat(options) {
652
895
  recalledMemories,
653
896
  handedOff,
654
897
  handedOffSubThreadId,
898
+ queuedCount,
899
+ queueEnabled,
655
900
  sendMessage,
656
901
  clearChat,
657
902
  loadChat,