@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
@@ -1,5 +1,6 @@
1
- import type { ChatMessage, ModelInterfaceTool, ChatFile, AgentThreadDto, AgentDto, ToolGroupConfig, WhisperTranscriptionResponse, TenantLimitExceeded, RecalledMemoryRecord } from '../../api/types';
1
+ import type { ChatMessage, ModelInterfaceTool, ChatFile, AgentThreadDto, AgentDto, ToolGroupConfig, WhisperTranscriptionResponse, TenantLimitExceeded, RecalledMemoryRecord, QueueDisposition } from '../../api/types';
2
2
  import type { PendingWidgetCall } from '../../hooks/useModelInterface';
3
+ import type { SendMessageResult } from '../../hooks/useDevicChat';
3
4
  import type { AIReference } from '../../provider/types';
4
5
  import type { UsageBarDisplay, UsageBarData } from './UsageBar';
5
6
  import type { RecalledMemoriesRenderer } from './RecalledMemoriesWidget';
@@ -83,6 +84,13 @@ export interface CustomPromptBoxProps {
83
84
  * their own notice.
84
85
  */
85
86
  limitExceeded?: TenantLimitExceeded | null;
87
+ /**
88
+ * Whether this assistant accepts messages written while it is working. With
89
+ * it off, sending during a run is refused and the box should stay closed.
90
+ */
91
+ queueEnabled?: boolean;
92
+ /** How many messages are waiting their turn on this conversation. */
93
+ queuedCount?: number;
86
94
  }
87
95
  /**
88
96
  * Allowed file types for upload. Each flag turns on a family of MIME types:
@@ -469,6 +477,32 @@ export interface ChatDrawerOptions {
469
477
  * The input is disabled regardless while the limit is active.
470
478
  */
471
479
  limitBannerRenderer?: (limit: TenantLimitExceeded) => React.ReactNode;
480
+ /**
481
+ * Hide the notice shown above the input while a message can be — or already
482
+ * is — queued. The queue itself keeps working; only the explanation goes.
483
+ * @default false
484
+ */
485
+ hideQueueNotice?: boolean;
486
+ /**
487
+ * Render your own version of that notice. Receives how many messages are
488
+ * waiting (0 while the user is only writing) and what the last accepted one
489
+ * was told about when it will be picked up.
490
+ */
491
+ queueNoticeRenderer?: (state: {
492
+ queuedCount: number;
493
+ willProcess?: QueueDisposition;
494
+ /** Set when a message was refused, or discarded by a stop. */
495
+ alert?: string;
496
+ }) => React.ReactNode;
497
+ /**
498
+ * Whether messages may be written while the assistant is working.
499
+ *
500
+ * Left unset it follows the assistant's own `messageQueueEnabled` setting,
501
+ * resolved through the lookup the drawer already makes. Set it to pin the
502
+ * behaviour regardless — `false` keeps the input closed during a run, which is
503
+ * how the drawer behaved before queueing existed.
504
+ */
505
+ messageQueue?: boolean;
472
506
  /**
473
507
  * Show the "Recalled memories" strip interleaved with the messages that
474
508
  * brought long-term memories into the conversation (including while the
@@ -745,9 +779,14 @@ export interface ChatMessagesProps {
745
779
  * Props for ChatInput component
746
780
  */
747
781
  export interface ChatInputProps {
782
+ /**
783
+ * Sends the message. May report back that the conversation turned it down —
784
+ * the input clears itself on submit, so a rejected send has to hand the text
785
+ * back or it is gone.
786
+ */
748
787
  onSend: (message: string, files?: File[], meta?: {
749
788
  transcriptId?: string;
750
- }) => void;
789
+ }) => void | Promise<SendMessageResult | void>;
751
790
  disabled?: boolean;
752
791
  placeholder?: string;
753
792
  enableFileUploads?: boolean;
