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