@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.
- package/README.md +68 -1
- package/dist/cjs/api/client.js +15 -0
- package/dist/cjs/api/client.js.map +1 -1
- package/dist/cjs/api/types.js.map +1 -1
- package/dist/cjs/components/ChatDrawer/ChatDrawer.js +138 -11
- package/dist/cjs/components/ChatDrawer/ChatDrawer.js.map +1 -1
- package/dist/cjs/components/ChatDrawer/ChatInput.js +69 -13
- package/dist/cjs/components/ChatDrawer/ChatInput.js.map +1 -1
- package/dist/cjs/components/ChatDrawer/ChatMessages.js +6 -2
- package/dist/cjs/components/ChatDrawer/ChatMessages.js.map +1 -1
- package/dist/cjs/components/ChatDrawer/QueueNotice.js +46 -0
- package/dist/cjs/components/ChatDrawer/QueueNotice.js.map +1 -0
- package/dist/cjs/components/IntegrationsModal/integrationChoice.js +59 -0
- package/dist/cjs/components/IntegrationsModal/integrationChoice.js.map +1 -0
- package/dist/cjs/hooks/useDevicChat.js +277 -32
- package/dist/cjs/hooks/useDevicChat.js.map +1 -1
- package/dist/cjs/hooks/usePolling.js +5 -2
- package/dist/cjs/hooks/usePolling.js.map +1 -1
- package/dist/cjs/index.js +7 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/styles.css +1 -1
- package/dist/esm/api/client.d.ts +2 -5
- package/dist/esm/api/client.js +15 -0
- package/dist/esm/api/client.js.map +1 -1
- package/dist/esm/api/types.d.ts +69 -1
- package/dist/esm/api/types.js.map +1 -1
- package/dist/esm/components/ChatDrawer/ChatDrawer.js +139 -12
- package/dist/esm/components/ChatDrawer/ChatDrawer.js.map +1 -1
- package/dist/esm/components/ChatDrawer/ChatDrawer.types.d.ts +56 -4
- package/dist/esm/components/ChatDrawer/ChatInput.js +69 -13
- package/dist/esm/components/ChatDrawer/ChatInput.js.map +1 -1
- package/dist/esm/components/ChatDrawer/ChatMessages.js +6 -2
- package/dist/esm/components/ChatDrawer/ChatMessages.js.map +1 -1
- package/dist/esm/components/ChatDrawer/QueueNotice.d.ts +23 -0
- package/dist/esm/components/ChatDrawer/QueueNotice.js +44 -0
- package/dist/esm/components/ChatDrawer/QueueNotice.js.map +1 -0
- package/dist/esm/components/ChatDrawer/index.d.ts +2 -0
- package/dist/esm/components/IntegrationsModal/index.d.ts +1 -0
- package/dist/esm/components/IntegrationsModal/integrationChoice.d.ts +16 -0
- package/dist/esm/components/IntegrationsModal/integrationChoice.js +54 -0
- package/dist/esm/components/IntegrationsModal/integrationChoice.js.map +1 -0
- package/dist/esm/hooks/index.d.ts +1 -1
- package/dist/esm/hooks/useDevicChat.d.ts +65 -5
- package/dist/esm/hooks/useDevicChat.js +277 -32
- package/dist/esm/hooks/useDevicChat.js.map +1 -1
- package/dist/esm/hooks/usePolling.d.ts +12 -0
- package/dist/esm/hooks/usePolling.js +5 -2
- package/dist/esm/hooks/usePolling.js.map +1 -1
- package/dist/esm/index.d.ts +5 -5
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/styles.css +1 -1
- 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
|
-
/**
|
|
797
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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,
|
|
567
|
+
}, [enableFileUploads, inputDisabled]);
|
|
528
568
|
const handleDragOver = useCallback((e) => {
|
|
529
|
-
if (!enableFileUploads ||
|
|
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,
|
|
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 ||
|
|
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,
|
|
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:
|
|
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:
|
|
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).
|