@@ -793,8 +832,21 @@ export interface ChatInputProps {
793
832
  disabledMessage?: string;
794
833
  /** Whether the assistant is currently processing (shows stop button) */
795
834
  isProcessing?: boolean;
796
- /** Callback to stop the current processing */
797
- onStop?: () => void;
835
+ /**
836
+ * Stops the current processing. May return the text of anything the stop
837
+ * discarded, so it can be put back in the box rather than lost.
838
+ */
839
+ onStop?: () => void | Promise<{
840
+ restoredText?: string;
841
+ } | void>;
842
+ /**
843
+ * Whether a message may be written while the assistant is working. With it
844
+ * on, stop and send are shown together and the textarea stays live; with it
845
+ * off the box closes during a run, as it always has.
846
+ */
847
+ allowQueueing?: boolean;
848
+ /** Notice rendered above the textarea while messages can be, or are, queued. */
849
+ queueNotice?: React.ReactNode;
798
850
  /** Custom stop button content */
799
851
  stopButtonContent?: React.ReactNode;
800
852
  /** Pending widget tool call to render replacing the input (render: 'input') */
@@ -62,7 +62,7 @@ function ChatInput(props) {
62
62
  return jsx(ChatInputBox, { ...props });
63
63
  }
64
64
  function ChatInputBox({ onSend, disabled = false, placeholder = 'Type a message...', enableFileUploads = false, allowedFileTypes = { images: true, documents: true }, maxFileSize = 10 * 1024 * 1024, // 10MB
65
- enableLongTextPaste = false, longTextPasteThreshold = 2000, enableSpeechToText = false, speechLanguage, speechTenantId, speechAutoStop = true, speechAutoStopCountdownMs, speechAutoStopSilenceMs, speechAutoStopSilenceRatio, speechAutoStopSilenceLevel, speechAutoStopSpeechLevel, speechHandoff = false, speechHandoffSendDelayMs, speechHandoffHoldMs, apiKey, baseUrl, sendButtonContent, disabledMessage, isProcessing = false, onStop, stopButtonContent, references, onRemoveReference, usageBar, limitBanner, integrationsHint, integrationsToggle, }) {
65
+ enableLongTextPaste = false, longTextPasteThreshold = 2000, enableSpeechToText = false, speechLanguage, speechTenantId, speechAutoStop = true, speechAutoStopCountdownMs, speechAutoStopSilenceMs, speechAutoStopSilenceRatio, speechAutoStopSilenceLevel, speechAutoStopSpeechLevel, speechHandoff = false, speechHandoffSendDelayMs, speechHandoffHoldMs, apiKey, baseUrl, sendButtonContent, disabledMessage, isProcessing = false, onStop, allowQueueing = false, queueNotice, stopButtonContent, references, onRemoveReference, usageBar, limitBanner, integrationsHint, integrationsToggle, }) {
66
66
  const [message, setMessage] = useState('');
67
67
  const [files, setFiles] = useState([]);
68
68
  // Long blocks of pasted text, kept out of the textarea and shown as cards.
@@ -182,11 +182,22 @@ enableLongTextPaste = false, longTextPasteThreshold = 2000, enableSpeechToText =
182
182
  textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
183
183
  }
184
184
  }, []);
185
+ /** Whether there is anything to send. */
186
+ const hasContent = !!message.trim() || files.length > 0 || pastedTexts.length > 0;
187
+ /**
188
+ * Busy, and this assistant does not queue: writing is pointless because the
189
+ * message would be refused. Kept apart from `disabled`, which the stop button
190
+ * also answers to — it lives in this same box and has to stay clickable.
191
+ */
192
+ const busyWithoutQueue = isProcessing && !allowQueueing;
193
+ const inputDisabled = disabled || busyWithoutQueue;
185
194
  // Handle send
186
- const handleSend = useCallback(() => {
195
+ const handleSend = useCallback(async () => {
187
196
  const trimmedMessage = message.trim();
188
197
  if (!trimmedMessage && files.length === 0 && pastedTexts.length === 0)
189
198
  return;
199
+ if (inputDisabled)
200
+ return;
190
201
  // Pasted text is shown as a card but still reaches the model in full: each
191
202
  // block is prepended to the message inside a delimiter the thread can parse
192
203
  // back out (see parsePastedBlocks in ChatMessages).
@@ -195,7 +206,13 @@ enableLongTextPaste = false, longTextPasteThreshold = 2000, enableSpeechToText =
195
206
  .filter(Boolean)
196
207
  .join('\n\n')
197
208
  : trimmedMessage;
198
- onSend(composedMessage, files.length > 0 ? files : undefined, transcriptId ? { transcriptId } : undefined);
209
+ // Cleared straight away so the box is ready for the next message, and put
210
+ // back if the conversation turned this one down — the text is the user's,
211
+ // and losing it is not an acceptable way to report a refusal.
212
+ const sentMessage = message;
213
+ const sentFiles = files;
214
+ const sentPastedTexts = pastedTexts;
215
+ const sentTranscriptId = transcriptId;
199
216
  setMessage('');
200
217
  setFiles([]);
201
218
  setPastedTexts([]);
@@ -204,9 +221,32 @@ enableLongTextPaste = false, longTextPasteThreshold = 2000, enableSpeechToText =
204
221
  if (textareaRef.current) {
205
222
  textareaRef.current.style.height = 'auto';
206
223
  }
207
- }, [message, files, pastedTexts, onSend, transcriptId]);
224
+ const result = await onSend(composedMessage, sentFiles.length > 0 ? sentFiles : undefined, sentTranscriptId ? { transcriptId: sentTranscriptId } : undefined);
225
+ if (result && 'rejected' in result) {
226
+ // Only restores what the user has not started replacing already.
227
+ setMessage((current) => current || sentMessage);
228
+ setFiles((current) => (current.length ? current : sentFiles));
229
+ setPastedTexts((current) => (current.length ? current : sentPastedTexts));
230
+ setTranscriptId((current) => current ?? sentTranscriptId);
231
+ }
232
+ }, [message, files, pastedTexts, onSend, transcriptId, inputDisabled]);
208
233
  // Keep a fresh send fn for the deferred handoff auto-send.
209
234
  handleSendRef.current = handleSend;
235
+ /**
236
+ * Stopping the run hands back whatever was queued behind it. Put that text
237
+ * back where it was written — quietly discarding something the user typed is
238
+ * the one outcome a stop should not have.
239
+ */
240
+ const handleStop = useCallback(async () => {
241
+ if (!onStop)
242
+ return;
243
+ const result = await onStop();
244
+ const restoredText = result
245
+ ?.restoredText;
246
+ if (!restoredText)
247
+ return;
248
+ setMessage((current) => [current, restoredText].filter(Boolean).join('\n'));
249
+ }, [onStop]);
210
250
  // --- Speech-to-text handlers ---
211
251
  const clearPending = useCallback(() => {
212
252
  if (pendingRafRef.current !== null) {
@@ -517,23 +557,23 @@ enableLongTextPaste = false, longTextPasteThreshold = 2000, enableSpeechToText =
517
557
  }, [enableFileUploads, enableLongTextPaste, longTextPasteThreshold, addFiles]);
518
558
  // --- Drag & drop ---
519
559
  const handleDragEnter = useCallback((e) => {
520
- if (!enableFileUploads || disabled)
560
+ if (!enableFileUploads || inputDisabled)
521
561
  return;
522
562
  if (!e.dataTransfer.types.includes('Files'))
523
563
  return;
524
564
  e.preventDefault();
525
565
  dragDepthRef.current += 1;
526
566
  setIsDraggingOver(true);
527
- }, [enableFileUploads, disabled]);
567
+ }, [enableFileUploads, inputDisabled]);
528
568
  const handleDragOver = useCallback((e) => {
529
- if (!enableFileUploads || disabled)
569
+ if (!enableFileUploads || inputDisabled)
530
570
  return;
531
571
  if (!e.dataTransfer.types.includes('Files'))
532
572
  return;
533
573
  // Without this the browser navigates away to open the dropped file.
534
574
  e.preventDefault();
535
575
  e.dataTransfer.dropEffect = 'copy';
536
- }, [enableFileUploads, disabled]);
576
+ }, [enableFileUploads, inputDisabled]);
537
577
  const handleDragLeave = useCallback((e) => {
538
578
  e.preventDefault();
539
579
  dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
@@ -541,16 +581,22 @@ enableLongTextPaste = false, longTextPasteThreshold = 2000, enableSpeechToText =
541
581
  setIsDraggingOver(false);
542
582
  }, []);
543
583
  const handleDrop = useCallback((e) => {
544
- if (!enableFileUploads || disabled)
584
+ if (!enableFileUploads || inputDisabled)
545
585
  return;
546
586
  e.preventDefault();
547
587
  dragDepthRef.current = 0;
548
588
  setIsDraggingOver(false);
549
589
  addFiles(Array.from(e.dataTransfer.files || []));
550
- }, [enableFileUploads, disabled, addFiles]);
551
- return (jsxs("div", { className: "devic-input-area", "data-dragging": isDraggingOver ? 'true' : 'false', onDragEnter: handleDragEnter, onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop, children: [isDraggingOver && (jsxs("div", { className: "devic-drop-overlay", children: [jsx(AttachIcon, {}), jsx("span", { children: "Drop files to attach" })] })), limitBanner, usageBar, integrationsHint, disabledMessage && disabled && (jsxs("div", { className: "devic-input-disabled-notice", children: [jsx(WaitingIcon, {}), disabledMessage] })), speechError && (jsx("div", { className: "devic-speech-error", role: "alert", children: speechError })), handoffActive && (jsxs("div", { className: "devic-handoff-bar", "data-waiting": isProcessing ? 'true' : 'false', children: [jsx("span", { className: "devic-handoff-dot", "aria-hidden": "true" }), jsx("span", { className: "devic-handoff-label", children: isProcessing ? 'Hands-free · waiting for reply' : 'Hands-free on' }), jsx("button", { type: "button", className: "devic-handoff-stop", onClick: cancelHandoff, title: "Stop hands-free", "aria-label": "Stop hands-free", children: jsx(CloseIcon, {}) })] })), references && references.length > 0 && (jsx("div", { className: "devic-reference-chips", children: references.map((ref) => (jsx(ReferenceChip, { label: ref.label, variant: "input", onRemove: () => onRemoveReference?.(ref.id) }, ref.id))) })), pastedTexts.length > 0 && (jsx("div", { className: "devic-pasted-cards", children: pastedTexts.map((pasted) => (jsxs("div", { className: "devic-pasted-card", children: [jsx("p", { className: "devic-pasted-card-preview", children: pastedPreview(pasted.text) }), jsxs("div", { className: "devic-pasted-card-footer", children: [jsx("span", { className: "devic-pasted-card-badge", children: "PASTED" }), jsxs("span", { className: "devic-pasted-card-meta", children: [pastedLineCount(pasted.text), " lines"] })] }), jsx("button", { className: "devic-file-remove devic-pasted-card-remove", onClick: () => removePastedText(pasted.id), type: "button", title: "Remove pasted text", "aria-label": "Remove pasted text", children: "\u00D7" })] }, pasted.id))) })), files.length > 0 && (jsx("div", { className: "devic-file-preview", children: files.map((file, idx) => (jsxs("div", { className: "devic-file-preview-item", children: [filePreviews[idx] ? (jsx("img", { className: "devic-file-preview-thumb", src: filePreviews[idx], alt: file.name })) : (jsx(FileIcon, {})), jsx("span", { children: file.name }), jsx("button", { className: "devic-file-remove", onClick: () => removeFile(idx), type: "button", children: "\u00D7" })] }, idx))) })), jsx("div", { className: "devic-input-wrapper", children: pendingSend ? (jsx("div", { className: "devic-speech-panel", "data-state": "pending", children: jsxs("div", { className: "devic-handoff-pending", children: [jsxs("div", { className: "devic-handoff-pending-icon", children: [jsx(SendCountdownRing, { progress: pendingProgress }), jsx(SendIcon, {})] }), jsxs("div", { className: "devic-handoff-pending-text", children: [jsx("span", { className: "devic-handoff-pending-title", children: "Sending\u2026 interact to cancel" }), message.trim() && (jsx("span", { className: "devic-handoff-pending-preview", children: message.trim() }))] })] }) })) : isTranscribing ? (jsxs("div", { className: "devic-speech-panel", "data-state": "processing", children: [jsx("span", { className: "devic-speech-spinner", "aria-hidden": "true" }), jsx("span", { className: "devic-speech-status", children: "Transcribing\u2026" })] })) : isRecordingActive ? (jsxs("div", { className: "devic-speech-panel", "data-state": "recording", children: [jsx("button", { className: "devic-input-btn devic-speech-cancel", onClick: cancelRecording, type: "button", title: "Cancel recording", children: jsx(CloseIcon, {}) }), jsxs("div", { className: "devic-speech-live", children: [jsx(Equalizer, { levels: recording.levels, paused: recording.isPaused }), jsx("span", { className: "devic-speech-timer", children: formatDuration(recording.durationMs) })] }), jsx("button", { className: "devic-input-btn", onClick: recording.isPaused ? recording.resume : recording.pause, type: "button", title: recording.isPaused ? 'Resume' : 'Pause', children: recording.isPaused ? jsx(PlayIcon, {}) : jsx(PauseIcon, {}) }), jsxs("div", { className: "devic-speech-confirm-wrap", "data-autostop": recording.isAutoStopping ? 'true' : 'false', children: [recording.isAutoStopping && (jsx(AutoStopRing, { progress: recording.autoStopProgress })), jsx("button", { className: "devic-input-btn devic-speech-confirm", onClick: () => void handleConfirm(), type: "button", title: recording.isAutoStopping
590
+ }, [enableFileUploads, inputDisabled, addFiles]);
591
+ return (jsxs("div", { className: "devic-input-area", "data-dragging": isDraggingOver ? 'true' : 'false', onDragEnter: handleDragEnter, onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop, children: [isDraggingOver && (jsxs("div", { className: "devic-drop-overlay", children: [jsx(AttachIcon, {}), jsx("span", { children: "Drop files to attach" })] })), limitBanner, usageBar, integrationsHint, queueNotice, disabledMessage && disabled && (jsxs("div", { className: "devic-input-disabled-notice", children: [jsx(WaitingIcon, {}), disabledMessage] })), speechError && (jsx("div", { className: "devic-speech-error", role: "alert", children: speechError })), handoffActive && (jsxs("div", { className: "devic-handoff-bar", "data-waiting": isProcessing ? 'true' : 'false', children: [jsx("span", { className: "devic-handoff-dot", "aria-hidden": "true" }), jsx("span", { className: "devic-handoff-label", children: isProcessing ? 'Hands-free · waiting for reply' : 'Hands-free on' }), jsx("button", { type: "button", className: "devic-handoff-stop", onClick: cancelHandoff, title: "Stop hands-free", "aria-label": "Stop hands-free", children: jsx(CloseIcon, {}) })] })), references && references.length > 0 && (jsx("div", { className: "devic-reference-chips", children: references.map((ref) => (jsx(ReferenceChip, { label: ref.label, variant: "input", onRemove: () => onRemoveReference?.(ref.id) }, ref.id))) })), pastedTexts.length > 0 && (jsx("div", { className: "devic-pasted-cards", children: pastedTexts.map((pasted) => (jsxs("div", { className: "devic-pasted-card", children: [jsx("p", { className: "devic-pasted-card-preview", children: pastedPreview(pasted.text) }), jsxs("div", { className: "devic-pasted-card-footer", children: [jsx("span", { className: "devic-pasted-card-badge", children: "PASTED" }), jsxs("span", { className: "devic-pasted-card-meta", children: [pastedLineCount(pasted.text), " lines"] })] }), jsx("button", { className: "devic-file-remove devic-pasted-card-remove", onClick: () => removePastedText(pasted.id), type: "button", title: "Remove pasted text", "aria-label": "Remove pasted text", children: "\u00D7" })] }, pasted.id))) })), files.length > 0 && (jsx("div", { className: "devic-file-preview", children: files.map((file, idx) => (jsxs("div", { className: "devic-file-preview-item", children: [filePreviews[idx] ? (jsx("img", { className: "devic-file-preview-thumb", src: filePreviews[idx], alt: file.name })) : (jsx(FileIcon, {})), jsx("span", { children: file.name }), jsx("button", { className: "devic-file-remove", onClick: () => removeFile(idx), type: "button", children: "\u00D7" })] }, idx))) })), jsx("div", { className: "devic-input-wrapper", children: pendingSend ? (jsx("div", { className: "devic-speech-panel", "data-state": "pending", children: jsxs("div", { className: "devic-handoff-pending", children: [jsxs("div", { className: "devic-handoff-pending-icon", children: [jsx(SendCountdownRing, { progress: pendingProgress }), jsx(SendIcon, {})] }), jsxs("div", { className: "devic-handoff-pending-text", children: [jsx("span", { className: "devic-handoff-pending-title", children: "Sending\u2026 interact to cancel" }), message.trim() && (jsx("span", { className: "devic-handoff-pending-preview", children: message.trim() }))] })] }) })) : isTranscribing ? (jsxs("div", { className: "devic-speech-panel", "data-state": "processing", children: [jsx("span", { className: "devic-speech-spinner", "aria-hidden": "true" }), jsx("span", { className: "devic-speech-status", children: "Transcribing\u2026" })] })) : isRecordingActive ? (jsxs("div", { className: "devic-speech-panel", "data-state": "recording", children: [jsx("button", { className: "devic-input-btn devic-speech-cancel", onClick: cancelRecording, type: "button", title: "Cancel recording", children: jsx(CloseIcon, {}) }), jsxs("div", { className: "devic-speech-live", children: [jsx(Equalizer, { levels: recording.levels, paused: recording.isPaused }), jsx("span", { className: "devic-speech-timer", children: formatDuration(recording.durationMs) })] }), jsx("button", { className: "devic-input-btn", onClick: recording.isPaused ? recording.resume : recording.pause, type: "button", title: recording.isPaused ? 'Resume' : 'Pause', children: recording.isPaused ? jsx(PlayIcon, {}) : jsx(PauseIcon, {}) }), jsxs("div", { className: "devic-speech-confirm-wrap", "data-autostop": recording.isAutoStopping ? 'true' : 'false', children: [recording.isAutoStopping && (jsx(AutoStopRing, { progress: recording.autoStopProgress })), jsx("button", { className: "devic-input-btn devic-speech-confirm", onClick: () => void handleConfirm(), type: "button", title: recording.isAutoStopping
552
592
  ? 'Auto-sending… keep talking to cancel'
553
- : 'Confirm', children: jsx(CheckIcon, {}) })] })] })) : (jsxs(Fragment, { children: [integrationsToggle, enableFileUploads && (jsxs(Fragment, { children: [jsx("input", { ref: fileInputRef, type: "file", accept: acceptedTypes, multiple: true, onChange: handleFileSelect, style: { display: 'none' } }), jsx("button", { className: "devic-input-btn", onClick: () => fileInputRef.current?.click(), disabled: disabled, type: "button", title: "Attach file", children: jsx(AttachIcon, {}) })] })), speechEnabled && (jsxs("div", { className: "devic-speech-mic-wrap", "data-holding": isHolding ? 'true' : 'false', children: [isHolding && jsx(HoldRing, { progress: holdProgress }), jsx("button", { className: "devic-input-btn devic-speech-mic", onClick: speechHandoff ? undefined : startOneShotRecording, onPointerDown: speechHandoff ? handleMicPointerDown : undefined, onPointerUp: speechHandoff ? handleMicPointerUp : undefined, onPointerCancel: speechHandoff ? handleMicPointerCancel : undefined, disabled: disabled || isProcessing, type: "button", title: speechHandoff
593
+ : 'Confirm', children: jsx(CheckIcon, {}) })] })] })) : (jsxs(Fragment, { children: [integrationsToggle, enableFileUploads && (jsxs(Fragment, { children: [jsx("input", { ref: fileInputRef, type: "file", accept: acceptedTypes, multiple: true, onChange: handleFileSelect, style: { display: 'none' } }), jsx("button", { className: "devic-input-btn", onClick: () => fileInputRef.current?.click(), disabled: inputDisabled, type: "button", title: "Attach file", children: jsx(AttachIcon, {}) })] })), speechEnabled && (jsxs("div", { className: "devic-speech-mic-wrap", "data-holding": isHolding ? 'true' : 'false', children: [isHolding && jsx(HoldRing, { progress: holdProgress }), jsx("button", { className: "devic-input-btn devic-speech-mic", onClick: speechHandoff ? undefined : startOneShotRecording, onPointerDown: speechHandoff ? handleMicPointerDown : undefined, onPointerUp: speechHandoff ? handleMicPointerUp : undefined, onPointerCancel: speechHandoff ? handleMicPointerCancel : undefined,
594
+ // Dictating a message for the assistant to pick up next is a
595
+ // reasonable thing to do mid-answer, and on a phone it is the
596
+ // natural one. The hands-free loop is another matter: it
597
+ // auto-sends when the assistant finishes, which with a queue
598
+ // would chain sends — see the handoff effect below.
599
+ disabled: inputDisabled || (isProcessing && speechHandoff), type: "button", title: speechHandoff
554
600
  ? 'Tap to dictate · hold to start hands-free'
555
601
  : 'Record voice message', children: jsx(MicIcon, {}) })] })), jsx("textarea", { ref: textareaRef, className: "devic-input", value: message, onChange: (e) => {
556
602
  const value = e.target.value;
@@ -560,7 +606,17 @@ enableLongTextPaste = false, longTextPasteThreshold = 2000, enableSpeechToText =
560
606
  if (transcriptId && value.trim() === '')
561
607
  setTranscriptId(undefined);
562
608
  handleInput();
563
- }, onKeyDown: handleKeyDown, onPaste: handlePaste, placeholder: placeholder, disabled: disabled, rows: 1 }), isProcessing ? (stopButtonContent ? (jsxs("div", { className: "devic-send-btn-wrapper", children: [jsx("div", { className: "devic-send-btn-custom", "aria-hidden": "true", children: stopButtonContent }), jsx("button", { className: "devic-send-btn-overlay", onClick: onStop, type: "button", title: "Stop" })] })) : (jsx("button", { className: "devic-input-btn devic-stop-btn", onClick: onStop, type: "button", title: "Stop", children: jsx(StopIcon, {}) }))) : sendButtonContent ? (jsxs("div", { className: "devic-send-btn-wrapper", children: [jsx("div", { className: "devic-send-btn-custom", "aria-hidden": "true", children: sendButtonContent }), jsx("button", { className: "devic-send-btn-overlay", onClick: handleSend, disabled: disabled || (!message.trim() && files.length === 0 && pastedTexts.length === 0), type: "button", title: "Send message" })] })) : (jsx("button", { className: "devic-input-btn devic-send-btn", onClick: handleSend, disabled: disabled || (!message.trim() && files.length === 0 && pastedTexts.length === 0), type: "button", title: "Send message", children: jsx(SendIcon, {}) }))] })) })] }));
609
+ }, onKeyDown: handleKeyDown, onPaste: handlePaste, placeholder: busyWithoutQueue
610
+ ? 'The assistant is answering…'
611
+ : isProcessing
612
+ ? 'Write while the assistant answers — it will be queued'
613
+ : placeholder, disabled: inputDisabled, rows: 1 }), isProcessing &&
614
+ (stopButtonContent ? (jsxs("div", { className: "devic-send-btn-wrapper", children: [jsx("div", { className: "devic-send-btn-custom", "aria-hidden": "true", children: stopButtonContent }), jsx("button", { className: "devic-send-btn-overlay", onClick: handleStop, type: "button", title: "Stop" })] })) : (jsx("button", { className: "devic-input-btn devic-stop-btn", onClick: handleStop, type: "button", title: "Stop", children: jsx(StopIcon, {}) }))), (!isProcessing || (allowQueueing && hasContent)) &&
615
+ (sendButtonContent ? (jsxs("div", { className: "devic-send-btn-wrapper", children: [jsx("div", { className: "devic-send-btn-custom", "aria-hidden": "true", children: sendButtonContent }), jsx("button", { className: "devic-send-btn-overlay", onClick: handleSend, disabled: inputDisabled || !hasContent, type: "button", title: isProcessing
616
+ ? "Queue this message for the assistant's next turn"
617
+ : 'Send message' })] })) : (jsx("button", { className: "devic-input-btn devic-send-btn", onClick: handleSend, disabled: inputDisabled || !hasContent, type: "button", title: isProcessing
618
+ ? "Queue this message for the assistant's next turn"
619
+ : 'Send message', children: jsx(SendIcon, {}) })))] })) })] }));
564
620
  }
565
621
  /**
566
622
  * Live equalizer rendered from the recording amplitude levels (0..1 per bar).